1// UQO Éval — Métré : générateur paramétrique de gabarits de plans (bungalows, maisons 2 étages, plex, rangée, chalets, garages) + fiche technique2// par défaut pour la méthode du relevé des quantités. Module PUR : produit des objets de plan (m) par niveau ; le service les convertit en pixels.3import type { ObjectKind, ObjectProps, Point } from "./types";45/* ------------------------------------------------------------- fiche QS */67export type BasementKind = "none" | "crawl" | "unfinished" | "finished";8export type FoundationKind = "poured_concrete" | "concrete_block" | "slab_on_grade" | "piers";9export type RoofKind = "asphalt_shingle" | "metal" | "membrane";10export type RoofGeom = "gable" | "hip" | "flat";11export type HeatingKind = "electric_baseboard" | "heat_pump" | "furnace_electric" | "furnace_gas";12export type Quality = "economy" | "standard" | "superior" | "prestige";1314/** Fiche technique d'un projet — tout ce que le plan ne montre pas mais que le relevé des quantités exige. */15export interface BuildingSpecQS {16 units: number; // logements17 basement: BasementKind;18 basementFinishedPct: number; // 0-119 foundation: FoundationKind;20 foundationWallHeightM: number; // 2.44 sous-sol, 1.2 vide sanitaire / mur hors gel21 roofType: RoofKind;22 roofGeometry: RoofGeom;23 roofPitch: number; // po/1224 siding: Partial<Record<"vinyl" | "brick" | "fiber_cement" | "wood" | "stone" | "steel", number>>;25 windows: "pvc" | "hybrid" | "aluminum" | "wood";26 heating: HeatingKind;27 hasAirConditioning: boolean;28 hasAirExchanger: boolean;29 quality: Quality;30 kitchenQuality: Quality;31 bathroomQuality: Quality;32 flooring: Partial<Record<"hardwood" | "engineered" | "vinyl_plank" | "ceramic" | "laminate" | "carpet", number>>;33 ceilingHeightM: number;34 garage: "none" | "attached" | "detached" | "integrated";35 deckM2: number;36 drivewayM2: number;37 driveway: "asphalt" | "pavers" | "gravel" | "none";38 fenceM: number;39 landscapingM2: number;40 notes: string | null;41}4243export function defaultSpec(p: Partial<BuildingSpecQS> = {}): BuildingSpecQS {44 return {45 units: 1, basement: "unfinished", basementFinishedPct: 0, foundation: "poured_concrete", foundationWallHeightM: 2.44, roofType: "asphalt_shingle", roofGeometry: "gable", roofPitch: 6,46 siding: { vinyl: 0.7, brick: 0.3 }, windows: "pvc", heating: "electric_baseboard", hasAirConditioning: false, hasAirExchanger: true, quality: "standard", kitchenQuality: "standard", bathroomQuality: "standard",47 flooring: { hardwood: 0.6, ceramic: 0.25, vinyl_plank: 0.15 }, ceilingHeightM: 2.44, garage: "none", deckM2: 0, drivewayM2: 0, driveway: "none", fenceM: 0, landscapingM2: 0, notes: null, ...p,48 };49}5051/* -------------------------------------------------------- objets générés */5253export interface GenObject { kind: ObjectKind; name: string; points: Point[]; closed: boolean; props: ObjectProps; openings?: GenOpening[] }54export interface GenOpening { name: string; kind: "door" | "window" | "opening"; at: Point; widthM: number; heightM: number; swing?: number }55export interface GenLevel { level: string; widthM: number; heightM: number; objects: GenObject[] }56export interface Template { key: string; kind: "bungalow" | "cottage" | "plex" | "row" | "chalet" | "garage" | "condo" | "mobile"; fr: string; en: string; footprint: string; storeys: number; units: number; areaM2: number; spec: Partial<BuildingSpecQS>; levels: GenLevel[] }5758const T_EXT = 0.3, T_INT = 0.12, H = 2.44;59const rect = (x: number, y: number, w: number, h: number): Point[] => [{ x, y }, { x: x + w, y }, { x: x + w, y: y + h }, { x, y: y + h }];60const r2 = (v: number) => Math.round(v * 100) / 100;6162/** Programme d'une bande de pièces : [usage, nom, fraction de la largeur]. */63type Band = [string, string, number][];64interface UnitProgram { front: Band; back: Band; frontDepth: number; stairs?: boolean; entrance?: "front" | "side" }6566const PROGRAMS: Record<string, UnitProgram> = {67 bungalow2: { front: [["séjour", "Séjour", 0.55], ["cuisine", "Cuisine / salle à manger", 0.45]], back: [["chambre", "Chambre principale", 0.38], ["chambre", "Chambre 2", 0.3], ["salle de bain", "Salle de bain", 0.17], ["hall", "Hall / escalier", 0.15]], frontDepth: 0.5, stairs: true },68 bungalow3: { front: [["séjour", "Séjour", 0.45], ["cuisine", "Cuisine / salle à manger", 0.4], ["hall", "Hall / escalier", 0.15]], back: [["chambre", "Chambre principale", 0.34], ["chambre", "Chambre 2", 0.25], ["chambre", "Chambre 3", 0.23], ["salle de bain", "Salle de bain", 0.18]], frontDepth: 0.5, stairs: true },69 cottageRdc: { front: [["séjour", "Salon", 0.5], ["salle à manger", "Salle à manger", 0.3], ["hall", "Hall / escalier", 0.2]], back: [["cuisine", "Cuisine", 0.45], ["bureau", "Bureau / salle familiale", 0.35], ["salle d'eau", "Salle d'eau", 0.2]], frontDepth: 0.5, stairs: true },70 cottageEtage: { front: [["chambre", "Chambre principale", 0.45], ["salle de bain", "Salle de bain", 0.25], ["hall", "Hall / escalier", 0.3]], back: [["chambre", "Chambre 2", 0.37], ["chambre", "Chambre 3", 0.33], ["chambre", "Chambre 4", 0.3]], frontDepth: 0.5, stairs: true },71 cottageEtage3: { front: [["chambre", "Chambre principale", 0.5], ["salle de bain", "Salle de bain", 0.25], ["hall", "Hall / escalier", 0.25]], back: [["chambre", "Chambre 2", 0.5], ["chambre", "Chambre 3", 0.5]], frontDepth: 0.5, stairs: true },72 apartment: { front: [["séjour", "Séjour", 0.5], ["cuisine", "Cuisine / salle à manger", 0.5]], back: [["chambre", "Chambre 1", 0.4], ["chambre", "Chambre 2", 0.35], ["salle de bain", "Salle de bain", 0.25]], frontDepth: 0.5 },73 apartment1: { front: [["séjour", "Séjour / cuisine", 1]], back: [["chambre", "Chambre", 0.6], ["salle de bain", "Salle de bain", 0.4]], frontDepth: 0.5 },74 rowRdc: { front: [["séjour", "Salon", 0.65], ["hall", "Hall / escalier", 0.35]], back: [["cuisine", "Cuisine / salle à manger", 0.7], ["salle d'eau", "Salle d'eau", 0.3]], frontDepth: 0.5, stairs: true },75 rowEtage: { front: [["chambre", "Chambre principale", 0.65], ["hall", "Hall / escalier", 0.35]], back: [["chambre", "Chambre 2", 0.55], ["salle de bain", "Salle de bain", 0.45]], frontDepth: 0.5, stairs: true },76 chalet: { front: [["séjour", "Séjour / cuisine", 1]], back: [["chambre", "Chambre 1", 0.4], ["chambre", "Chambre 2", 0.35], ["salle de bain", "Salle de bain", 0.25]], frontDepth: 0.55 },77 mobile: { front: [["séjour", "Séjour / cuisine", 0.45], ["chambre", "Chambre 2", 0.2], ["salle de bain", "Salle de bain", 0.12], ["chambre", "Chambre principale", 0.23]], back: [], frontDepth: 1 },78};7980/** Dispose un logement rectangulaire (x,y,W,D en m) : cloisons, pièces, ouvertures sur les murs extérieurs exposés. */81function layoutUnit(x: number, y: number, W: number, D: number, prog: UnitProgram, exposed: { front: boolean; back: boolean; left: boolean; right: boolean }, unitLabel: string, out: GenObject[], openings: GenOpening[]): void {82 const inner = (ox: number, oy: number, w: number, h: number): Point[] => rect(ox + 0.08, oy + 0.08, w - 0.16, h - 0.16);83 const frontD = prog.back.length ? D * prog.frontDepth : D;84 const prefix = unitLabel ? `${unitLabel} — ` : "";85 if (prog.back.length) out.push({ kind: "wall", name: `${prefix}Cloison centrale`, points: [{ x, y: y + frontD }, { x: x + W, y: y + frontD }], closed: false, props: { thickness: T_INT, height: H } });86 const band = (rooms: Band, by: number, bh: number, isFront: boolean) => {87 let cx = x;88 rooms.forEach(([usage, name, frac], i) => {89 const w = W * frac;90 out.push({ kind: "room", name: `${prefix}${name}`, points: inner(cx, by, w, bh), closed: true, props: { usage, height: H } });91 if (i < rooms.length - 1) out.push({ kind: "wall", name: `${prefix}Cloison ${name}`, points: [{ x: cx + w, y: by }, { x: cx + w, y: by + bh }], closed: false, props: { thickness: T_INT, height: H } });92 // fenêtre sur la face exposée (avant ou arrière)93 const edgeY = isFront ? y : y + D; const exposedEdge = isFront ? exposed.front : exposed.back;94 if (exposedEdge && usage !== "hall" && usage !== "salle d'eau") openings.push({ name: `Fenêtre ${name}`, kind: "window", at: { x: cx + w / 2, y: edgeY }, widthM: usage === "séjour" || usage === "cuisine" ? 1.8 : usage === "salle de bain" ? 0.9 : 1.2, heightM: usage === "salle de bain" ? 0.9 : 1.3 });95 if (usage === "hall" && prog.stairs) out.push({ kind: "stairs", name: `${prefix}Escalier`, points: rect(cx + 0.3, by + 0.3, Math.min(1.0, w - 0.6), Math.min(3.6, bh - 0.6)), closed: true, props: {} });96 cx += w;97 });98 };99 band(prog.front, y, frontD, true);100 if (prog.back.length) band(prog.back, y + frontD, D - frontD, false);101 // porte d'entrée (face avant, premier tiers) et porte-patio (face arrière, côté cuisine) si exposées102 if (exposed.front) openings.push({ name: `${prefix}Porte d'entrée`, kind: "door", at: { x: x + W * 0.12, y }, widthM: 0.9, heightM: 2.1, swing: 1 });103 else if (exposed.left) openings.push({ name: `${prefix}Porte d'entrée`, kind: "door", at: { x, y: y + 1.2 }, widthM: 0.9, heightM: 2.1, swing: 1 });104 if (exposed.back && prog.back.length) openings.push({ name: `${prefix}Porte-patio`, kind: "door", at: { x: x + W * 0.78, y: y + D }, widthM: 1.8, heightM: 2.1, swing: -1 });105 // fenêtres latérales (une par face exposée)106 if (exposed.left) openings.push({ name: "Fenêtre latérale", kind: "window", at: { x, y: y + D * 0.3 }, widthM: 1.2, heightM: 1.3 });107 if (exposed.right) openings.push({ name: "Fenêtre latérale", kind: "window", at: { x: x + W, y: y + D * 0.7 }, widthM: 1.2, heightM: 1.3 });108}109110/** Niveau complet : enveloppe + logements côte à côte (nUnitsAcross) + garage attenant facultatif. */111function level(name: string, W: number, D: number, programs: UnitProgram[], opts: { garage?: { w: number; d: number } | null; unitLabels?: string[]; basement?: boolean } = {}): GenLevel {112 const margin = 2.5; const objects: GenObject[] = []; const openings: GenOpening[] = [];113 const x0 = margin, y0 = margin;114 objects.push({ kind: "wall", name: "Murs extérieurs", points: rect(x0, y0, W, D), closed: true, props: { thickness: T_EXT, height: H, exterior: true } });115 objects.push({ kind: "footprint", name: "Empreinte", points: rect(x0 - T_EXT / 2, y0 - T_EXT / 2, W + T_EXT, D + T_EXT), closed: true, props: {} });116 if (opts.basement) {117 objects.push({ kind: "room", name: "Sous-sol", points: rect(x0 + 0.1, y0 + 0.1, W - 0.2, D - 0.2), closed: true, props: { usage: "sous-sol", height: 2.3 } });118 objects.push({ kind: "wall", name: "Cloison salle mécanique", points: [{ x: x0 + 3, y: y0 }, { x: x0 + 3, y: y0 + 3 }, { x: x0, y: y0 + 3 }], closed: false, props: { thickness: T_INT, height: 2.3 } });119 objects.push({ kind: "room", name: "Salle mécanique", points: rect(x0 + 0.1, y0 + 0.1, 2.8, 2.8), closed: true, props: { usage: "salle mécanique", height: 2.3 } });120 objects.push({ kind: "stairs", name: "Escalier", points: rect(x0 + W - 1.3, y0 + 0.3, 1.0, 3.6), closed: true, props: {} });121 openings.push({ name: "Fenêtre de sous-sol", kind: "window", at: { x: x0 + W * 0.3, y: y0 + D }, widthM: 0.8, heightM: 0.5 }, { name: "Fenêtre de sous-sol", kind: "window", at: { x: x0 + W * 0.7, y: y0 + D }, widthM: 0.8, heightM: 0.5 }, { name: "Fenêtre de sous-sol", kind: "window", at: { x: x0, y: y0 + D * 0.5 }, widthM: 0.8, heightM: 0.5 });122 } else {123 const n = programs.length; const uw = W / n;124 programs.forEach((p, i) => {125 if (i > 0) objects.push({ kind: "wall", name: `Mur mitoyen ${i}`, points: [{ x: x0 + uw * i, y: y0 }, { x: x0 + uw * i, y: y0 + D }], closed: false, props: { thickness: 0.25, height: H } });126 layoutUnit(x0 + uw * i, y0, uw, D, p, { front: true, back: true, left: i === 0, right: i === n - 1 }, opts.unitLabels?.[i] ?? "", objects, openings);127 });128 }129 if (opts.garage) {130 const g = opts.garage; const gx = x0 + W, gy = y0 + D - g.d;131 objects.push({ kind: "wall", name: "Murs du garage", points: [{ x: gx, y: gy }, { x: gx + g.w, y: gy }, { x: gx + g.w, y: gy + g.d }, { x: gx, y: gy + g.d }], closed: false, props: { thickness: T_EXT, height: 3.0, exterior: true }, openings: [{ name: "Porte de garage", kind: "door", at: { x: gx + g.w / 2, y: gy + g.d }, widthM: g.w >= 6 ? 4.9 : 2.7, heightM: 2.1, swing: -2 }, { name: "Fenêtre garage", kind: "window", at: { x: gx + g.w, y: gy + g.d / 2 }, widthM: 0.9, heightM: 0.8 }] });132 objects.push({ kind: "room", name: "Garage", points: rect(gx + 0.1, gy + 0.1, g.w - 0.2, g.d - 0.2), closed: true, props: { usage: "garage", height: 3.0 } });133 objects.push({ kind: "footprint", name: "Empreinte garage", points: rect(gx, gy - T_EXT / 2, g.w + T_EXT / 2, g.d + T_EXT), closed: true, props: {} });134 }135 const ext = objects[0]; ext.openings = openings;136 return { level: name, widthM: r2(W + 2 * margin + (opts.garage?.w ?? 0)), heightM: r2(D + 2 * margin), objects };137}138139/* ------------------------------------------------------------ catalogue */140141interface Def { key: string; kind: Template["kind"]; fr: string; en: string; W: number; D: number; storeys: number; units?: number; basement?: BasementKind; garage?: { w: number; d: number } | null; programs: (lvl: number) => UnitProgram[]; labels?: (lvl: number) => string[]; spec?: Partial<BuildingSpecQS>; levelNames?: string[] }142143const B = (key: string, fr: string, en: string, W: number, D: number, prog: string, extra: Partial<Def> = {}): Def => ({ key, kind: "bungalow", fr, en, W, D, storeys: 1, basement: "unfinished", garage: null, programs: () => [PROGRAMS[prog]], ...extra });144const C = (key: string, fr: string, en: string, W: number, D: number, upper = "cottageEtage", extra: Partial<Def> = {}): Def => ({ key, kind: "cottage", fr, en, W, D, storeys: 2, basement: "unfinished", garage: null, programs: (l) => [l === 0 ? PROGRAMS.cottageRdc : PROGRAMS[upper]], ...extra });145const P = (key: string, fr: string, en: string, W: number, D: number, storeys: number, across: number, prog = "apartment", extra: Partial<Def> = {}): Def => ({ key, kind: "plex", fr, en, W, D, storeys, units: storeys * across, basement: "unfinished", garage: null, programs: () => Array.from({ length: across }, () => PROGRAMS[prog]), labels: (l) => Array.from({ length: across }, (_, i) => `Logement ${l * across + i + 1}`), spec: { units: storeys * across, siding: { brick: 0.8, vinyl: 0.2 }, roofGeometry: "flat", roofType: "membrane", roofPitch: 0, heating: "electric_baseboard" }, ...extra });146147const DEFS: Def[] = [148 B("bungalow-a", "Bungalow 2 chambres 10,4 × 8,5 m", "Bungalow 2 bedrooms 10.4 × 8.5 m", 10.4, 8.5, "bungalow2"),149 B("bungalow-b", "Bungalow 2 chambres 11,6 × 9,1 m", "Bungalow 2 bedrooms 11.6 × 9.1 m", 11.6, 9.1, "bungalow2"),150 B("bungalow-c", "Bungalow 3 chambres 12,2 × 9,1 m", "Bungalow 3 bedrooms 12.2 × 9.1 m", 12.2, 9.1, "bungalow3"),151 B("bungalow-d", "Bungalow 3 chambres 13,4 × 9,8 m", "Bungalow 3 bedrooms 13.4 × 9.8 m", 13.4, 9.8, "bungalow3"),152 B("bungalow-e", "Bungalow 3 chambres 14,6 × 10,4 m", "Bungalow 3 bedrooms 14.6 × 10.4 m", 14.6, 10.4, "bungalow3", { spec: { quality: "superior", kitchenQuality: "superior" } }),153 B("bungalow-garage", "Bungalow 3 ch. 12,2 × 9,1 m + garage attenant", "Bungalow 3 bdr 12.2 × 9.1 m + attached garage", 12.2, 9.1, "bungalow3", { garage: { w: 4.3, d: 6.7 }, spec: { garage: "attached" } }),154 B("bungalow-garage-2", "Bungalow 3 ch. 13,4 × 9,8 m + garage double", "Bungalow 3 bdr 13.4 × 9.8 m + double garage", 13.4, 9.8, "bungalow3", { garage: { w: 6.7, d: 6.7 }, spec: { garage: "attached", quality: "superior" } }),155 B("bungalow-fini", "Bungalow 3 ch. 12,2 × 9,1 m, sous-sol fini", "Bungalow 3 bdr 12.2 × 9.1 m, finished basement", 12.2, 9.1, "bungalow3", { basement: "finished", spec: { basement: "finished", basementFinishedPct: 0.8 } }),156 B("bungalow-dalle", "Bungalow 2 ch. 11,6 × 9,1 m sur dalle (sans sous-sol)", "Bungalow 2 bdr 11.6 × 9.1 m on slab (no basement)", 11.6, 9.1, "bungalow2", { basement: "none", spec: { basement: "none", foundation: "slab_on_grade", foundationWallHeightM: 1.2 } }),157 B("bungalow-vs", "Bungalow 2 ch. 10,4 × 8,5 m sur vide sanitaire", "Bungalow 2 bdr 10.4 × 8.5 m on crawl space", 10.4, 8.5, "bungalow2", { basement: "crawl", spec: { basement: "crawl", foundationWallHeightM: 1.2 } }),158 C("cottage-a", "Maison 2 étages 9,1 × 7,9 m (3 chambres)", "Two-storey 9.1 × 7.9 m (3 bedrooms)", 9.1, 7.9, "cottageEtage3"),159 C("cottage-b", "Maison 2 étages 10,4 × 8,5 m (3 chambres)", "Two-storey 10.4 × 8.5 m (3 bedrooms)", 10.4, 8.5, "cottageEtage3"),160 C("cottage-c", "Maison 2 étages 11,6 × 9,1 m (4 chambres)", "Two-storey 11.6 × 9.1 m (4 bedrooms)", 11.6, 9.1),161 C("cottage-d", "Maison 2 étages 12,2 × 9,8 m (4 chambres)", "Two-storey 12.2 × 9.8 m (4 bedrooms)", 12.2, 9.8, "cottageEtage", { spec: { quality: "superior", kitchenQuality: "superior", bathroomQuality: "superior", siding: { brick: 0.6, fiber_cement: 0.4 } } }),162 C("cottage-garage", "Maison 2 étages 11,6 × 9,1 m + garage attenant", "Two-storey 11.6 × 9.1 m + attached garage", 11.6, 9.1, "cottageEtage", { garage: { w: 4.3, d: 6.7 }, spec: { garage: "attached" } }),163 C("cottage-garage-2", "Maison 2 étages 12,2 × 9,8 m + garage double", "Two-storey 12.2 × 9.8 m + double garage", 12.2, 9.8, "cottageEtage", { garage: { w: 6.7, d: 7.3 }, spec: { garage: "attached", quality: "superior", heating: "heat_pump", hasAirConditioning: true } }),164 C("cottage-fini", "Maison 2 étages 11,6 × 9,1 m, sous-sol fini", "Two-storey 11.6 × 9.1 m, finished basement", 11.6, 9.1, "cottageEtage", { basement: "finished", spec: { basement: "finished", basementFinishedPct: 0.8 } }),165 C("cottage-prestige", "Maison 2 étages 13,4 × 10,4 m, prestige, garage double", "Two-storey 13.4 × 10.4 m, prestige, double garage", 13.4, 10.4, "cottageEtage", { garage: { w: 6.7, d: 7.3 }, spec: { garage: "attached", quality: "prestige", kitchenQuality: "prestige", bathroomQuality: "prestige", siding: { brick: 0.5, stone: 0.3, fiber_cement: 0.2 }, windows: "hybrid", heating: "heat_pump", hasAirConditioning: true } }),166 P("duplex", "Duplex superposé 11,6 × 10,4 m (2 logements)", "Stacked duplex 11.6 × 10.4 m (2 units)", 11.6, 10.4, 2, 1),167 P("duplex-b", "Duplex superposé 12,8 × 11 m (2 logements)", "Stacked duplex 12.8 × 11 m (2 units)", 12.8, 11, 2, 1),168 P("triplex", "Triplex 11,6 × 10,4 m (3 logements)", "Triplex 11.6 × 10.4 m (3 units)", 11.6, 10.4, 3, 1),169 P("triplex-b", "Triplex 12,8 × 11 m (3 logements)", "Triplex 12.8 × 11 m (3 units)", 12.8, 11, 3, 1),170 P("quadruplex", "Quadruplex 2 étages 18,3 × 10,4 m (4 logements)", "Fourplex 2 storeys 18.3 × 10.4 m (4 units)", 18.3, 10.4, 2, 2),171 P("quadruplex-b", "Quadruplex 2 étages 20,1 × 11 m (4 logements)", "Fourplex 2 storeys 20.1 × 11 m (4 units)", 20.1, 11, 2, 2),172 P("sixplex", "Sixplex 3 étages 18,3 × 10,4 m (6 logements)", "Sixplex 3 storeys 18.3 × 10.4 m (6 units)", 18.3, 10.4, 3, 2),173 P("sixplex-b", "Sixplex 3 étages 20,1 × 11 m (6 logements)", "Sixplex 3 storeys 20.1 × 11 m (6 units)", 20.1, 11, 3, 2),174 P("huitplex", "8 logements 3 étages 24,4 × 11 m (3 + 3 + 2)", "8 units 3 storeys 24.4 × 11 m", 24.4, 11, 3, 3, "apartment", { units: 9, spec: { units: 9 } }),175 P("studio-plex", "Triplex de studios 9,1 × 9,1 m (3 logements 1 ch.)", "Triplex of one-bedroom units 9.1 × 9.1 m", 9.1, 9.1, 3, 1, "apartment1"),176 { key: "rangee", kind: "row", fr: "Maison en rangée 6,1 × 11 m (2 étages)", en: "Row house 6.1 × 11 m (2 storeys)", W: 6.1, D: 11, storeys: 2, basement: "unfinished", garage: null, programs: (l) => [l === 0 ? PROGRAMS.rowRdc : PROGRAMS.rowEtage], spec: { siding: { brick: 0.9, vinyl: 0.1 } } },177 { key: "rangee-b", kind: "row", fr: "Maison en rangée 7,3 × 12,2 m (2 étages, sous-sol fini)", en: "Row house 7.3 × 12.2 m (2 storeys, finished basement)", W: 7.3, D: 12.2, storeys: 2, basement: "finished", garage: null, programs: (l) => [l === 0 ? PROGRAMS.rowRdc : PROGRAMS.rowEtage], spec: { basement: "finished", basementFinishedPct: 0.8, siding: { brick: 0.9, vinyl: 0.1 } } },178 { key: "jumele", kind: "cottage", fr: "Jumelé 2 étages 7,9 × 10,4 m (une unité)", en: "Semi-detached 2 storeys 7.9 × 10.4 m (one unit)", W: 7.9, D: 10.4, storeys: 2, basement: "unfinished", garage: null, programs: (l) => [l === 0 ? PROGRAMS.rowRdc : PROGRAMS.rowEtage], spec: { siding: { brick: 0.5, vinyl: 0.5 } } },179 { key: "chalet", kind: "chalet", fr: "Chalet 8,5 × 7,3 m sur pieux", "en": "Cottage cabin 8.5 × 7.3 m on piers", W: 8.5, D: 7.3, storeys: 1, basement: "none", garage: null, programs: () => [PROGRAMS.chalet], spec: { basement: "none", foundation: "piers", roofType: "metal", roofPitch: 8, siding: { wood: 1 }, heating: "electric_baseboard", hasAirExchanger: false } },180 { key: "chalet-b", kind: "chalet", fr: "Chalet 4 saisons 10,4 × 8,5 m, vide sanitaire", en: "Four-season cottage 10.4 × 8.5 m, crawl space", W: 10.4, D: 8.5, storeys: 1, basement: "crawl", garage: null, programs: () => [PROGRAMS.bungalow2], spec: { basement: "crawl", foundationWallHeightM: 1.2, roofType: "metal", roofPitch: 8, siding: { wood: 0.7, stone: 0.3 }, heating: "heat_pump" } },181 { key: "mobile", kind: "mobile", fr: "Maison mobile 4,3 × 18,3 m", en: "Mobile home 4.3 × 18.3 m", W: 18.3, D: 4.3, storeys: 1, basement: "none", garage: null, programs: () => [PROGRAMS.mobile], spec: { basement: "none", foundation: "piers", roofType: "metal", roofGeometry: "flat", roofPitch: 2, siding: { steel: 1 }, hasAirExchanger: false, quality: "economy", kitchenQuality: "economy", bathroomQuality: "economy" } },182 { key: "condo", kind: "condo", fr: "Unité de copropriété 10,4 × 8,5 m (intérieur seulement)", en: "Condo unit 10.4 × 8.5 m (interior only)", W: 10.4, D: 8.5, storeys: 1, basement: "none", garage: null, programs: () => [PROGRAMS.apartment], spec: { basement: "none", foundation: "slab_on_grade", roofGeometry: "flat", roofType: "membrane", roofPitch: 0, siding: { brick: 1 }, heating: "electric_baseboard" } },183 { key: "garage-1", kind: "garage", fr: "Garage détaché simple 4,3 × 6,7 m", en: "Detached single garage 4.3 × 6.7 m", W: 4.3, D: 6.7, storeys: 1, basement: "none", garage: null, programs: () => [], spec: { basement: "none", foundation: "slab_on_grade", foundationWallHeightM: 1.2, siding: { vinyl: 1 }, heating: "electric_baseboard", hasAirExchanger: false, garage: "detached" } },184 { key: "garage-2", kind: "garage", fr: "Garage détaché double 6,7 × 6,7 m", en: "Detached double garage 6.7 × 6.7 m", W: 6.7, D: 6.7, storeys: 1, basement: "none", garage: null, programs: () => [], spec: { basement: "none", foundation: "slab_on_grade", foundationWallHeightM: 1.2, siding: { vinyl: 1 }, heating: "electric_baseboard", hasAirExchanger: false, garage: "detached" } },185 { key: "garage-3", kind: "garage", fr: "Garage détaché 7,3 × 9,1 m avec atelier", en: "Detached garage 7.3 × 9.1 m with workshop", W: 7.3, D: 9.1, storeys: 1, basement: "none", garage: null, programs: () => [], spec: { basement: "none", foundation: "slab_on_grade", foundationWallHeightM: 1.2, siding: { steel: 1 }, heating: "electric_baseboard", hasAirExchanger: false, garage: "detached" } },186];187188const LEVEL_NAMES = ["Rez-de-chaussée", "Étage", "2e étage", "3e étage"];189190export function listTemplates(): Omit<Template, "levels">[] { return DEFS.map((d) => { const t = buildTemplate(d.key)!; const { levels, ...rest } = t; void levels; return rest; }); }191192export function buildTemplate(key: string): Template | null {193 const d = DEFS.find((x) => x.key === key); if (!d) return null;194 const levels: GenLevel[] = [];195 if (d.kind === "garage") {196 const margin = 2; const objs: GenObject[] = [197 { kind: "wall", name: "Murs du garage", points: rect(margin, margin, d.W, d.D), closed: true, props: { thickness: 0.2, height: 3.0, exterior: true }, openings: [{ name: "Porte de garage", kind: "door", at: { x: margin + d.W / 2, y: margin }, widthM: d.W >= 6 ? 4.9 : 2.7, heightM: 2.1, swing: 1 }, { name: "Porte de service", kind: "door", at: { x: margin + d.W, y: margin + d.D * 0.7 }, widthM: 0.9, heightM: 2.1 }, { name: "Fenêtre", kind: "window", at: { x: margin + d.W * 0.5, y: margin + d.D }, widthM: 1.0, heightM: 0.8 }] },198 { kind: "room", name: "Garage", points: rect(margin + 0.1, margin + 0.1, d.W - 0.2, d.D - 0.2), closed: true, props: { usage: "garage", height: 3.0 } },199 { kind: "slab", name: "Dalle 125 mm", points: rect(margin, margin, d.W, d.D), closed: true, props: { thickness: 0.125 } },200 { kind: "footprint", name: "Empreinte", points: rect(margin - 0.1, margin - 0.1, d.W + 0.2, d.D + 0.2), closed: true, props: {} },201 ];202 levels.push({ level: "Rez-de-chaussée", widthM: d.W + 2 * margin, heightM: d.D + 2 * margin, objects: objs });203 } else {204 if (d.basement && d.basement !== "none") levels.push(level("Sous-sol", d.W, d.D, [], { basement: true }));205 for (let l = 0; l < d.storeys; l++) levels.push(level(d.levelNames?.[l] ?? LEVEL_NAMES[l], d.W, d.D, d.programs(l), { garage: l === 0 ? d.garage ?? null : null, unitLabels: d.labels?.(l) }));206 }207 const units = d.units ?? 1;208 const areaM2 = r2(d.W * d.D * d.storeys);209 const spec: Partial<BuildingSpecQS> = { units, basement: d.basement ?? "none", ...(d.spec ?? {}) };210 return { key: d.key, kind: d.kind, fr: d.fr, en: d.en, footprint: `${d.W} × ${d.D} m`, storeys: d.storeys, units, areaM2, spec, levels };211}212213export const TEMPLATE_KINDS: { key: Template["kind"]; fr: string; en: string }[] = [214 { key: "bungalow", fr: "Bungalows", en: "Bungalows" }, { key: "cottage", fr: "Maisons 2 étages / jumelés", en: "Two-storey / semi-detached" }, { key: "plex", fr: "Plex (2 à 9 logements)", en: "Plexes (2 to 9 units)" },215 { key: "row", fr: "Maisons en rangée", en: "Row houses" }, { key: "chalet", fr: "Chalets", en: "Cottages" }, { key: "mobile", fr: "Maisons mobiles", en: "Mobile homes" }, { key: "condo", fr: "Copropriétés", en: "Condos" }, { key: "garage", fr: "Garages détachés", en: "Detached garages" },216];217218/* ------------------------------------------------- matérialisation (px) */219220import type { PlanObject } from "./types";221222/** Convertit un niveau généré (mètres) en objets de plan (pixels) avec identifiants temporaires ; les ouvertures référencent leur mur. */223export function materializeLevel(lv: GenLevel, pxPerMeter: number): PlanObject[] {224 const k = pxPerMeter; let n = 0; const out: PlanObject[] = [];225 const px = (p: Point): Point => ({ x: Math.round(p.x * k * 100) / 100, y: Math.round(p.y * k * 100) / 100 });226 for (const o of lv.objects) {227 const id = `tmp-${++n}`;228 out.push({ id, pageId: "", kind: o.kind, layer: o.kind === "wall" ? "murs" : o.kind === "room" ? "pièces" : o.kind === "footprint" || o.kind === "lot" ? "site" : o.kind === "slab" ? "fondations" : "default", name: o.name, points: o.points.map(px), closed: o.closed, props: { ...o.props }, createdAt: "", updatedAt: "" });229 for (const op of o.openings ?? []) out.push({ id: `tmp-${++n}`, pageId: "", kind: "opening", layer: "ouvertures", name: op.name, points: [px(op.at)], closed: false, props: { openingKind: op.kind, openingWidth: op.widthM, openingHeight: op.heightM, count: 1, deductFrom: id, swing: op.swing ?? 1 }, createdAt: "", updatedAt: "" });230 }231 return out;232}233