SPB Git forge

spb/vrai-prix

Public

Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.

60commits 1branches 0releases
12.3 MBsize
maindefault branch
17 days agolast push
TypeScript 90.2% JavaScript 3.5% Python 3.4% CSS 1.9% HTML 0.6%
23.3 KB · 339 lines typescript
Raw Blame History
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2/**3 * Moteur de géométrie et de quantités : description du bâtiment → quantités4 * d'assemblages, chaque ligne accompagnée de sa formule. Module PUR.5 *6 * Principe (§193) : tout ce qu'une formule explicite peut calculer est calculé7 * ici, jamais par l'IA. Les constantes (hauteur d'étage 9 pi, rapport de forme8 * 1,5, 1 fenêtre de 10 pi² par 90 pi² de plancher…) sont des HYPOTHÈSES de9 * prise de quantités résidentielle, affichées dans chaque formule.10 *11 * Coût de REMPLACEMENT (et non de reproduction) : le bâtiment est reconstruit12 * avec les composantes modernes équivalentes (2 × 6 R24, R50 au comble,13 * poutrelles ajourées), quel que soit son année de construction.14 */15import type { BuildingSpec, Quality, QuantityLine, AttributeSource } from "./types";16import { KITCHEN_LINEAR_FT } from "./taxonomy";1718export const GEOMETRY_VERSION = "1.0";19const FT3_TO_M3 = 0.0283168;20export const STORY_HEIGHT_FT = 9; // 8 pi de plafond + structure de plancher21export const ASPECT_RATIO = 1.5; // rectangle longueur/largeur22export const WINDOW_SQFT = 10; // fenêtre type ~30 × 48 po23export const WINDOW_PER_SQFT_FLOOR = 0.011; // ≈ 11 % de la surface de plancher vitrée24export const PARTITION_LF_PER_SQFT = 0.09; // pi lin de cloison par pi² de plancher25export const BASEMENT_WALL_FT = 8;26export const GARAGE_WALL_FT = 10;2728export interface Geometry {29  footprintSqft: number;30  widthFt: number;31  lengthFt: number;32  perimeterFt: number;33  exposedPerimeterFt: number; // murs extérieurs non mitoyens34  exposedShare: number;35  wallHeightFt: number;36  grossWallSqft: number;37  gableSqft: number;38  windowCount: number;39  extDoors: number;40  patioDoors: number;41  openingsSqft: number;42  netWallSqft: number;43  roofSlopeFactor: number;44  roofSqft: number;45  basementFloorSqft: number;46  basementFinishedSqft: number;47  garageSqft: number;48  garagePerimeterFt: number;49  garageExposedPerimeterFt: number;50}5152const r1 = (x: number) => Math.round(x * 10) / 10;53const r0 = (x: number) => Math.round(x);5455export function computeGeometry(b: BuildingSpec): Geometry {56  const stories = Math.max(1, b.stories || 1);57  const footprint = b.footprintSqft && b.footprintSqft > 100 ? b.footprintSqft : b.grossFloorAreaSqft / stories;58  const width = Math.sqrt(footprint / ASPECT_RATIO);59  const length = ASPECT_RATIO * width;60  const perimeter = 2 * (width + length);61  const exposedShare = b.type === "semi_detached" ? 1 - length / perimeter : b.type === "row" || b.type === "condo" ? 1 - (2 * length) / perimeter : 1;62  const exposedPerimeter = perimeter * exposedShare;63  const grossWall = exposedPerimeter * STORY_HEIGHT_FT * stories;64  const pitch = b.roofGeometry === "flat" ? 0 : b.roofPitch || 6;65  const gable = b.roofGeometry === "gable" && b.type !== "condo" ? 2 * ((width * width * pitch) / 24) * (b.type === "detached" || b.type === "chalet" ? 1 : 0.5) : 0;66  const windowCount = b.windowCount ?? Math.max(4, Math.round(b.grossFloorAreaSqft * WINDOW_PER_SQFT_FLOOR));67  const extDoors = b.type === "row" || b.type === "condo" ? 1 : 2;68  const patioDoors = b.type === "condo" ? 1 : b.deckSqft > 0 || b.quality !== "economy" ? 1 : 0;69  const openings = windowCount * WINDOW_SQFT + extDoors * 20 + patioDoors * 40;70  const slope = Math.sqrt(1 + (pitch / 12) ** 2);71  const complexity = b.roofGeometry === "hip" ? 1.05 : b.roofGeometry === "complex" ? 1.12 : b.roofGeometry === "mansard" ? 1.15 : 1;72  const garageSqft = b.garage.type === "none" || b.garage.type === "carport" ? 0 : b.garage.areaSqft ?? (b.garage.spaces >= 3 ? 700 : b.garage.spaces === 2 ? 480 : 264);73  const roofFootprint = footprint + (b.garage.type === "attached" ? garageSqft : 0);74  const roofSqft = b.type === "condo" ? 0 : roofFootprint * slope * 1.1 * complexity;75  const basementFloor = b.basement === "none" || b.basement === "crawl" ? 0 : footprint * 0.9;76  const finishedPct = b.basement === "finished" || b.basement === "walkout" ? Math.max(b.basementFinishedPct, 0.9) : b.basement === "partial" ? Math.max(b.basementFinishedPct, 0.5) : b.basementFinishedPct;77  const gw = Math.sqrt(garageSqft / 1.1);78  const garagePerimeter = garageSqft ? 2 * (gw + 1.1 * gw) : 0;79  const garageExposed = b.garage.type === "detached" ? garagePerimeter : b.garage.type === "attached" ? garagePerimeter * 0.75 : b.garage.type === "integrated" ? garagePerimeter * 0.5 : 0;80  return {81    footprintSqft: r0(footprint), widthFt: r1(width), lengthFt: r1(length), perimeterFt: r1(perimeter), exposedPerimeterFt: r1(exposedPerimeter), exposedShare: r1(exposedShare * 100) / 100,82    wallHeightFt: STORY_HEIGHT_FT, grossWallSqft: r0(grossWall), gableSqft: r0(gable), windowCount, extDoors, patioDoors, openingsSqft: r0(openings),83    netWallSqft: r0(Math.max(0, grossWall + gable - openings)), roofSlopeFactor: r1(slope * 100) / 100, roofSqft: r0(roofSqft),84    basementFloorSqft: r0(basementFloor), basementFinishedSqft: r0(basementFloor * finishedPct),85    garageSqft: r0(garageSqft), garagePerimeterFt: r1(garagePerimeter), garageExposedPerimeterFt: r1(garageExposed),86  };87}8889/** Répartition de plancher par défaut selon la qualité (si non fournie). */90export function defaultFlooring(q: Quality): BuildingSpec["flooring"] {91  switch (q) {92    case "economy": return { vinyl_plank: 0.7, laminate: 0.2, ceramic: 0.1 };93    case "standard": return { engineered: 0.5, vinyl_plank: 0.35, ceramic: 0.15 };94    case "superior": return { hardwood: 0.6, ceramic: 0.25, vinyl_plank: 0.15 };95    default: return { hardwood: 0.65, ceramic: 0.35 };96  }97}9899/**100 * Quantités d'assemblages à partir de la description du bâtiment.101 * `overrides` remplace la quantité d'un assemblage (source « user »/« AI »/« listing »).102 */103export function deriveQuantities(b: BuildingSpec, overrides: Record<string, number> = {}, overrideSource: AttributeSource = "user", excluded: string[] = []): { geometry: Geometry; lines: QuantityLine[]; warnings: string[] } {104  const g = computeGeometry(b);105  const warnings: string[] = [];106  const lines: QuantityLine[] = [];107  const stories = Math.max(1, b.stories || 1);108  const GFA = b.grossFloorAreaSqft;109  const F = g.footprintSqft;110  const P = g.perimeterFt;111  const Pe = g.exposedPerimeterFt;112  const units = Math.max(1, b.units || 1);113  const finished = g.basementFinishedSqft;114  const livable = GFA + finished;115116  const add = (code: string, qty: number, formula: string, inputs: Record<string, number | string> = {}, unit: QuantityLine["unit"] = "pi2") => {117    if (excluded.includes(code)) return;118    if (!(qty > 0)) return;119    lines.push({ assemblyCode: code, quantity: Math.round(qty * 100) / 100, unit, source: "derived", formula, inputs });120  };121122  const isCondo = b.type === "condo";123  if (isCondo) warnings.push("condo_common_areas");124125  /* ---------------------------------------------------------- site / fondations */126  if (!isCondo) {127    const hasBasement = g.basementFloorSqft > 0;128    const depthFt = hasBasement ? 9 : b.basement === "crawl" ? 5 : b.foundation === "piers" ? 0 : 4.5;129    if (depthFt > 0) {130      const excM3 = (F + 3 * P) * depthFt * FT3_TO_M3;131      add("SITE-EXCAV-BASEMENT", excM3, "(empreinte + 3 pi × périmètre) × profondeur × 0,0283 m³/pi³", { empreinte_pi2: F, perimetre_pi: P, profondeur_pi: depthFt }, "m3");132      add("SITE-BACKFILL", P * 3 * (depthFt - 1) * FT3_TO_M3, "périmètre × 3 pi × (profondeur − 1 pi) × 0,0283", { perimetre_pi: P, profondeur_pi: depthFt }, "m3");133      add("SITE-DRAIN-FRENCH", P, "périmètre de fondation", { perimetre_pi: P }, "pi_lin");134    }135    add("SITE-SERVICE-CONNECTIONS", 1, "1 branchement par bâtiment", {}, "lump");136    if (b.foundation === "piers") {137      add("FND-PIER", Math.ceil(F / 64), "empreinte ÷ 64 pi² (grille 8 × 8 pi)", { empreinte_pi2: F }, "unit");138    } else {139      add("FND-FOOTING", P, "périmètre de fondation", { perimetre_pi: P }, "pi_lin");140      const wallH = hasBasement ? BASEMENT_WALL_FT : b.basement === "crawl" ? 4 : 4;141      const wallCode = b.foundation === "concrete_block" || b.foundation === "stone" ? "FND-WALL-BLOCK-8IN" : "FND-WALL-POURED-8IN";142      add(wallCode, P * wallH, `périmètre × hauteur de mur (${wallH} pi)`, { perimetre_pi: P, hauteur_pi: wallH });143      add("FND-DAMPPROOF-MEMBRANE", P * Math.max(0, wallH - 1), "périmètre × (hauteur − 1 pi hors sol)", { perimetre_pi: P, hauteur_pi: wallH });144      if (hasBasement) {145        add("FND-SLAB-BASEMENT-4IN", F, "empreinte du bâtiment", { empreinte_pi2: F });146        add("OPN-WINDOW-BASEMENT", Math.max(2, Math.round(P / 25)), "périmètre ÷ 25 pi", { perimetre_pi: P }, "unit");147      } else if (b.basement === "none") {148        add("FND-SLAB-ON-GRADE-INSULATED", F, "empreinte du bâtiment (dalle sur sol)", { empreinte_pi2: F });149      }150    }151  }152153  /* ------------------------------------------------------------- structure */154  const floorsSqft = isCondo ? GFA : b.basement === "none" && b.foundation !== "piers" ? Math.max(0, GFA - F) : GFA;155  add("STR-FLOOR-IJOIST", floorsSqft, isCondo ? "aire de l'unité" : b.basement === "none" ? "aire d'étages − empreinte (rez-de-chaussée sur dalle)" : "aire d'étages (planchers portés)", { aire_etages_pi2: GFA, empreinte_pi2: F });156  add("STR-WALL-2X6-EXT", g.grossWallSqft + g.gableSqft, "périmètre exposé × 9 pi × étages + pignons", { perimetre_expose_pi: Pe, etages: stories, pignons_pi2: g.gableSqft });157  const partitionsLf = livable * PARTITION_LF_PER_SQFT;158  add("STR-WALL-2X4-INT", partitionsLf * STORY_HEIGHT_FT, "(aire habitable × 0,09 pi lin/pi²) × 9 pi", { aire_habitable_pi2: livable, cloisons_pi_lin: Math.round(partitionsLf) });159  if (!isCondo) {160    add("STR-BEAM-LVL", g.lengthFt, "longueur du bâtiment (poutre centrale)", { longueur_pi: g.lengthFt }, "pi_lin");161    add("STR-COLUMN-STEEL", Math.max(2, Math.round(g.lengthFt / 10)), "longueur ÷ 10 pi", { longueur_pi: g.lengthFt }, "unit");162    add("STR-ROOF-TRUSS-OSB", g.roofSqft, "(empreinte + garage attenant) × facteur de pente × 1,10 débords × complexité", { empreinte_pi2: F, facteur_pente: g.roofSlopeFactor, geometrie: b.roofGeometry });163  }164  const stairs = (stories - 1) + (g.basementFloorSqft > 0 ? 1 : 0);165  add("STR-STAIRS-INTERIOR", stairs, "(étages − 1) + 1 si sous-sol", { etages: stories }, "unit");166167  /* -------------------------------------------------------------- enveloppe */168  add("ENV-INSUL-WALL-R24", g.netWallSqft, "mur brut + pignons − ouvertures", { mur_brut_pi2: g.grossWallSqft, ouvertures_pi2: g.openingsSqft });169  if (!isCondo) add("ENV-INSUL-ATTIC-R50", F + (b.garage.type === "attached" ? 0 : 0), "empreinte (plafond du dernier étage)", { empreinte_pi2: F });170  const mix = normalizeMix(b.siding);171  const SIDING: Record<string, string> = { vinyl: "ENV-SIDING-VINYL", brick: "ENV-SIDING-BRICK", fiber_cement: "ENV-SIDING-FIBERCEMENT", wood: "ENV-SIDING-WOOD", stone: "ENV-SIDING-STONE", stucco: "ENV-SIDING-FIBERCEMENT", aluminum: "ENV-SIDING-STEEL", steel: "ENV-SIDING-STEEL" };172  const garageWall = g.garageExposedPerimeterFt * GARAGE_WALL_FT;173  for (const [k, share] of Object.entries(mix)) {174    if (share <= 0) continue;175    add(SIDING[k], (g.netWallSqft + garageWall * 0.85) * share, `(mur net + murs de garage) × ${Math.round(share * 100)} %`, { mur_net_pi2: g.netWallSqft, garage_pi2: Math.round(garageWall), part: share });176  }177  if (!isCondo) {178    add("ENV-SOFFIT-FASCIA", P, "périmètre (débords de toit)", { perimetre_pi: P }, "pi_lin");179    add("EXT-GUTTERS", P * 0.6, "périmètre × 60 % (côtés d'égouttement)", { perimetre_pi: P }, "pi_lin");180  }181182  /* ---------------------------------------------------------------- toiture */183  if (!isCondo) {184    const roofCode = b.roofGeometry === "flat" ? (b.roof === "membrane" || b.roof === "asphalt_shingle" ? "ROOF-MEMBRANE-ELASTO" : "ROOF-EPDM") : b.roof === "metal" ? "ROOF-METAL-STANDING" : b.roof === "membrane" ? "ROOF-MEMBRANE-ELASTO" : "ROOF-ASPHALT-ARCH";185    add(roofCode, g.roofSqft, "surface de toit", { toit_pi2: g.roofSqft });186  }187188  /* ------------------------------------------------------------- ouvertures */189  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" };190  add(WIN[b.windows] ?? "OPN-WINDOW-PVC-STD", g.windowCount, b.windowCount != null ? "nombre de fenêtres fourni" : "aire d'étages × 0,011 fenêtre/pi² (≈ 11 % vitré, 10 pi² chacune)", { aire_etages_pi2: GFA, fenetres: g.windowCount }, "unit");191  add(b.quality === "prestige" || b.quality === "superior" ? "OPN-DOOR-EXT-FIBERGLASS" : "OPN-DOOR-EXT-STEEL", g.extDoors, "portes extérieures selon le type de bâtiment", { type: b.type }, "unit");192  add("OPN-DOOR-PATIO-6FT", g.patioDoors, "1 porte-patio si terrasse ou qualité ≥ standard", {}, "unit");193  const intDoors = Math.max(3, Math.round(livable / 180));194  add(b.quality === "superior" || b.quality === "prestige" ? "OPN-DOOR-INT-SOLID" : "OPN-DOOR-INT-HOLLOW", intDoors, "aire habitable ÷ 180 pi²", { aire_habitable_pi2: livable }, "unit");195  add("OPN-DOOR-CLOSET-BIFOLD", Math.max(2, Math.round(livable / 350)), "aire habitable ÷ 350 pi²", { aire_habitable_pi2: livable }, "unit");196  if (g.garageSqft) add(b.garage.spaces >= 2 ? "OPN-DOOR-GARAGE-16X7" : "OPN-DOOR-GARAGE-9X7", 1, "1 porte de garage", {}, "unit");197198  /* ----------------------------------------------------------------- garage */199  if (g.garageSqft && !isCondo) {200    const Ag = g.garageSqft;201    add("FND-SLAB-GARAGE-5IN", Ag, "aire du garage", { garage_pi2: Ag });202    if (b.garage.type !== "integrated") {203      add("FND-FOOTING", g.garagePerimeterFt, "périmètre du garage", { perimetre_garage_pi: g.garagePerimeterFt }, "pi_lin");204      add("FND-WALL-POURED-8IN", g.garageExposedPerimeterFt * 4, "périmètre exposé du garage × 4 pi (mur hors gel)", { perimetre_expose_pi: g.garageExposedPerimeterFt });205    }206    add("STR-WALL-2X6-EXT", garageWall, "périmètre exposé du garage × 10 pi", { perimetre_expose_pi: g.garageExposedPerimeterFt });207    add("ENV-INSUL-WALL-R20", garageWall, "murs de garage", { garage_murs_pi2: Math.round(garageWall) });208    add("INT-DRYWALL-1/2-FINISHED", garageWall, "murs de garage (séparation coupe-feu)", { garage_murs_pi2: Math.round(garageWall) });209    add("INT-DRYWALL-5/8-CEILING", Ag, "plafond du garage", { garage_pi2: Ag });210    add("ELE-LIGHTING-STD", 2, "2 luminaires de garage", {}, "unit");211    if (b.garage.type === "detached") {212      const roofG = Ag * g.roofSlopeFactor * 1.1;213      add("STR-ROOF-TRUSS-OSB", roofG, "aire du garage × facteur de pente × 1,10", { garage_pi2: Ag, facteur_pente: g.roofSlopeFactor });214      add(b.roof === "metal" ? "ROOF-METAL-STANDING" : "ROOF-ASPHALT-ARCH", roofG, "toit du garage détaché", { toit_pi2: Math.round(roofG) });215    }216  }217218  /* -------------------------------------------------------------- intérieur */219  const drywallWalls = g.netWallSqft + partitionsLf * STORY_HEIGHT_FT * 2 + (finished ? P * BASEMENT_WALL_FT * (finished / Math.max(1, g.basementFloorSqft)) : 0);220  add("INT-DRYWALL-1/2-FINISHED", drywallWalls, "face intérieure des murs extérieurs + 2 faces des cloisons + murs du sous-sol fini", { murs_ext_pi2: g.netWallSqft, cloisons_pi2: Math.round(partitionsLf * STORY_HEIGHT_FT * 2), sous_sol_pi2: Math.round(finished ? P * BASEMENT_WALL_FT : 0) });221  add("INT-DRYWALL-5/8-CEILING", livable, "plafonds (aire habitable)", { aire_habitable_pi2: livable });222  add("INT-PAINT-2-COATS", drywallWalls + livable, "murs + plafonds", { murs_pi2: Math.round(drywallWalls), plafonds_pi2: livable });223  const bathFloors = b.bathrooms * (b.bathroomQuality === "superior" || b.bathroomQuality === "prestige" ? 60 : b.bathroomQuality === "standard" ? 45 : 40) + b.powderRooms * 20;224  const floorArea = Math.max(0, GFA - bathFloors);225  const fmix = normalizeMix(Object.keys(b.flooring).length ? b.flooring : defaultFlooring(b.quality));226  const FLOOR: Record<string, string> = { hardwood: "INT-FLOOR-HARDWOOD", engineered: "INT-FLOOR-ENGINEERED", vinyl_plank: "INT-FLOOR-VINYL-PLANK", laminate: "INT-FLOOR-LAMINATE", ceramic: "INT-FLOOR-CERAMIC", carpet: "INT-FLOOR-CARPET" };227  for (const [k, share] of Object.entries(fmix)) if (share > 0 && FLOOR[k]) add(FLOOR[k], floorArea * share, `(aire d'étages − planchers de salles de bain) × ${Math.round(share * 100)} %`, { aire_pi2: floorArea, part: share });228  if (finished) {229    add("BSM-SUBFLOOR-PANELS", finished, "aire finie du sous-sol", { sous_sol_fini_pi2: finished });230    add("INT-FLOOR-VINYL-PLANK", finished, "aire finie du sous-sol (vinyle)", { sous_sol_fini_pi2: finished });231  }232  if (g.basementFloorSqft) add("ENV-INSUL-BASEMENT-R20", P * BASEMENT_WALL_FT, "périmètre × 8 pi (isolation intérieure des murs de sous-sol)", { perimetre_pi: P });233  add("INT-TRIM-BASEBOARD", partitionsLf * 2 + Pe * stories + (finished ? P : 0), "2 × cloisons + périmètre exposé × étages + sous-sol fini", { cloisons_pi_lin: Math.round(partitionsLf), perimetre_expose_pi: Pe }, "pi_lin");234  add("INT-CLOSET-SHELVING", Math.max(8, livable / 60), "aire habitable ÷ 60", { aire_habitable_pi2: livable }, "pi_lin");235236  /* ------------------------------------------------------ cuisine / bains */237  const KIT: Record<Quality, string> = { economy: "KIT-ECONOMY", standard: "KIT-STANDARD", superior: "KIT-SUPERIOR", prestige: "KIT-PRESTIGE" };238  const sizeFactor = Math.min(1.3, Math.max(0.8, GFA / units / 1800));239  add(KIT[b.kitchenQuality], KITCHEN_LINEAR_FT[b.kitchenQuality] * sizeFactor * Math.max(1, b.kitchens), `${KITCHEN_LINEAR_FT[b.kitchenQuality]} pi lin (qualité) × facteur de taille ${sizeFactor.toFixed(2)} × cuisines`, { cuisines: b.kitchens, facteur_taille: Math.round(sizeFactor * 100) / 100 }, "pi_lin");240  const nb = Math.max(0, b.bathrooms);241  if (nb) {242    if (b.bathroomQuality === "prestige") add("BATH-SUPERIOR", nb, "toutes les salles de bain (prestige)", {}, "unit");243    else if (b.bathroomQuality === "superior") {244      add("BATH-SUPERIOR", Math.min(nb, units), "1 salle de bain principale supérieure par logement", {}, "unit");245      add("BATH-STANDARD", Math.max(0, nb - units), "autres salles de bain (standard)", {}, "unit");246    } else if (b.bathroomQuality === "standard") add("BATH-STANDARD", nb, "salles de bain standard", {}, "unit");247    else add("BATH-ECONOMY", nb, "salles de bain économiques", {}, "unit");248  }249  add("BATH-POWDER", b.powderRooms, "salles d'eau", {}, "unit");250251  /* ------------------------------------------------------------- mécanique */252  const fixtures = Math.max(1, b.kitchens) * 2 + nb * 3 + b.powderRooms * 2 + units * 2; // laveuse + chauffe-eau par logement253  add("MEC-PLUMBING-ROUGH-IN", fixtures, "2 par cuisine + 3 par salle de bain + 2 par salle d'eau + 2 par logement (laveuse, chauffe-eau)", { cuisines: b.kitchens, sdb: nb, salles_eau: b.powderRooms, logements: units }, "unit");254  add("MEC-WATER-HEATER-60", units, "1 par logement", { logements: units }, "unit");255  if (!isCondo) add("MEC-DRAINAGE-BASE", 1, "1 par bâtiment", {}, "lump");256  const heated = livable;257  switch (b.heating) {258    case "heat_pump":259    case "geothermal":260      add("MEC-HEATPUMP-CENTRAL", units, "1 par logement", {}, "unit");261      add("MEC-FURNACE-ELECTRIC", units, "fournaise électrique d'appoint", {}, "unit");262      add("MEC-DUCTWORK", heated, "aire chauffée", { aire_pi2: heated });263      if (b.heating === "geothermal") warnings.push("geothermal_as_heatpump");264      break;265    case "furnace_electric":266    case "hydronic":267      add("MEC-FURNACE-ELECTRIC", units, "1 par logement", {}, "unit");268      add("MEC-DUCTWORK", heated, "aire chauffée", { aire_pi2: heated });269      if (b.hasAirConditioning) add("MEC-CENTRAL-AC", units, "climatisation centrale", {}, "unit");270      if (b.heating === "hydronic") warnings.push("hydronic_as_furnace");271      break;272    case "furnace_gas":273    case "furnace_oil":274      add("MEC-FURNACE-GAS", units, "1 par logement", {}, "unit");275      add("MEC-DUCTWORK", heated, "aire chauffée", { aire_pi2: heated });276      if (b.hasAirConditioning) add("MEC-CENTRAL-AC", units, "climatisation centrale", {}, "unit");277      break;278    default: {279      add("MEC-BASEBOARD-ELECTRIC", Math.ceil(heated / 150), "aire chauffée ÷ 150 pi² par plinthe de 1 500 W", { aire_pi2: heated }, "unit");280      if (b.hasAirConditioning) add("MEC-HEATPUMP-WALL", units, "thermopompe murale (climatisation)", {}, "unit");281      if (b.heating === "wood") warnings.push("wood_as_baseboard");282    }283  }284  if (b.hasAirExchanger) add("MEC-HRV", units, "1 par logement", {}, "unit");285286  /* ------------------------------------------------------------ électricité */287  add("ELE-SERVICE-200A", units, "1 entrée par logement", { logements: units }, "unit");288  add("ELE-ROUGH-WIRING", livable, "aire habitable", { aire_habitable_pi2: livable });289  add("ELE-LIGHTING-STD", Math.round(livable / 70), "aire habitable ÷ 70 pi²", { aire_habitable_pi2: livable }, "unit");290  if (!isCondo) add("ELE-LIGHTING-EXT", 3 + (g.garageSqft ? 1 : 0), "3 + 1 si garage", {}, "unit");291292  /* -------------------------------------------------------------- extérieur */293  if (!isCondo) {294    if (b.deckSqft > 0) {295      add("EXT-DECK-TREATED", b.deckSqft, "aire de terrasse fournie", { terrasse_pi2: b.deckSqft });296      const rail = 2.5 * Math.sqrt(b.deckSqft);297      add(b.quality === "superior" || b.quality === "prestige" ? "EXT-DECK-RAILING-ALU" : "EXT-DECK-RAILING-WOOD", rail, "2,5 × √aire de terrasse (3 côtés)", { terrasse_pi2: b.deckSqft }, "pi_lin");298    }299    if (b.drivewaySqft > 0 && b.driveway !== "none") {300      const code = b.driveway === "pavers" ? "EXT-DRIVEWAY-PAVERS" : b.driveway === "gravel" ? "EXT-DRIVEWAY-GRAVEL" : "EXT-DRIVEWAY-ASPHALT";301      add(code, b.drivewaySqft, "aire d'entrée fournie", { entree_pi2: b.drivewaySqft });302    }303    if (b.fenceLinFt > 0) add("EXT-FENCE-WOOD", b.fenceLinFt, "longueur de clôture fournie", {}, "pi_lin");304    if (b.landscapingSqft > 0) add("EXT-LANDSCAPING-SOD", b.landscapingSqft, "aire aménagée fournie", {}, "pi2");305    add("EXT-STEPS-PRECAST", 1, "1 perron", {}, "unit");306    if (b.pool === "above_ground") add("EXT-POOL-ABOVEGROUND", 1, "piscine hors terre", {}, "lump");307    if (b.pool === "inground") add("EXT-POOL-INGROUND", 1, "piscine creusée", {}, "lump");308  }309310  /* ----------------------------------------------------- fusion + surcharges */311  const merged = new Map<string, QuantityLine>();312  for (const l of lines) {313    const cur = merged.get(l.assemblyCode);314    if (cur) {315      cur.quantity = Math.round((cur.quantity + l.quantity) * 100) / 100;316      cur.formula = `${cur.formula} + ${l.formula}`;317      cur.inputs = { ...cur.inputs, ...l.inputs };318    } else merged.set(l.assemblyCode, { ...l });319  }320  for (const [code, qty] of Object.entries(overrides)) {321    if (excluded.includes(code)) continue;322    if (!(qty >= 0)) continue;323    const cur = merged.get(code);324    if (cur) {325      cur.formula = `surcharge (${overrideSource}) — calculé : ${cur.quantity}`;326      cur.quantity = qty;327      cur.source = overrideSource;328    } else if (qty > 0) merged.set(code, { assemblyCode: code, quantity: qty, unit: "pi2", source: overrideSource, formula: `quantité fournie (${overrideSource})`, inputs: {} });329  }330  return { geometry: g, lines: [...merged.values()].filter((l) => l.quantity > 0), warnings };331}332333function normalizeMix(m: object): Record<string, number> {334  const entries = Object.entries(m as Record<string, number | undefined>).filter((e): e is [string, number] => typeof e[1] === "number" && e[1] > 0);335  const total = entries.reduce((s, [, v]) => s + v, 0);336  if (!total) return { vinyl: 1 };337  return Object.fromEntries(entries.map(([k, v]) => [k, v / total]));338}339