// Auteur : Simon-Pierre Boucher — contact@spboucher.ai /** * Moteur de géométrie et de quantités : description du bâtiment → quantités * d'assemblages, chaque ligne accompagnée de sa formule. Module PUR. * * Principe (§193) : tout ce qu'une formule explicite peut calculer est calculé * ici, jamais par l'IA. Les constantes (hauteur d'étage 9 pi, rapport de forme * 1,5, 1 fenêtre de 10 pi² par 90 pi² de plancher…) sont des HYPOTHÈSES de * prise de quantités résidentielle, affichées dans chaque formule. * * Coût de REMPLACEMENT (et non de reproduction) : le bâtiment est reconstruit * avec les composantes modernes équivalentes (2 × 6 R24, R50 au comble, * poutrelles ajourées), quel que soit son année de construction. */ import type { BuildingSpec, Quality, QuantityLine, AttributeSource } from "./types"; import { KITCHEN_LINEAR_FT } from "./taxonomy"; export const GEOMETRY_VERSION = "1.0"; const FT3_TO_M3 = 0.0283168; export const STORY_HEIGHT_FT = 9; // 8 pi de plafond + structure de plancher export const ASPECT_RATIO = 1.5; // rectangle longueur/largeur export const WINDOW_SQFT = 10; // fenêtre type ~30 × 48 po export const WINDOW_PER_SQFT_FLOOR = 0.011; // ≈ 11 % de la surface de plancher vitrée export const PARTITION_LF_PER_SQFT = 0.09; // pi lin de cloison par pi² de plancher export const BASEMENT_WALL_FT = 8; export const GARAGE_WALL_FT = 10; export interface Geometry { footprintSqft: number; widthFt: number; lengthFt: number; perimeterFt: number; exposedPerimeterFt: number; // murs extérieurs non mitoyens exposedShare: number; wallHeightFt: number; grossWallSqft: number; gableSqft: number; windowCount: number; extDoors: number; patioDoors: number; openingsSqft: number; netWallSqft: number; roofSlopeFactor: number; roofSqft: number; basementFloorSqft: number; basementFinishedSqft: number; garageSqft: number; garagePerimeterFt: number; garageExposedPerimeterFt: number; } const r1 = (x: number) => Math.round(x * 10) / 10; const r0 = (x: number) => Math.round(x); export function computeGeometry(b: BuildingSpec): Geometry { const stories = Math.max(1, b.stories || 1); const footprint = b.footprintSqft && b.footprintSqft > 100 ? b.footprintSqft : b.grossFloorAreaSqft / stories; const width = Math.sqrt(footprint / ASPECT_RATIO); const length = ASPECT_RATIO * width; const perimeter = 2 * (width + length); const exposedShare = b.type === "semi_detached" ? 1 - length / perimeter : b.type === "row" || b.type === "condo" ? 1 - (2 * length) / perimeter : 1; const exposedPerimeter = perimeter * exposedShare; const grossWall = exposedPerimeter * STORY_HEIGHT_FT * stories; const pitch = b.roofGeometry === "flat" ? 0 : b.roofPitch || 6; const gable = b.roofGeometry === "gable" && b.type !== "condo" ? 2 * ((width * width * pitch) / 24) * (b.type === "detached" || b.type === "chalet" ? 1 : 0.5) : 0; const windowCount = b.windowCount ?? Math.max(4, Math.round(b.grossFloorAreaSqft * WINDOW_PER_SQFT_FLOOR)); const extDoors = b.type === "row" || b.type === "condo" ? 1 : 2; const patioDoors = b.type === "condo" ? 1 : b.deckSqft > 0 || b.quality !== "economy" ? 1 : 0; const openings = windowCount * WINDOW_SQFT + extDoors * 20 + patioDoors * 40; const slope = Math.sqrt(1 + (pitch / 12) ** 2); const complexity = b.roofGeometry === "hip" ? 1.05 : b.roofGeometry === "complex" ? 1.12 : b.roofGeometry === "mansard" ? 1.15 : 1; 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); const roofFootprint = footprint + (b.garage.type === "attached" ? garageSqft : 0); const roofSqft = b.type === "condo" ? 0 : roofFootprint * slope * 1.1 * complexity; const basementFloor = b.basement === "none" || b.basement === "crawl" ? 0 : footprint * 0.9; 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; const gw = Math.sqrt(garageSqft / 1.1); const garagePerimeter = garageSqft ? 2 * (gw + 1.1 * gw) : 0; const garageExposed = b.garage.type === "detached" ? garagePerimeter : b.garage.type === "attached" ? garagePerimeter * 0.75 : b.garage.type === "integrated" ? garagePerimeter * 0.5 : 0; return { footprintSqft: r0(footprint), widthFt: r1(width), lengthFt: r1(length), perimeterFt: r1(perimeter), exposedPerimeterFt: r1(exposedPerimeter), exposedShare: r1(exposedShare * 100) / 100, wallHeightFt: STORY_HEIGHT_FT, grossWallSqft: r0(grossWall), gableSqft: r0(gable), windowCount, extDoors, patioDoors, openingsSqft: r0(openings), netWallSqft: r0(Math.max(0, grossWall + gable - openings)), roofSlopeFactor: r1(slope * 100) / 100, roofSqft: r0(roofSqft), basementFloorSqft: r0(basementFloor), basementFinishedSqft: r0(basementFloor * finishedPct), garageSqft: r0(garageSqft), garagePerimeterFt: r1(garagePerimeter), garageExposedPerimeterFt: r1(garageExposed), }; } /** Répartition de plancher par défaut selon la qualité (si non fournie). */ export function defaultFlooring(q: Quality): BuildingSpec["flooring"] { switch (q) { case "economy": return { vinyl_plank: 0.7, laminate: 0.2, ceramic: 0.1 }; case "standard": return { engineered: 0.5, vinyl_plank: 0.35, ceramic: 0.15 }; case "superior": return { hardwood: 0.6, ceramic: 0.25, vinyl_plank: 0.15 }; default: return { hardwood: 0.65, ceramic: 0.35 }; } } /** * Quantités d'assemblages à partir de la description du bâtiment. * `overrides` remplace la quantité d'un assemblage (source « user »/« AI »/« listing »). */ export function deriveQuantities(b: BuildingSpec, overrides: Record = {}, overrideSource: AttributeSource = "user", excluded: string[] = []): { geometry: Geometry; lines: QuantityLine[]; warnings: string[] } { const g = computeGeometry(b); const warnings: string[] = []; const lines: QuantityLine[] = []; const stories = Math.max(1, b.stories || 1); const GFA = b.grossFloorAreaSqft; const F = g.footprintSqft; const P = g.perimeterFt; const Pe = g.exposedPerimeterFt; const units = Math.max(1, b.units || 1); const finished = g.basementFinishedSqft; const livable = GFA + finished; const add = (code: string, qty: number, formula: string, inputs: Record = {}, unit: QuantityLine["unit"] = "pi2") => { if (excluded.includes(code)) return; if (!(qty > 0)) return; lines.push({ assemblyCode: code, quantity: Math.round(qty * 100) / 100, unit, source: "derived", formula, inputs }); }; const isCondo = b.type === "condo"; if (isCondo) warnings.push("condo_common_areas"); /* ---------------------------------------------------------- site / fondations */ if (!isCondo) { const hasBasement = g.basementFloorSqft > 0; const depthFt = hasBasement ? 9 : b.basement === "crawl" ? 5 : b.foundation === "piers" ? 0 : 4.5; if (depthFt > 0) { const excM3 = (F + 3 * P) * depthFt * FT3_TO_M3; 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"); 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"); add("SITE-DRAIN-FRENCH", P, "périmètre de fondation", { perimetre_pi: P }, "pi_lin"); } add("SITE-SERVICE-CONNECTIONS", 1, "1 branchement par bâtiment", {}, "lump"); if (b.foundation === "piers") { add("FND-PIER", Math.ceil(F / 64), "empreinte ÷ 64 pi² (grille 8 × 8 pi)", { empreinte_pi2: F }, "unit"); } else { add("FND-FOOTING", P, "périmètre de fondation", { perimetre_pi: P }, "pi_lin"); const wallH = hasBasement ? BASEMENT_WALL_FT : b.basement === "crawl" ? 4 : 4; const wallCode = b.foundation === "concrete_block" || b.foundation === "stone" ? "FND-WALL-BLOCK-8IN" : "FND-WALL-POURED-8IN"; add(wallCode, P * wallH, `périmètre × hauteur de mur (${wallH} pi)`, { perimetre_pi: P, hauteur_pi: wallH }); add("FND-DAMPPROOF-MEMBRANE", P * Math.max(0, wallH - 1), "périmètre × (hauteur − 1 pi hors sol)", { perimetre_pi: P, hauteur_pi: wallH }); if (hasBasement) { add("FND-SLAB-BASEMENT-4IN", F, "empreinte du bâtiment", { empreinte_pi2: F }); add("OPN-WINDOW-BASEMENT", Math.max(2, Math.round(P / 25)), "périmètre ÷ 25 pi", { perimetre_pi: P }, "unit"); } else if (b.basement === "none") { add("FND-SLAB-ON-GRADE-INSULATED", F, "empreinte du bâtiment (dalle sur sol)", { empreinte_pi2: F }); } } } /* ------------------------------------------------------------- structure */ const floorsSqft = isCondo ? GFA : b.basement === "none" && b.foundation !== "piers" ? Math.max(0, GFA - F) : GFA; 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 }); 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 }); const partitionsLf = livable * PARTITION_LF_PER_SQFT; 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) }); if (!isCondo) { add("STR-BEAM-LVL", g.lengthFt, "longueur du bâtiment (poutre centrale)", { longueur_pi: g.lengthFt }, "pi_lin"); add("STR-COLUMN-STEEL", Math.max(2, Math.round(g.lengthFt / 10)), "longueur ÷ 10 pi", { longueur_pi: g.lengthFt }, "unit"); 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 }); } const stairs = (stories - 1) + (g.basementFloorSqft > 0 ? 1 : 0); add("STR-STAIRS-INTERIOR", stairs, "(étages − 1) + 1 si sous-sol", { etages: stories }, "unit"); /* -------------------------------------------------------------- enveloppe */ add("ENV-INSUL-WALL-R24", g.netWallSqft, "mur brut + pignons − ouvertures", { mur_brut_pi2: g.grossWallSqft, ouvertures_pi2: g.openingsSqft }); if (!isCondo) add("ENV-INSUL-ATTIC-R50", F + (b.garage.type === "attached" ? 0 : 0), "empreinte (plafond du dernier étage)", { empreinte_pi2: F }); const mix = normalizeMix(b.siding); const SIDING: Record = { 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" }; const garageWall = g.garageExposedPerimeterFt * GARAGE_WALL_FT; for (const [k, share] of Object.entries(mix)) { if (share <= 0) continue; 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 }); } if (!isCondo) { add("ENV-SOFFIT-FASCIA", P, "périmètre (débords de toit)", { perimetre_pi: P }, "pi_lin"); add("EXT-GUTTERS", P * 0.6, "périmètre × 60 % (côtés d'égouttement)", { perimetre_pi: P }, "pi_lin"); } /* ---------------------------------------------------------------- toiture */ if (!isCondo) { 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"; add(roofCode, g.roofSqft, "surface de toit", { toit_pi2: g.roofSqft }); } /* ------------------------------------------------------------- ouvertures */ const WIN: Record = { pvc: "OPN-WINDOW-PVC-STD", hybrid: "OPN-WINDOW-HYBRID-STD", aluminum: "OPN-WINDOW-ALU-STD", wood: "OPN-WINDOW-WOOD-STD" }; 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"); 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"); add("OPN-DOOR-PATIO-6FT", g.patioDoors, "1 porte-patio si terrasse ou qualité ≥ standard", {}, "unit"); const intDoors = Math.max(3, Math.round(livable / 180)); 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"); add("OPN-DOOR-CLOSET-BIFOLD", Math.max(2, Math.round(livable / 350)), "aire habitable ÷ 350 pi²", { aire_habitable_pi2: livable }, "unit"); if (g.garageSqft) add(b.garage.spaces >= 2 ? "OPN-DOOR-GARAGE-16X7" : "OPN-DOOR-GARAGE-9X7", 1, "1 porte de garage", {}, "unit"); /* ----------------------------------------------------------------- garage */ if (g.garageSqft && !isCondo) { const Ag = g.garageSqft; add("FND-SLAB-GARAGE-5IN", Ag, "aire du garage", { garage_pi2: Ag }); if (b.garage.type !== "integrated") { add("FND-FOOTING", g.garagePerimeterFt, "périmètre du garage", { perimetre_garage_pi: g.garagePerimeterFt }, "pi_lin"); add("FND-WALL-POURED-8IN", g.garageExposedPerimeterFt * 4, "périmètre exposé du garage × 4 pi (mur hors gel)", { perimetre_expose_pi: g.garageExposedPerimeterFt }); } add("STR-WALL-2X6-EXT", garageWall, "périmètre exposé du garage × 10 pi", { perimetre_expose_pi: g.garageExposedPerimeterFt }); add("ENV-INSUL-WALL-R20", garageWall, "murs de garage", { garage_murs_pi2: Math.round(garageWall) }); add("INT-DRYWALL-1/2-FINISHED", garageWall, "murs de garage (séparation coupe-feu)", { garage_murs_pi2: Math.round(garageWall) }); add("INT-DRYWALL-5/8-CEILING", Ag, "plafond du garage", { garage_pi2: Ag }); add("ELE-LIGHTING-STD", 2, "2 luminaires de garage", {}, "unit"); if (b.garage.type === "detached") { const roofG = Ag * g.roofSlopeFactor * 1.1; add("STR-ROOF-TRUSS-OSB", roofG, "aire du garage × facteur de pente × 1,10", { garage_pi2: Ag, facteur_pente: g.roofSlopeFactor }); add(b.roof === "metal" ? "ROOF-METAL-STANDING" : "ROOF-ASPHALT-ARCH", roofG, "toit du garage détaché", { toit_pi2: Math.round(roofG) }); } } /* -------------------------------------------------------------- intérieur */ const drywallWalls = g.netWallSqft + partitionsLf * STORY_HEIGHT_FT * 2 + (finished ? P * BASEMENT_WALL_FT * (finished / Math.max(1, g.basementFloorSqft)) : 0); 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) }); add("INT-DRYWALL-5/8-CEILING", livable, "plafonds (aire habitable)", { aire_habitable_pi2: livable }); add("INT-PAINT-2-COATS", drywallWalls + livable, "murs + plafonds", { murs_pi2: Math.round(drywallWalls), plafonds_pi2: livable }); const bathFloors = b.bathrooms * (b.bathroomQuality === "superior" || b.bathroomQuality === "prestige" ? 60 : b.bathroomQuality === "standard" ? 45 : 40) + b.powderRooms * 20; const floorArea = Math.max(0, GFA - bathFloors); const fmix = normalizeMix(Object.keys(b.flooring).length ? b.flooring : defaultFlooring(b.quality)); const FLOOR: Record = { 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" }; 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 }); if (finished) { add("BSM-SUBFLOOR-PANELS", finished, "aire finie du sous-sol", { sous_sol_fini_pi2: finished }); add("INT-FLOOR-VINYL-PLANK", finished, "aire finie du sous-sol (vinyle)", { sous_sol_fini_pi2: finished }); } 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 }); 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"); add("INT-CLOSET-SHELVING", Math.max(8, livable / 60), "aire habitable ÷ 60", { aire_habitable_pi2: livable }, "pi_lin"); /* ------------------------------------------------------ cuisine / bains */ const KIT: Record = { economy: "KIT-ECONOMY", standard: "KIT-STANDARD", superior: "KIT-SUPERIOR", prestige: "KIT-PRESTIGE" }; const sizeFactor = Math.min(1.3, Math.max(0.8, GFA / units / 1800)); 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"); const nb = Math.max(0, b.bathrooms); if (nb) { if (b.bathroomQuality === "prestige") add("BATH-SUPERIOR", nb, "toutes les salles de bain (prestige)", {}, "unit"); else if (b.bathroomQuality === "superior") { add("BATH-SUPERIOR", Math.min(nb, units), "1 salle de bain principale supérieure par logement", {}, "unit"); add("BATH-STANDARD", Math.max(0, nb - units), "autres salles de bain (standard)", {}, "unit"); } else if (b.bathroomQuality === "standard") add("BATH-STANDARD", nb, "salles de bain standard", {}, "unit"); else add("BATH-ECONOMY", nb, "salles de bain économiques", {}, "unit"); } add("BATH-POWDER", b.powderRooms, "salles d'eau", {}, "unit"); /* ------------------------------------------------------------- mécanique */ const fixtures = Math.max(1, b.kitchens) * 2 + nb * 3 + b.powderRooms * 2 + units * 2; // laveuse + chauffe-eau par logement 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"); add("MEC-WATER-HEATER-60", units, "1 par logement", { logements: units }, "unit"); if (!isCondo) add("MEC-DRAINAGE-BASE", 1, "1 par bâtiment", {}, "lump"); const heated = livable; switch (b.heating) { case "heat_pump": case "geothermal": add("MEC-HEATPUMP-CENTRAL", units, "1 par logement", {}, "unit"); add("MEC-FURNACE-ELECTRIC", units, "fournaise électrique d'appoint", {}, "unit"); add("MEC-DUCTWORK", heated, "aire chauffée", { aire_pi2: heated }); if (b.heating === "geothermal") warnings.push("geothermal_as_heatpump"); break; case "furnace_electric": case "hydronic": add("MEC-FURNACE-ELECTRIC", units, "1 par logement", {}, "unit"); add("MEC-DUCTWORK", heated, "aire chauffée", { aire_pi2: heated }); if (b.hasAirConditioning) add("MEC-CENTRAL-AC", units, "climatisation centrale", {}, "unit"); if (b.heating === "hydronic") warnings.push("hydronic_as_furnace"); break; case "furnace_gas": case "furnace_oil": add("MEC-FURNACE-GAS", units, "1 par logement", {}, "unit"); add("MEC-DUCTWORK", heated, "aire chauffée", { aire_pi2: heated }); if (b.hasAirConditioning) add("MEC-CENTRAL-AC", units, "climatisation centrale", {}, "unit"); break; default: { add("MEC-BASEBOARD-ELECTRIC", Math.ceil(heated / 150), "aire chauffée ÷ 150 pi² par plinthe de 1 500 W", { aire_pi2: heated }, "unit"); if (b.hasAirConditioning) add("MEC-HEATPUMP-WALL", units, "thermopompe murale (climatisation)", {}, "unit"); if (b.heating === "wood") warnings.push("wood_as_baseboard"); } } if (b.hasAirExchanger) add("MEC-HRV", units, "1 par logement", {}, "unit"); /* ------------------------------------------------------------ électricité */ add("ELE-SERVICE-200A", units, "1 entrée par logement", { logements: units }, "unit"); add("ELE-ROUGH-WIRING", livable, "aire habitable", { aire_habitable_pi2: livable }); add("ELE-LIGHTING-STD", Math.round(livable / 70), "aire habitable ÷ 70 pi²", { aire_habitable_pi2: livable }, "unit"); if (!isCondo) add("ELE-LIGHTING-EXT", 3 + (g.garageSqft ? 1 : 0), "3 + 1 si garage", {}, "unit"); /* -------------------------------------------------------------- extérieur */ if (!isCondo) { if (b.deckSqft > 0) { add("EXT-DECK-TREATED", b.deckSqft, "aire de terrasse fournie", { terrasse_pi2: b.deckSqft }); const rail = 2.5 * Math.sqrt(b.deckSqft); 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"); } if (b.drivewaySqft > 0 && b.driveway !== "none") { const code = b.driveway === "pavers" ? "EXT-DRIVEWAY-PAVERS" : b.driveway === "gravel" ? "EXT-DRIVEWAY-GRAVEL" : "EXT-DRIVEWAY-ASPHALT"; add(code, b.drivewaySqft, "aire d'entrée fournie", { entree_pi2: b.drivewaySqft }); } if (b.fenceLinFt > 0) add("EXT-FENCE-WOOD", b.fenceLinFt, "longueur de clôture fournie", {}, "pi_lin"); if (b.landscapingSqft > 0) add("EXT-LANDSCAPING-SOD", b.landscapingSqft, "aire aménagée fournie", {}, "pi2"); add("EXT-STEPS-PRECAST", 1, "1 perron", {}, "unit"); if (b.pool === "above_ground") add("EXT-POOL-ABOVEGROUND", 1, "piscine hors terre", {}, "lump"); if (b.pool === "inground") add("EXT-POOL-INGROUND", 1, "piscine creusée", {}, "lump"); } /* ----------------------------------------------------- fusion + surcharges */ const merged = new Map(); for (const l of lines) { const cur = merged.get(l.assemblyCode); if (cur) { cur.quantity = Math.round((cur.quantity + l.quantity) * 100) / 100; cur.formula = `${cur.formula} + ${l.formula}`; cur.inputs = { ...cur.inputs, ...l.inputs }; } else merged.set(l.assemblyCode, { ...l }); } for (const [code, qty] of Object.entries(overrides)) { if (excluded.includes(code)) continue; if (!(qty >= 0)) continue; const cur = merged.get(code); if (cur) { cur.formula = `surcharge (${overrideSource}) — calculé : ${cur.quantity}`; cur.quantity = qty; cur.source = overrideSource; } else if (qty > 0) merged.set(code, { assemblyCode: code, quantity: qty, unit: "pi2", source: overrideSource, formula: `quantité fournie (${overrideSource})`, inputs: {} }); } return { geometry: g, lines: [...merged.values()].filter((l) => l.quantity > 0), warnings }; } function normalizeMix(m: object): Record { const entries = Object.entries(m as Record).filter((e): e is [string, number] => typeof e[1] === "number" && e[1] > 0); const total = entries.reduce((s, [, v]) => s + v, 0); if (!total) return { vinyl: 1 }; return Object.fromEntries(entries.map(([k, v]) => [k, v / total])); }