SPB Git

spb/uqo-eval

Public
24.3 KB · 194 lines typescript
1// UQO Éval — Métré : méthode du relevé des quantités (quantity survey) — des plans (par niveau) + de la fiche technique vers TOUTES les2// quantités d'assemblages du catalogue : excavation, fondations, structure, enveloppe, toiture, ouvertures, isolation, finitions, cuisine, bains,3// plomberie, CVCA, électricité, garage, extérieur. Module PUR, déterministe ; chaque ligne garde sa formule, ses entrées et son origine.4import type { PlanObject } from "./types";5import { planQuantities, type PlanQuantities } from "./plan-geometry";6import { polygonAreaPx, polylineLengthPx, roofSlopeFactor } from "./geometry";7import type { BuildingSpecQS } from "./templates";89export const QS_VERSION = "metre-qs-1.0";1011export interface LevelInput { pageId: string; level: string; objects: PlanObject[]; pxPerMeter: number | null }12export interface QsLine { wbs: string; assemblyCode: string; description: string; quantity: number; unit: "m" | "m2" | "m3" | "unit" | "lump"; formula: string; inputs: Record<string, number | string>; origin: "plan" | "spec" | "derived"; pageIds: string[] }13export interface QsResult { lines: QsLine[]; warnings: string[]; summary: Record<string, number>; version: string }1415const M2_FT2 = 10.7639;16const r2 = (x: number) => Math.round(x * 100) / 100;1718interface LevelQ { level: string; pageId: string; q: PlanQuantities; footprintM2: number; perimeterM: number; rooms: { name: string; usage: string; areaM2: number; perimeterM: number }[]; extNetM2: number; extGrossM2: number; intGrossM2: number; extDoors: number; intDoors: number; windows: number; patioDoors: number; garageDoors: { w: number }[]; stairs: number; garageM2: number; isSite: boolean; isBasement: boolean }1920function analyse(l: LevelInput): LevelQ | null {21  if (!l.pxPerMeter) return null;22  const k = l.pxPerMeter, q = planQuantities(l.objects, k);23  const byId = new Map(l.objects.map((o) => [o.id, o]));24  const fps = l.objects.filter((o) => o.kind === "footprint" && o.points.length >= 3);25  const extLoop = l.objects.find((o) => o.kind === "wall" && o.closed && o.props.exterior);26  const footprintM2 = fps.length ? fps.reduce((s, f) => s + polygonAreaPx(f.points) / (k * k), 0) : extLoop ? polygonAreaPx(extLoop.points) / (k * k) : q.totals.floorAreaM2;27  const perimeterM = extLoop ? polylineLengthPx(extLoop.points, true) / k : fps[0] ? polylineLengthPx(fps[0].points, true) / k : 4 * Math.sqrt(Math.max(0, footprintM2));28  const rooms = q.rooms.map((r) => ({ name: r.name, usage: (byId.get(r.id)?.props.usage ?? "").toLowerCase(), areaM2: r.areaM2, perimeterM: r.perimeterM }));29  const ext = q.walls.filter((w) => w.exterior), int = q.walls.filter((w) => !w.exterior);30  let extDoors = 0, intDoors = 0, windows = 0, patioDoors = 0; const garageDoors: { w: number }[] = [];31  for (const o of q.openings) {32    const wall = o.wallId ? byId.get(o.wallId) : null; const onExt = !!wall?.props.exterior;33    if (o.kind === "window") windows++;34    else if (o.kind === "door") { if (/garage/i.test(byId.get(o.id)?.name ?? "") || o.widthM >= 2.4) garageDoors.push({ w: o.widthM }); else if (onExt) { if (o.widthM >= 1.5) patioDoors++; else extDoors++; } else intDoors++; }35  }36  const lvl = l.level.toLowerCase();37  return { level: l.level, pageId: l.pageId, q, footprintM2, perimeterM, rooms, extNetM2: ext.reduce((s, w) => s + w.netAreaM2, 0), extGrossM2: ext.reduce((s, w) => s + w.grossAreaM2, 0), intGrossM2: int.reduce((s, w) => s + w.grossAreaM2, 0), extDoors, intDoors, windows, patioDoors, garageDoors, stairs: l.objects.filter((o) => o.kind === "stairs").length, garageM2: rooms.filter((r) => r.usage === "garage").reduce((s, r) => s + r.areaM2, 0), isSite: lvl === "site" || lvl.startsWith("terrain"), isBasement: lvl.includes("sous-sol") || lvl.includes("basement") };38}3940export function quantitySurvey(levels: LevelInput[], spec: BuildingSpecQS, available: Set<string> | null = null): QsResult {41  const warnings: string[] = []; const lines: QsLine[] = [];42  const Ls = levels.map(analyse).filter((x): x is LevelQ => !!x);43  if (levels.some((l) => !l.pxPerMeter)) warnings.push("page(s) non calibrée(s) ignorée(s)");44  const above = Ls.filter((l) => !l.isSite && !l.isBasement), basement = Ls.find((l) => l.isBasement) ?? null, site = Ls.find((l) => l.isSite) ?? null;45  if (!above.length) { warnings.push("aucun niveau hors sol calibré : relevé impossible"); return { lines, warnings, summary: {}, version: QS_VERSION }; }46  const ground = above[0], top = above[above.length - 1];47  const add = (wbs: string, code: string, description: string, quantity: number, unit: QsLine["unit"], formula: string, inputs: Record<string, number | string>, origin: QsLine["origin"], pageIds: string[] = []) => {48    if (!(quantity > 0)) return;49    if (available && !available.has(code)) { warnings.push(`assemblage absent du catalogue : ${code}`); return; }50    lines.push({ wbs, assemblyCode: code, description, quantity: r2(quantity), unit, formula, inputs, origin, pageIds });51  };52  const allPages = above.map((l) => l.pageId);53  const F = ground.footprintM2, P = ground.perimeterM, units = Math.max(1, spec.units || 1);54  // bâtiment « garage seulement » (toutes les pièces sont des garages, pas de sous-sol) : aucun logement → ni cuisine, ni bain, ni plomberie55  const garageOnly = !basement && above.every((l) => l.rooms.length > 0 && l.rooms.every((r) => r.usage === "garage"));56  const dwellings = garageOnly ? 0 : units;57  const livable = garageOnly ? 0 : above.reduce((s, l) => s + (l.rooms.filter((r) => r.usage !== "garage").reduce((a, r) => a + r.areaM2, 0) || l.footprintM2), 0);58  const basementArea = basement?.footprintM2 ?? (spec.basement !== "none" && spec.basement !== "crawl" ? F : 0);59  const finishedBsm = spec.basement === "finished" ? basementArea * (spec.basementFinishedPct || 0.8) : 0;60  const heated = livable + finishedBsm;61  const kitchens = Math.max(dwellings, above.reduce((s, l) => s + l.rooms.filter((r) => r.usage.includes("cuisine")).length, 0));62  const baths = above.reduce((s, l) => s + l.rooms.filter((r) => r.usage === "salle de bain").length, 0) + (basement ? basement.rooms.filter((r) => r.usage === "salle de bain").length : 0);63  const powders = above.reduce((s, l) => s + l.rooms.filter((r) => r.usage === "salle d'eau").length, 0);64  const bedrooms = above.reduce((s, l) => s + l.rooms.filter((r) => r.usage === "chambre").length, 0);65  const windows = above.reduce((s, l) => s + l.windows, 0), extDoors = above.reduce((s, l) => s + l.extDoors, 0), patio = above.reduce((s, l) => s + l.patioDoors, 0), intDoors = above.reduce((s, l) => s + l.intDoors, 0);66  const garageM2 = above.reduce((s, l) => s + l.garageM2, 0);67  const qual = spec.quality;6869  /* 1. Excavation et fondations (depuis le périmètre et l'empreinte du rez-de-chaussée) */70  const hasBsm = spec.basement === "unfinished" || spec.basement === "finished";71  const depth = hasBsm ? 2.6 : spec.basement === "crawl" ? 1.5 : spec.foundation === "piers" ? 0 : 1.4;72  if (spec.foundation === "piers") add("31", "FND-PIER", "Pieux (grille 2,4 × 2,4 m)", Math.ceil(F / 5.9), "unit", "empreinte ÷ 5,9 m²", { empreinte_m2: r2(F) }, "derived", [ground.pageId]);73  else {74    if (depth > 0) {75      add("31", "SITE-EXCAV-BASEMENT", "Excavation", (F + 0.9 * P) * depth, "m3", "(empreinte + 0,9 m × périmètre) × profondeur", { empreinte_m2: r2(F), perimetre_m: r2(P), profondeur_m: depth }, "derived", [ground.pageId]);76      add("31", "SITE-BACKFILL", "Remblai", P * 0.9 * Math.max(0, depth - 0.3), "m3", "périmètre × 0,9 m × (profondeur − 0,3)", { perimetre_m: r2(P), profondeur_m: depth }, "derived", [ground.pageId]);77      add("31", "SITE-DRAIN-FRENCH", "Drain français", P, "m", "périmètre de fondation", { perimetre_m: r2(P) }, "plan", [ground.pageId]);78    }79    add("33", "SITE-SERVICE-CONNECTIONS", "Branchements (aqueduc, égout, électricité)", 1, "lump", "1 par bâtiment", {}, "spec");80    add("03", "FND-FOOTING", "Semelles filantes", P + (garageM2 ? 4 * Math.sqrt(garageM2) * 0.5 : 0), "m", "périmètre (+ ½ périmètre du garage)", { perimetre_m: r2(P), garage_m2: r2(garageM2) }, "plan", [ground.pageId]);81    const wh = spec.foundationWallHeightM || (hasBsm ? 2.44 : 1.2);82    add("03", spec.foundation === "concrete_block" ? "FND-WALL-BLOCK-8IN" : "FND-WALL-POURED-8IN", `Murs de fondation (h ${wh} m)`, P * wh, "m2", "périmètre × hauteur de mur", { perimetre_m: r2(P), hauteur_m: wh }, "derived", [ground.pageId]);83    add("07", "FND-DAMPPROOF-MEMBRANE", "Membrane d'étanchéité des fondations", P * Math.max(0, wh - 0.3), "m2", "périmètre × (hauteur − 0,3 m hors sol)", { perimetre_m: r2(P), hauteur_m: wh }, "derived", [ground.pageId]);84    if (hasBsm) { add("03", "FND-SLAB-BASEMENT-4IN", "Dalle de sous-sol 100 mm", basementArea, "m2", "aire du sous-sol", { aire_m2: r2(basementArea) }, basement ? "plan" : "derived", basement ? [basement.pageId] : [ground.pageId]); if (hasBsm) add("08", "OPN-WINDOW-BASEMENT", "Fenêtres de sous-sol", basement ? Math.max(basement.windows, 2) : Math.max(2, Math.round(P / 7.6)), "unit", basement?.windows ? "comptées sur le plan du sous-sol" : "périmètre ÷ 7,6 m (min. 2)", { perimetre_m: r2(P) }, basement?.windows ? "plan" : "derived", basement ? [basement.pageId] : []); }85    else if (spec.basement === "none") add("03", "FND-SLAB-ON-GRADE-INSULATED", "Dalle sur sol isolée", F, "m2", "empreinte", { empreinte_m2: r2(F) }, "plan", [ground.pageId]);86  }8788  /* 2. Structure (depuis les plans : murs mesurés, planchers = aires de pièces) */89  const floorsM2 = above.reduce((s, l, i) => s + (i === 0 && spec.basement === "none" && spec.foundation !== "piers" ? 0 : (l.rooms.reduce((a, r) => a + r.areaM2, 0) || l.footprintM2)), 0);90  add("06", "STR-FLOOR-IJOIST", "Planchers portés (solives)", floorsM2, "m2", spec.basement === "none" ? "Σ aires des niveaux sauf rez-de-chaussée sur dalle" : "Σ aires de plancher des niveaux", { aire_m2: r2(floorsM2) }, "plan", allPages);91  const roofF = roofSlopeFactor(spec.roofGeometry === "flat" ? 0 : spec.roofPitch);92  const gable = spec.roofGeometry === "gable" ? 2 * 0.5 * Math.sqrt(F / 1.4) * (Math.sqrt(F / 1.4) / 2) * (spec.roofPitch / 12) : 0; // 2 pignons triangulaires93  add("06", "STR-WALL-2X6-EXT", "Murs extérieurs ossature 2×6", above.reduce((s, l) => s + l.extGrossM2, 0) + gable, "m2", "Σ murs extérieurs bruts mesurés + pignons", { murs_bruts_m2: r2(above.reduce((s, l) => s + l.extGrossM2, 0)), pignons_m2: r2(gable) }, "plan", allPages);94  add("06", "STR-WALL-2X4-INT", "Cloisons ossature 2×4", above.reduce((s, l) => s + l.intGrossM2, 0), "m2", "Σ cloisons mesurées (longueur × hauteur)", { cloisons_m2: r2(above.reduce((s, l) => s + l.intGrossM2, 0)) }, "plan", allPages);95  const L = Math.sqrt(F * 1.4);96  add("06", "STR-BEAM-LVL", "Poutre centrale LVL", L, "m", "longueur du bâtiment ≈ √(empreinte × 1,4)", { empreinte_m2: r2(F) }, "derived", [ground.pageId]);97  add("05", "STR-COLUMN-STEEL", "Colonnes d'acier", Math.max(2, Math.round(L / 3)), "unit", "longueur ÷ 3 m", { longueur_m: r2(L) }, "derived");98  const roofM2 = (top.footprintM2 + garageM2) * roofF * 1.1;99  add("06", "STR-ROOF-TRUSS-OSB", "Fermes de toit + support OSB", roofM2, "m2", "(empreinte du dernier niveau + garage) × facteur de pente × 1,10 débords", { empreinte_m2: r2(top.footprintM2), facteur_pente: r2(roofF), pente: spec.roofPitch }, "derived", [top.pageId]);100  add("09", "STR-STAIRS-INTERIOR", "Escaliers intérieurs", Math.max(above.reduce((s, l) => s + l.stairs, 0), above.length - 1 + (hasBsm ? 1 : 0)), "unit", "escaliers dessinés (min. niveaux − 1 + sous-sol)", { dessines: above.reduce((s, l) => s + l.stairs, 0) }, "plan", allPages);101102  /* 3. Enveloppe, isolation, toiture */103  const extNet = above.reduce((s, l) => s + l.extNetM2, 0);104  add("07", "ENV-INSUL-WALL-R24", "Isolation des murs extérieurs R-24", extNet, "m2", "Σ murs extérieurs nets (ouvertures déduites)", { net_m2: r2(extNet) }, "plan", allPages);105  add("07", "ENV-INSUL-ATTIC-R50", "Isolation du comble R-50", top.footprintM2, "m2", "empreinte du dernier niveau", { empreinte_m2: r2(top.footprintM2) }, "plan", [top.pageId]);106  if (hasBsm) add("07", "ENV-INSUL-BASEMENT-R20", "Isolation intérieure des murs de sous-sol R-20", P * 2.44, "m2", "périmètre × 2,44 m", { perimetre_m: r2(P) }, "derived");107  const mix = normalize(spec.siding); const SID: Record<string, string> = { vinyl: "ENV-SIDING-VINYL", brick: "ENV-SIDING-BRICK", fiber_cement: "ENV-SIDING-FIBERCEMENT", wood: "ENV-SIDING-WOOD", stone: "ENV-SIDING-STONE", steel: "ENV-SIDING-STEEL" };108  for (const [kk, share] of Object.entries(mix)) add("07", SID[kk], `Revêtement ${kk} (${Math.round(share * 100)} %)`, extNet * share, "m2", "murs extérieurs nets × part", { net_m2: r2(extNet), part: share }, "spec", allPages);109  add("07", "ENV-SOFFIT-FASCIA", "Soffites et bordures de toit", P * 1.1, "m", "périmètre × 1,10", { perimetre_m: r2(P) }, "derived");110  add("07", "EXT-GUTTERS", "Gouttières", P * 0.6, "m", "périmètre × 60 %", { perimetre_m: r2(P) }, "derived");111  const roofCode = spec.roofGeometry === "flat" ? (spec.roofType === "membrane" ? "ROOF-MEMBRANE-ELASTO" : "ROOF-EPDM") : spec.roofType === "metal" ? "ROOF-METAL-STANDING" : spec.roofType === "membrane" ? "ROOF-MEMBRANE-ELASTO" : "ROOF-ASPHALT-ARCH";112  add("07", roofCode, "Couverture", roofM2, "m2", "surface de toit", { toit_m2: r2(roofM2) }, "derived", [top.pageId]);113114  /* 4. Ouvertures (comptées sur les plans ; dérivées si absentes) */115  const WIN: Record<string, string> = { pvc: "OPN-WINDOW-PVC-STD", hybrid: "OPN-WINDOW-HYBRID-STD", aluminum: "OPN-WINDOW-ALU-STD", wood: "OPN-WINDOW-WOOD-STD" };116  add("08", WIN[spec.windows] ?? "OPN-WINDOW-PVC-STD", "Fenêtres", windows || Math.round(livable * 0.118), "unit", windows ? "comptées sur les plans" : "aire habitable × 0,118 fenêtre/m²", { comptees: windows }, windows ? "plan" : "derived", allPages);117  add("08", qual === "superior" || qual === "prestige" ? "OPN-DOOR-EXT-FIBERGLASS" : "OPN-DOOR-EXT-STEEL", "Portes extérieures", extDoors || units, "unit", extDoors ? "comptées sur les plans" : "1 par logement", { comptees: extDoors }, extDoors ? "plan" : "derived", allPages);118  add("08", "OPN-DOOR-PATIO-6FT", "Portes-patio", patio, "unit", "comptées sur les plans (portes ext. ≥ 1,5 m)", { comptees: patio }, "plan", allPages);119  const intD = intDoors || bedrooms + baths + powders + units;120  add("08", qual === "superior" || qual === "prestige" ? "OPN-DOOR-INT-SOLID" : "OPN-DOOR-INT-HOLLOW", "Portes intérieures", intD, "unit", intDoors ? "comptées sur les plans" : "chambres + salles de bain + salles d'eau + 1 par logement", { comptees: intDoors, chambres: bedrooms }, intDoors ? "plan" : "derived", allPages);121  add("08", "OPN-DOOR-CLOSET-BIFOLD", "Portes de garde-robe", Math.max(bedrooms, Math.round(livable / 32)), "unit", "1 par chambre (min. aire ÷ 32 m²)", { chambres: bedrooms }, "derived");122  for (const g of above.flatMap((l) => l.garageDoors)) add("08", g.w >= 4 ? "OPN-DOOR-GARAGE-16X7" : "OPN-DOOR-GARAGE-9X7", "Porte de garage", 1, "unit", "comptée sur le plan", { largeur_m: g.w }, "plan");123124  /* 5. Garage */125  if (garageM2 > 0) { add("03", "FND-SLAB-GARAGE-5IN", "Dalle de garage 125 mm", garageM2, "m2", "aire du garage (pièce « garage »)", { garage_m2: r2(garageM2) }, "plan"); add("09", "INT-DRYWALL-5/8-CEILING", "Plafond de garage (gypse 5/8 coupe-feu)", garageM2, "m2", "aire du garage", { garage_m2: r2(garageM2) }, "plan"); add("26", "ELE-LIGHTING-STD", "Luminaires de garage", 2, "unit", "2 par garage", {}, "derived"); }126127  /* 6. Finitions intérieures (depuis les pièces : aires, périmètres, usages) */128  const roomsAll = above.flatMap((l) => l.rooms.filter((r) => r.usage !== "garage"));129  const ceilings = roomsAll.reduce((s, r) => s + r.areaM2, 0) + finishedBsm;130  const partitions2 = above.reduce((s, l) => s + l.intGrossM2, 0) * 2;131  const drywallWalls = extNet + partitions2 + (finishedBsm ? P * 2.44 * (finishedBsm / Math.max(1, basementArea)) : 0);132  add("09", "INT-DRYWALL-1/2-FINISHED", "Gypse ½ po murs (face intérieure des murs ext. + 2 faces des cloisons)", drywallWalls, "m2", "murs ext. nets + 2 × cloisons + sous-sol fini", { ext_net_m2: r2(extNet), cloisons_x2_m2: r2(partitions2) }, "plan", allPages);133  add("09", "INT-DRYWALL-5/8-CEILING", "Gypse ⅝ po plafonds", ceilings, "m2", "Σ aires des pièces (+ sous-sol fini)", { pieces_m2: r2(ceilings) }, "plan", allPages);134  add("09", "INT-PAINT-2-COATS", "Peinture 2 couches (murs + plafonds)", drywallWalls + ceilings, "m2", "gypse murs + plafonds", {}, "derived");135  const wet = roomsAll.filter((r) => r.usage.includes("salle de bain") || r.usage.includes("salle d'eau") || r.usage.includes("cuisine") || r.usage.includes("buanderie")).reduce((s, r) => s + r.areaM2, 0);136  const dry = Math.max(0, roomsAll.reduce((s, r) => s + r.areaM2, 0) - wet);137  add("09", "INT-FLOOR-CERAMIC", "Céramique (cuisine, salles de bain, salles d'eau)", wet, "m2", "aires des pièces d'eau et cuisine", { pieces_eau_m2: r2(wet) }, "plan", allPages);138  const fl = normalize(Object.fromEntries(Object.entries(spec.flooring).filter(([k]) => k !== "ceramic"))); const FLR: Record<string, string> = { hardwood: "INT-FLOOR-HARDWOOD", engineered: "INT-FLOOR-ENGINEERED", vinyl_plank: "INT-FLOOR-VINYL-PLANK", laminate: "INT-FLOOR-LAMINATE", carpet: "INT-FLOOR-CARPET" };139  for (const [kk, share] of Object.entries(fl)) if (FLR[kk]) add("09", FLR[kk], `Plancher ${kk} (${Math.round(share * 100)} %)`, dry * share, "m2", "aires des pièces sèches × part", { pieces_seches_m2: r2(dry), part: share }, "spec", allPages);140  if (finishedBsm) { add("09", "BSM-SUBFLOOR-PANELS", "Sous-plancher du sous-sol fini", finishedBsm, "m2", "aire finie", {}, "spec"); add("09", "INT-FLOOR-VINYL-PLANK", "Plancher du sous-sol fini (vinyle)", finishedBsm, "m2", "aire finie", {}, "spec"); }141  add("09", "INT-TRIM-BASEBOARD", "Plinthes", roomsAll.reduce((s, r) => s + r.perimeterM, 0), "m", "Σ périmètres des pièces", {}, "plan", allPages);142  add("09", "INT-CLOSET-SHELVING", "Tablettes de garde-robe", Math.max(2.4, bedrooms * 2.4), "m", "2,4 m par chambre", { chambres: bedrooms }, "derived");143144  /* 7. Cuisine et salles de bain */145  const KIT: Record<string, string> = { economy: "KIT-ECONOMY", standard: "KIT-STANDARD", superior: "KIT-SUPERIOR", prestige: "KIT-PRESTIGE" };146  const kitchenRooms = above.flatMap((l) => l.rooms.filter((r) => r.usage.includes("cuisine")));147  const kitLin = kitchenRooms.length ? kitchenRooms.reduce((s, r) => s + Math.min(9, Math.max(3.6, r.perimeterM * 0.45)), 0) : kitchens * 5.5;148  add("11", KIT[spec.kitchenQuality], "Armoires et comptoirs de cuisine", kitLin, "m", kitchenRooms.length ? "45 % du périmètre de chaque cuisine (3,6–9 m)" : "5,5 m par logement", { cuisines: kitchens }, kitchenRooms.length ? "plan" : "derived");149  const BATH: Record<string, string> = { economy: "BATH-ECONOMY", standard: "BATH-STANDARD", superior: "BATH-SUPERIOR", prestige: "BATH-SUPERIOR" };150  add("22", BATH[spec.bathroomQuality], "Salles de bain complètes", Math.max(baths, dwellings), "unit", baths ? "pièces « salle de bain » comptées" : "1 par logement", { comptees: baths }, baths ? "plan" : "derived");151  add("22", "BATH-POWDER", "Salles d'eau", powders, "unit", "pièces « salle d'eau » comptées", {}, "plan");152153  /* 8. Plomberie, CVCA, électricité */154  const fixtures = kitchens * 2 + Math.max(baths, dwellings) * 3 + powders * 2 + dwellings * 2;155  add("22", "MEC-PLUMBING-ROUGH-IN", "Plomberie brute (appareils)", fixtures, "unit", "2/cuisine + 3/salle de bain + 2/salle d'eau + 2/logement", { cuisines: kitchens, sdb: Math.max(baths, units), salles_eau: powders, logements: units }, "derived");156  add("22", "MEC-WATER-HEATER-60", "Chauffe-eau 60 gal", dwellings, "unit", "1 par logement", { logements: units }, "spec");157  add("22", "MEC-DRAINAGE-BASE", "Drainage et évents", dwellings ? 1 : 0, "lump", "1 par bâtiment", {}, "spec");158  switch (spec.heating) {159    case "heat_pump": add("23", "MEC-HEATPUMP-CENTRAL", "Thermopompe centrale", dwellings, "unit", "1 par logement", {}, "spec"); add("23", "MEC-FURNACE-ELECTRIC", "Fournaise électrique d'appoint", dwellings, "unit", "1 par logement", {}, "spec"); add("23", "MEC-DUCTWORK", "Conduits", heated, "m2", "aire chauffée", { aire_m2: r2(heated) }, "derived"); break;160    case "furnace_electric": add("23", "MEC-FURNACE-ELECTRIC", "Fournaise électrique", dwellings, "unit", "1 par logement", {}, "spec"); add("23", "MEC-DUCTWORK", "Conduits", heated, "m2", "aire chauffée", { aire_m2: r2(heated) }, "derived"); if (spec.hasAirConditioning) add("23", "MEC-CENTRAL-AC", "Climatisation centrale", units, "unit", "1 par logement", {}, "spec"); break;161    case "furnace_gas": add("23", "MEC-FURNACE-GAS", "Fournaise au gaz", dwellings, "unit", "1 par logement", {}, "spec"); add("23", "MEC-DUCTWORK", "Conduits", heated, "m2", "aire chauffée", { aire_m2: r2(heated) }, "derived"); if (spec.hasAirConditioning) add("23", "MEC-CENTRAL-AC", "Climatisation centrale", units, "unit", "1 par logement", {}, "spec"); break;162    default: add("23", "MEC-BASEBOARD-ELECTRIC", "Plinthes électriques 1 500 W", Math.ceil(heated / 14), "unit", "aire chauffée ÷ 14 m²", { aire_m2: r2(heated) }, "derived"); if (spec.hasAirConditioning) add("23", "MEC-HEATPUMP-WALL", "Thermopompe murale", units, "unit", "1 par logement", {}, "spec");163  }164  if (spec.hasAirExchanger) add("23", "MEC-HRV", "Échangeur d'air (VRC)", dwellings, "unit", "1 par logement", {}, "spec");165  add("26", "ELE-SERVICE-200A", "Entrée électrique 200 A", units, "unit", "1 par logement", { logements: units }, "spec");166  add("26", "ELE-ROUGH-WIRING", "Filage brut", heated, "m2", "aire chauffée", { aire_m2: r2(heated) }, "derived");167  add("26", "ELE-LIGHTING-STD", "Luminaires", Math.round(heated / 6.5), "unit", "aire chauffée ÷ 6,5 m²", { aire_m2: r2(heated) }, "derived");168  add("26", "ELE-LIGHTING-EXT", "Luminaires extérieurs", 3 + (garageM2 ? 1 : 0), "unit", "3 + 1 si garage", {}, "derived");169170  /* 9. Extérieur (plan de site si présent, sinon fiche) */171  const siteRooms = site?.rooms ?? [];172  const sum = (pred: (u: string) => boolean) => siteRooms.filter((r) => pred(r.usage)).reduce((s, r) => s + r.areaM2, 0);173  const deck = sum((u) => u.includes("terrasse") || u.includes("deck") || u.includes("balcon")) || spec.deckM2;174  const drive = sum((u) => u.includes("entrée") || u.includes("stationnement") || u.includes("driveway")) || spec.drivewayM2;175  const lotM2 = site ? (site.q.footprintM2 ?? 0) : 0; const lotObj = site?.q ? site.q : null; void lotObj;176  if (deck > 0) { add("32", "EXT-DECK-TREATED", "Terrasse en bois traité", deck, "m2", site ? "aire « terrasse » sur le plan de site" : "fiche", { terrasse_m2: r2(deck) }, site ? "plan" : "spec", site ? [site.pageId] : []); add("32", qual === "superior" || qual === "prestige" ? "EXT-DECK-RAILING-ALU" : "EXT-DECK-RAILING-WOOD", "Garde-corps de terrasse", 2.5 * Math.sqrt(deck), "m", "2,5 × √aire", {}, "derived"); }177  if (drive > 0) add("32", spec.driveway === "pavers" ? "EXT-DRIVEWAY-PAVERS" : spec.driveway === "gravel" ? "EXT-DRIVEWAY-GRAVEL" : "EXT-DRIVEWAY-ASPHALT", "Entrée de stationnement", drive, "m2", site ? "aire « entrée » sur le plan de site" : "fiche", { entree_m2: r2(drive) }, site ? "plan" : "spec", site ? [site.pageId] : []);178  if (spec.fenceM > 0) add("32", "EXT-FENCE-WOOD", "Clôture", spec.fenceM, "m", "fiche", {}, "spec");179  const landscaping = spec.landscapingM2 || (lotM2 > F ? Math.max(0, lotM2 - F - drive - deck) : 0);180  if (landscaping > 0) add("32", "EXT-LANDSCAPING-SOD", "Aménagement paysager (tourbe)", landscaping, "m2", lotM2 ? "lot − empreinte − entrée − terrasse" : "fiche", { lot_m2: r2(lotM2) }, lotM2 ? "plan" : "spec", site ? [site.pageId] : []);181  if (spec.foundation !== "piers") add("32", "EXT-STEPS-PRECAST", "Perron préfabriqué", 1, "unit", "1 par bâtiment", {}, "derived");182183  const summary = { empreinte_m2: r2(F), perimetre_m: r2(P), aire_habitable_m2: r2(livable), aire_chauffee_m2: r2(heated), murs_ext_nets_m2: r2(extNet), toit_m2: r2(roofM2), fenetres: windows, portes_ext: extDoors, portes_int: intD, chambres: bedrooms, salles_de_bain: Math.max(baths, units), cuisines: kitchens, logements: units, niveaux_hors_sol: above.length, garage_m2: r2(garageM2), lignes: lines.length };184  return { lines, warnings, summary, version: QS_VERSION };185}186187function normalize(m: Record<string, number | undefined>): Record<string, number> {188  const e = Object.entries(m).filter((x): x is [string, number] => typeof x[1] === "number" && x[1] > 0);189  const t = e.reduce((s, [, v]) => s + v, 0); if (!t) return {};190  return Object.fromEntries(e.map(([k, v]) => [k, v / t]));191}192193export const M2_TO_FT2 = M2_FT2;194
Files · main
Menu

Theme