SPB Git

spb/uqo-eval

Public
9.1 KB · 156 lines typescript
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
Files · main
Menu

Theme