1// UQO Éval — Métré : géométrie plane des objets de plan et calibration d'échelle. Module PUR.2import type { Calibration, LengthUnit, Point } from "./types";3import { lengthToMeters } from "./units";45export const GEOMETRY_VERSION = "metre-geo-1.0";67/** précision de clic supposée pour l'incertitude de calibration (pixels) */8export const CLICK_SIGMA_PX = 1.5;910export function dist(a: Point, b: Point): number {11 return Math.hypot(b.x - a.x, b.y - a.y);12}1314/** Longueur d'une polyligne (pixels). `closed` ajoute le segment de fermeture. */15export function polylineLengthPx(points: Point[], closed = false): number {16 if (points.length < 2) return 0;17 let s = 0;18 for (let i = 1; i < points.length; i++) s += dist(points[i - 1], points[i]);19 if (closed && points.length > 2) s += dist(points[points.length - 1], points[0]);20 return s;21}2223/** Aire d'un polygone simple (formule du lacet), en pixels². Toujours positive. */24export function polygonAreaPx(points: Point[]): number {25 if (points.length < 3) return 0;26 let s = 0;27 for (let i = 0; i < points.length; i++) {28 const a = points[i], b = points[(i + 1) % points.length];29 s += a.x * b.y - b.x * a.y;30 }31 return Math.abs(s) / 2;32}3334/** Vrai si deux segments (a,b) et (c,d) se croisent strictement. */35function segmentsCross(a: Point, b: Point, c: Point, d: Point): boolean {36 const o = (p: Point, q: Point, r: Point) => Math.sign((q.x - p.x) * (r.y - p.y) - (q.y - p.y) * (r.x - p.x));37 const o1 = o(a, b, c), o2 = o(a, b, d), o3 = o(c, d, a), o4 = o(c, d, b);38 return o1 !== o2 && o3 !== o4 && o1 !== 0 && o2 !== 0 && o3 !== 0 && o4 !== 0;39}4041/** Détecte un polygone auto-intersectant (aire du lacet non fiable). */42export function isSelfIntersecting(points: Point[]): boolean {43 const n = points.length;44 if (n < 4) return false;45 for (let i = 0; i < n; i++) {46 for (let j = i + 2; j < n; j++) {47 if (i === 0 && j === n - 1) continue; // segments adjacents (fermeture)48 if (segmentsCross(points[i], points[(i + 1) % n], points[j], points[(j + 1) % n])) return true;49 }50 }51 return false;52}5354export function bounds(points: Point[]): { minX: number; minY: number; maxX: number; maxY: number } {55 let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;56 for (const p of points) { minX = Math.min(minX, p.x); minY = Math.min(minY, p.y); maxX = Math.max(maxX, p.x); maxY = Math.max(maxY, p.y); }57 return { minX, minY, maxX, maxY };58}5960/* ---------------------------------------------------------- calibration */6162/**63 * Construit une calibration à partir de deux points et de la distance réelle64 * connue. L'incertitude relative combine l'erreur de clic (σ ≈ 1,5 px par65 * point, √2 pour deux points) et l'incertitude de la longueur saisie (0 par66 * défaut). Jamais inventée : si la distance en pixels est trop courte, l'erreur67 * monte mécaniquement et l'interface l'affiche.68 */69export function makeCalibration(input: { id: string; pageId: string; revision: number; p1: Point; p2: Point; realLength: number; unit: LengthUnit; createdBy: string; createdAt: string; lengthUncertaintyPct?: number }): Calibration {70 const px = dist(input.p1, input.p2);71 if (!(px > 0)) throw new Error("Les deux points de calibration sont confondus.");72 if (!(input.realLength > 0)) throw new Error("La longueur réelle doit être positive.");73 const meters = lengthToMeters(input.realLength, input.unit);74 const pxPerMeter = px / meters;75 const clickPct = (CLICK_SIGMA_PX * Math.SQRT2) / px * 100;76 const uncertaintyPct = Math.sqrt(clickPct ** 2 + (input.lengthUncertaintyPct ?? 0) ** 2);77 return { id: input.id, pageId: input.pageId, revision: input.revision, p1: input.p1, p2: input.p2, realLength: input.realLength, unit: input.unit, pxPerMeter, uncertaintyPct: Math.round(uncertaintyPct * 100) / 100, createdBy: input.createdBy, createdAt: input.createdAt };78}7980/** pixels → mètres. */81export function pxToMeters(px: number, cal: Calibration): number { return px / cal.pxPerMeter; }82/** pixels² → m². */83export function px2ToM2(px2: number, cal: Calibration): number { return px2 / cal.pxPerMeter ** 2; }8485/* --------------------------------------------------------------- toiture */8687/**88 * Facteur de pente : surface réelle = surface projetée × √(1 + (pente/12)²).89 * La pente est la hausse en pouces par 12 pouces de course (6/12 ≈ 1,118).90 */91export function roofSlopeFactor(pitchRise12: number): number {92 const p = Math.max(0, pitchRise12 || 0) / 12;93 return Math.sqrt(1 + p * p);94}9596/** Facteur de pente depuis un angle en degrés. */97export function roofSlopeFactorFromDegrees(deg: number): number {98 const r = (Math.max(0, Math.min(89, deg)) * Math.PI) / 180;99 return 1 / Math.cos(r);100}101