1// UQO Éval — Métré : registre de mesures — formules inspectables, déductions d'ouvertures sans double compte. Module PUR.2/**3 * Formules (toutes en unités SI internes : m, m², m³) :4 * polygon_area = aire du lacet (px²) ÷ (px/m)²5 * polyline_length = Σ segments (px) ÷ (px/m)6 * wall_gross_area = longueur × hauteur7 * wall_net_area = wall_gross_area − Σ ouvertures VÉRIFIÉES déduites de ce mur (une seule fois chacune)8 * slab_volume = aire × épaisseur9 * roof_surface_area = aire projetée × √(1 + (pente/12)²)10 * opening_area = largeur × hauteur × nombre11 * count = nombre12 * assembly_quantity = takeoff_quantity × facteur d'ajustement explicite (voir estimate-engine)13 *14 * Garde-fous : une ouverture ne peut être déduite que d'un seul mur15 * (`deductFrom`), une ouverture non vérifiée n'est jamais déduite, une16 * calibration manquante rend la mesure impossible (pas de valeur fictive), et17 * une mesure calculée sous une révision de calibration antérieure est marquée18 * `stale` tant qu'elle n'est pas recalculée.19 */20import type { Calibration, FormulaKey, MeasureUnit, PlanObject } from "./types";21import { isSelfIntersecting, polygonAreaPx, polylineLengthPx, px2ToM2, pxToMeters, roofSlopeFactor } from "./geometry";2223export const FORMULA_VERSION = "metre-formulas-1.0";2425export interface ComputedMeasure {26 formula: FormulaKey;27 formulaText: string;28 inputs: Record<string, number | string | null>;29 value: number;30 unit: MeasureUnit;31 lineage: string[];32 warnings: string[];33}3435export const FORMULA_LABEL: Record<FormulaKey, { fr: string; en: string }> = {36 polygon_area: { fr: "aire = aire du polygone", en: "area = polygon area" },37 polyline_length: { fr: "longueur = Σ segments", en: "length = Σ segments" },38 wall_gross_area: { fr: "mur brut = longueur × hauteur", en: "wall gross = length × height" },39 wall_net_area: { fr: "mur net = mur brut − ouvertures vérifiées", en: "wall net = gross − verified openings" },40 slab_volume: { fr: "volume = aire × épaisseur", en: "volume = area × thickness" },41 roof_surface_area: { fr: "toit = aire projetée × √(1 + (pente/12)²)", en: "roof = projected area × √(1 + (pitch/12)²)" },42 opening_area: { fr: "ouverture = largeur × hauteur × nombre", en: "opening = width × height × count" },43 count: { fr: "nombre", en: "count" },44 manual: { fr: "saisie manuelle", en: "manual entry" },45};4647/** Formule par défaut selon le type d'objet. */48export function defaultFormulaFor(kind: PlanObject["kind"]): FormulaKey {49 switch (kind) {50 case "room": case "slab": case "area": case "footprint": case "lot": case "stairs": return "polygon_area";51 case "foundation": return "slab_volume";52 case "wall": return "wall_net_area";53 case "roof": return "roof_surface_area";54 case "opening": return "opening_area";55 case "line": return "polyline_length";56 default: return "count";57 }58}5960/** Objets purement graphiques (jamais mesurés automatiquement). */61export const NON_MEASURABLE: PlanObject["kind"][] = ["dimension", "text", "annotation"];6263/** Formules produites par « Mesurer tout » pour chaque type d'objet. */64export function formulasFor(kind: PlanObject["kind"]): FormulaKey[] {65 switch (kind) {66 case "wall": return ["polyline_length", "wall_gross_area", "wall_net_area"];67 case "room": return ["polygon_area", "polyline_length"];68 case "footprint": case "lot": case "area": case "slab": case "stairs": return ["polygon_area"];69 case "foundation": return ["slab_volume"];70 case "roof": return ["roof_surface_area"];71 case "opening": return ["opening_area", "count"];72 case "line": return ["polyline_length"];73 case "column": return ["count"];74 default: return [];75 }76}7778const r4 = (x: number) => Math.round(x * 10000) / 10000;7980/**81 * Calcule une mesure pour un objet. `openings` = ouvertures de la même page82 * dont `props.deductFrom === obj.id` ET déjà vérifiées (le service filtre).83 */84export function computeMeasure(obj: PlanObject, formula: FormulaKey, cal: Calibration | null, openings: { obj: PlanObject; verified: boolean }[] = []): ComputedMeasure {85 const warnings: string[] = [];86 const lineage: string[] = [obj.id];87 if (formula === "count") {88 const n = Math.max(0, Math.round(obj.props.count ?? 1));89 return { formula, formulaText: FORMULA_LABEL.count.fr, inputs: { nombre: n }, value: n, unit: "unit", lineage, warnings };90 }91 if (formula === "manual") throw new Error("La formule « manual » ne se calcule pas depuis un objet.");92 if (!cal) throw new Error("Page non calibrée : impossible de mesurer (aucune valeur ne sera inventée).");93 const pts = obj.points;94 if (formula === "opening_area") {95 const isWin = obj.props.openingKind === "window";96 const w = obj.props.openingWidth ?? (isWin ? 1.2 : 0.9), h = obj.props.openingHeight ?? (isWin ? 1.2 : 2.1), n = Math.max(1, Math.round(obj.props.count ?? 1));97 if (!(w > 0 && h > 0)) throw new Error("Ouverture : largeur et hauteur requises (m).");98 return { formula, formulaText: FORMULA_LABEL.opening_area.fr, inputs: { largeur_m: w, hauteur_m: h, nombre: n }, value: r4(w * h * n), unit: "m2", lineage, warnings };99 }100 if (formula === "polyline_length") {101 if (pts.length < 2) throw new Error("Au moins deux points requis.");102 const px = polylineLengthPx(pts, obj.closed);103 const m = pxToMeters(px, cal);104 return { formula, formulaText: obj.closed ? "périmètre = Σ segments (fermé)" : FORMULA_LABEL.polyline_length.fr, inputs: { longueur_px: r4(px), px_par_m: r4(cal.pxPerMeter), calibration_rev: cal.revision, ferme: obj.closed ? 1 : 0 }, value: r4(m), unit: "m", lineage, warnings };105 }106 if (formula === "wall_gross_area" || formula === "wall_net_area") {107 if (pts.length < 2) throw new Error("Un mur requiert au moins deux points.");108 const height = obj.props.height ?? 2.44;109 if (!(height > 0)) throw new Error("Mur : hauteur requise (m).");110 const px = polylineLengthPx(pts, obj.closed);111 const length = pxToMeters(px, cal);112 const gross = length * height;113 if (formula === "wall_gross_area") return { formula, formulaText: FORMULA_LABEL.wall_gross_area.fr, inputs: { longueur_m: r4(length), hauteur_m: height, calibration_rev: cal.revision }, value: r4(gross), unit: "m2", lineage, warnings };114 // déductions : une fois par ouverture, ouvertures vérifiées seulement, jamais plus que le brut115 const seen = new Set<string>();116 let deducted = 0;117 let skipped = 0;118 for (const o of openings) {119 if (o.obj.props.deductFrom !== obj.id) continue;120 if (seen.has(o.obj.id)) continue;121 seen.add(o.obj.id);122 if (!o.verified) { skipped++; continue; }123 const isWin = o.obj.props.openingKind === "window";124 const w = o.obj.props.openingWidth ?? (isWin ? 1.2 : 0.9), h = o.obj.props.openingHeight ?? (isWin ? 1.2 : 2.1), n = Math.max(1, Math.round(o.obj.props.count ?? 1));125 if (w > 0 && h > 0) { deducted += w * h * n; lineage.push(o.obj.id); }126 }127 if (skipped) warnings.push(`${skipped} ouverture(s) non vérifiée(s) non déduite(s)`);128 if (deducted > gross) { warnings.push("ouvertures > mur brut : déduction plafonnée au brut"); deducted = gross; }129 return { formula, formulaText: FORMULA_LABEL.wall_net_area.fr, inputs: { longueur_m: r4(length), hauteur_m: height, mur_brut_m2: r4(gross), ouvertures_m2: r4(deducted), ouvertures_n: lineage.length - 1, calibration_rev: cal.revision }, value: r4(gross - deducted), unit: "m2", lineage, warnings };130 }131 // formules d'aire (polygone)132 if (pts.length < 3) throw new Error("Un polygone requiert au moins trois points.");133 if (isSelfIntersecting(pts)) warnings.push("polygone auto-intersectant : aire non fiable");134 const areaPx = polygonAreaPx(pts);135 const area = px2ToM2(areaPx, cal);136 if (formula === "polygon_area") return { formula, formulaText: FORMULA_LABEL.polygon_area.fr, inputs: { aire_px2: r4(areaPx), px_par_m: r4(cal.pxPerMeter), calibration_rev: cal.revision }, value: r4(area), unit: "m2", lineage, warnings };137 if (formula === "slab_volume") {138 const t = obj.props.thickness ?? 0;139 if (!(t > 0)) throw new Error("Dalle/fondation : épaisseur requise (m).");140 return { formula, formulaText: FORMULA_LABEL.slab_volume.fr, inputs: { aire_m2: r4(area), epaisseur_m: t, calibration_rev: cal.revision }, value: r4(area * t), unit: "m3", lineage, warnings };141 }142 if (formula === "roof_surface_area") {143 const pitch = obj.props.pitch ?? 0;144 const f = roofSlopeFactor(pitch);145 if (!(pitch >= 0)) throw new Error("Toit : pente requise (po/12).");146 return { formula, formulaText: FORMULA_LABEL.roof_surface_area.fr, inputs: { aire_projetee_m2: r4(area), pente_po_12: pitch, facteur_pente: r4(f), calibration_rev: cal.revision }, value: r4(area * f), unit: "m2", lineage, warnings };147 }148 throw new Error(`Formule inconnue : ${formula}`);149}150151/** Vrai si la mesure a été calculée sous une révision de calibration différente de la révision courante. */152export function isStale(measureRevision: number | null, currentRevision: number | null): boolean {153 if (measureRevision == null) return false; // mesure manuelle / compte154 return currentRevision == null || measureRevision !== currentRevision;155}156