Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.
TypeScript 90.2%
JavaScript 3.5%
Python 3.4%
CSS 1.9%
HTML 0.6%
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