Repository Public
1// UQO Éval — Métré : géométrie d'un plan d'architecte (murs à épaisseur, ouvertures dans les murs, cotations, pièces). Module PUR.2/**3 * Toutes les coordonnées sont en PIXELS de page ; l'épaisseur des murs et les4 * largeurs d'ouvertures sont en MÈTRES dans les propriétés et converties avec5 * `pxPerMeter` (calibration de la page). Les fonctions sont partagées par6 * l'éditeur interactif (React/SVG) et le rendu serveur (export SVG/PNG/PDF).7 */8import type { PlanObject, Point } from "./types";9import { dist, polygonAreaPx, polylineLengthPx } from "./geometry";1011export const DEFAULT_WALL_THICKNESS_M = 0.3; // mur extérieur (2×6 + isolant + revêtements)12export const DEFAULT_PARTITION_THICKNESS_M = 0.12; // cloison 2×4 + gypse13export const DEFAULT_WALL_HEIGHT_M = 2.44;14export const DEFAULT_DOOR_WIDTH_M = 0.9, DEFAULT_DOOR_HEIGHT_M = 2.1;15export const DEFAULT_WINDOW_WIDTH_M = 1.2, DEFAULT_WINDOW_HEIGHT_M = 1.2;1617export type Seg = { a: Point; b: Point };1819export function segments(points: Point[], closed: boolean): Seg[] {20 const out: Seg[] = [];21 for (let i = 1; i < points.length; i++) out.push({ a: points[i - 1], b: points[i] });22 if (closed && points.length > 2) out.push({ a: points[points.length - 1], b: points[0] });23 return out;24}2526const sub = (a: Point, b: Point): Point => ({ x: a.x - b.x, y: a.y - b.y });27const add = (a: Point, b: Point): Point => ({ x: a.x + b.x, y: a.y + b.y });28const mul = (a: Point, k: number): Point => ({ x: a.x * k, y: a.y * k });29const norm = (a: Point): Point => { const l = Math.hypot(a.x, a.y) || 1; return { x: a.x / l, y: a.y / l }; };30const perp = (a: Point): Point => ({ x: -a.y, y: a.x });3132/**33 * Contour d'un mur (polyligne de centre + épaisseur) : décalage gauche/droite34 * avec jonctions en onglet (limitées à 4 × demi-épaisseur pour les angles aigus).35 */36export function wallOutline(points: Point[], thicknessPx: number, closed: boolean): Point[] {37 const n = points.length;38 if (n < 2 || thicknessPx <= 0) return [];39 if (closed) return closedWallRings(points, thicknessPx).outer;40 const h = thicknessPx / 2;41 const dirs: Point[] = [];42 for (let i = 0; i < n - 1; i++) dirs.push(norm(sub(points[i + 1], points[i])));43 const side = (sign: number): Point[] => {44 const out: Point[] = [];45 for (let i = 0; i < n; i++) {46 const dPrev = i > 0 ? dirs[i - 1] : null, dNext = i < n - 1 ? dirs[i] : null;47 if (dPrev && dNext) {48 const nPrev = mul(perp(dPrev), sign * h), nNext = mul(perp(dNext), sign * h);49 const bis = add(nPrev, nNext); const bl = Math.hypot(bis.x, bis.y);50 if (bl < 1e-9) { out.push(add(points[i], nPrev)); continue; }51 const cosHalf = (bis.x * nPrev.x + bis.y * nPrev.y) / (bl * h);52 out.push(add(points[i], mul(norm(bis), Math.min(h / Math.max(0.25, cosHalf), 4 * h))));53 } else if (dNext) out.push(add(points[i], mul(perp(dNext), sign * h)));54 else if (dPrev) out.push(add(points[i], mul(perp(dPrev), sign * h)));55 }56 return out;57 };58 return [...side(1), ...side(-1).reverse()];59}6061/** Les deux contours (extérieur/intérieur) d'un mur en boucle fermée, pour le rendu « evenodd ». */62export function closedWallRings(points: Point[], thicknessPx: number): { outer: Point[]; inner: Point[] } {63 const n = points.length;64 if (n < 3) return { outer: [], inner: [] };65 const h = thicknessPx / 2;66 const ring = (sign: number): Point[] => {67 const out: Point[] = [];68 for (let i = 0; i < n; i++) {69 const dPrev = norm(sub(points[i], points[(i - 1 + n) % n])), dNext = norm(sub(points[(i + 1) % n], points[i]));70 const nPrev = mul(perp(dPrev), sign * h), nNext = mul(perp(dNext), sign * h);71 const bis = add(nPrev, nNext);72 const bl = Math.hypot(bis.x, bis.y);73 if (bl < 1e-9) { out.push(add(points[i], nPrev)); continue; }74 const cosHalf = (bis.x * nPrev.x + bis.y * nPrev.y) / (bl * h);75 const len = Math.min(h / Math.max(0.25, cosHalf), 4 * h);76 out.push(add(points[i], mul(norm(bis), len)));77 }78 return out;79 };80 // orientation : on détermine le côté extérieur par l'aire (le plus grand)81 const a = ring(1), b = ring(-1);82 return polygonAreaPx(a) >= polygonAreaPx(b) ? { outer: a, inner: b } : { outer: b, inner: a };83}8485/** Projection d'un point sur une polyligne : segment, paramètre, point projeté, angle du segment (rad), distance. */86export function projectOnPolyline(p: Point, points: Point[], closed: boolean): { segIndex: number; t: number; point: Point; angle: number; dist: number } | null {87 const segs = segments(points, closed);88 let best: ReturnType<typeof projectOnPolyline> = null;89 segs.forEach((s, i) => {90 const d = sub(s.b, s.a); const l2 = d.x * d.x + d.y * d.y;91 const t = l2 ? Math.max(0, Math.min(1, ((p.x - s.a.x) * d.x + (p.y - s.a.y) * d.y) / l2)) : 0;92 const q = add(s.a, mul(d, t)); const dd = dist(p, q);93 if (!best || dd < best.dist) best = { segIndex: i, t, point: q, angle: Math.atan2(d.y, d.x), dist: dd };94 });95 return best;96}9798/** Ouverture posée sur un mur : centre, angle du mur, demi-largeur en px, épaisseur du mur en px. */99export interface OpeningPlacement { center: Point; angle: number; halfWidthPx: number; thicknessPx: number; kind: "door" | "window" | "opening"; swing: number; valid: boolean }100101export function placeOpening(opening: PlanObject, wall: PlanObject | null, pxPerMeter: number): OpeningPlacement {102 const kind = (opening.props.openingKind ?? "door") as OpeningPlacement["kind"];103 const widthPx = (opening.props.openingWidth ?? (kind === "window" ? DEFAULT_WINDOW_WIDTH_M : DEFAULT_DOOR_WIDTH_M)) * pxPerMeter;104 const p = opening.points[0] ?? { x: 0, y: 0 };105 if (!wall || wall.points.length < 2) return { center: p, angle: 0, halfWidthPx: widthPx / 2, thicknessPx: 0.12 * pxPerMeter, kind, swing: opening.props.swing ?? 1, valid: false };106 const pr = projectOnPolyline(p, wall.points, wall.closed)!;107 return { center: pr.point, angle: pr.angle, halfWidthPx: widthPx / 2, thicknessPx: (wall.props.thickness ?? DEFAULT_WALL_THICKNESS_M) * pxPerMeter, kind, swing: opening.props.swing ?? 1, valid: true };108}109110/** Tracé SVG (attribut d) d'une porte : vantail + arc de battement, repère local (centre, angle). */111export function doorPath(o: OpeningPlacement): { leaf: string; arc: string; cut: string } {112 // repère local : x le long du mur, y perpendiculaire ; swing = ±1 (côté du battement), ±2 (charnière à droite)113 const w = o.halfWidthPx * 2, s = o.swing >= 0 ? 1 : -1, hingeRight = Math.abs(o.swing) >= 2;114 const x0 = hingeRight ? o.halfWidthPx : -o.halfWidthPx, x1 = hingeRight ? -o.halfWidthPx : o.halfWidthPx;115 const t = o.thicknessPx / 2;116 const cut = `M ${-o.halfWidthPx} ${-t} L ${o.halfWidthPx} ${-t} L ${o.halfWidthPx} ${t} L ${-o.halfWidthPx} ${t} Z`;117 const leaf = `M ${x0} 0 L ${x0} ${-s * w}`;118 const sweep = (s > 0) !== hingeRight ? 1 : 0;119 const arc = `M ${x0} ${-s * w} A ${w} ${w} 0 0 ${sweep} ${x1} 0`;120 return { leaf, arc, cut };121}122123/** Fenêtre : rectangle découpé dans le mur + trois traits (vitrage) ; repère local. */124export function windowPath(o: OpeningPlacement): { cut: string; glass: string } {125 const x0 = -o.halfWidthPx, x1 = o.halfWidthPx, t = o.thicknessPx / 2;126 return { cut: `M ${x0} ${-t} L ${x1} ${-t} L ${x1} ${t} L ${x0} ${t} Z`, glass: `M ${x0} ${-t / 3} L ${x1} ${-t / 3} M ${x0} 0 L ${x1} 0 M ${x0} ${t / 3} L ${x1} ${t / 3}` };127}128129/** Cotation entre deux points, décalée perpendiculairement de `offsetPx` : lignes d'attache, ligne de cote, position/angle du texte. */130export function dimensionGeometry(a: Point, b: Point, offsetPx: number): { ext1: Seg; ext2: Seg; line: Seg; textPos: Point; angleDeg: number; lengthPx: number } {131 const d = norm(sub(b, a)); const n = mul(perp(d), offsetPx);132 const a2 = add(a, n), b2 = add(b, n);133 const over = mul(perp(d), offsetPx + Math.sign(offsetPx || 1) * 6);134 let angle = (Math.atan2(d.y, d.x) * 180) / Math.PI;135 if (angle > 90) angle -= 180; else if (angle <= -90) angle += 180; // texte toujours lisible136 return { ext1: { a, b: add(a, over) }, ext2: { a: b, b: add(b, over) }, line: { a: a2, b: b2 }, textPos: add(mul(add(a2, b2), 0.5), mul(perp(d), Math.sign(offsetPx || 1) * -4)), angleDeg: angle, lengthPx: dist(a, b) };137}138139export function centroid(points: Point[]): Point {140 if (points.length < 3) return points[0] ?? { x: 0, y: 0 };141 let a = 0, cx = 0, cy = 0;142 for (let i = 0; i < points.length; i++) {143 const p = points[i], q = points[(i + 1) % points.length];144 const f = p.x * q.y - q.x * p.y; a += f; cx += (p.x + q.x) * f; cy += (p.y + q.y) * f;145 }146 if (Math.abs(a) < 1e-9) return { x: points.reduce((s, p) => s + p.x, 0) / points.length, y: points.reduce((s, p) => s + p.y, 0) / points.length };147 return { x: cx / (3 * a), y: cy / (3 * a) };148}149150/** Aimantation : sommets et segments de murs/pièces, puis orthogonalité, puis grille. */151export function snapPoint(p: Point, opts: { objects: PlanObject[]; draft: Point[]; anchor: Point | null; ortho: boolean; grid: number | null; tolPx: number }): { p: Point; kind: "vertex" | "segment" | "ortho" | "grid" | "free" } {152 const { tolPx } = opts;153 let best: { p: Point; d: number; kind: "vertex" | "segment" } | null = null;154 for (const o of opts.objects) {155 if (o.kind === "text" || o.kind === "dimension" || o.kind === "opening") continue;156 for (const q of o.points) { const d = dist(p, q); if (d < tolPx && (!best || d < best.d)) best = { p: { ...q }, d, kind: "vertex" }; }157 }158 for (const q of opts.draft) { const d = dist(p, q); if (d < tolPx && (!best || d < best.d)) best = { p: { ...q }, d, kind: "vertex" }; }159 if (best) return { p: best.p, kind: best.kind };160 for (const o of opts.objects) {161 if (o.kind !== "wall" && o.kind !== "room" && o.kind !== "footprint") continue;162 const pr = projectOnPolyline(p, o.points, o.closed);163 if (pr && pr.dist < tolPx * 0.8 && (!best || pr.dist < (best as { d: number }).d)) best = { p: pr.point, d: pr.dist, kind: "segment" };164 }165 if (best) return { p: (best as { p: Point }).p, kind: "segment" };166 if (opts.ortho && opts.anchor) {167 const dx = p.x - opts.anchor.x, dy = p.y - opts.anchor.y;168 const ang = Math.atan2(dy, dx); const step = Math.PI / 12; // 15°169 const snapped = Math.round(ang / step) * step; const len = Math.hypot(dx, dy);170 const q = { x: opts.anchor.x + Math.cos(snapped) * len, y: opts.anchor.y + Math.sin(snapped) * len };171 if (opts.grid && Math.abs(Math.sin(snapped)) < 1e-6) q.x = Math.round(q.x / opts.grid) * opts.grid;172 if (opts.grid && Math.abs(Math.cos(snapped)) < 1e-6) q.y = Math.round(q.y / opts.grid) * opts.grid;173 return { p: q, kind: "ortho" };174 }175 if (opts.grid) return { p: { x: Math.round(p.x / opts.grid) * opts.grid, y: Math.round(p.y / opts.grid) * opts.grid }, kind: "grid" };176 return { p, kind: "free" };177}178179/** Quantités architecturales d'une page (m, m², unités) — à partir des objets et de la calibration. */180export interface PlanQuantities {181 walls: { id: string; name: string; lengthM: number; heightM: number; thicknessM: number; grossAreaM2: number; openingsAreaM2: number; netAreaM2: number; exterior: boolean }[];182 rooms: { id: string; name: string; areaM2: number; perimeterM: number; heightM: number }[];183 openings: { id: string; kind: string; wallId: string | null; widthM: number; heightM: number; areaM2: number }[];184 footprintM2: number | null;185 totals: { wallLengthM: number; wallNetAreaM2: number; extWallNetAreaM2: number; floorAreaM2: number; doors: number; windows: number };186}187188export function planQuantities(objects: PlanObject[], pxPerMeter: number | null): PlanQuantities {189 const k = pxPerMeter && pxPerMeter > 0 ? 1 / pxPerMeter : null;190 const walls = objects.filter((o) => o.kind === "wall" && o.points.length >= 2);191 const openings = objects.filter((o) => o.kind === "opening");192 const rooms = objects.filter((o) => o.kind === "room" && o.points.length >= 3);193 const fp = objects.find((o) => o.kind === "footprint" && o.points.length >= 3);194 const opn = openings.map((o) => {195 const kind = (o.props.openingKind ?? "door") as string;196 const w = o.props.openingWidth ?? (kind === "window" ? DEFAULT_WINDOW_WIDTH_M : DEFAULT_DOOR_WIDTH_M), h = o.props.openingHeight ?? (kind === "window" ? DEFAULT_WINDOW_HEIGHT_M : DEFAULT_DOOR_HEIGHT_M);197 return { id: o.id, kind, wallId: o.props.deductFrom ?? null, widthM: w, heightM: h, areaM2: Math.round(w * h * (o.props.count ?? 1) * 1000) / 1000 };198 });199 const w = walls.map((o) => {200 const lengthM = k ? polylineLengthPx(o.points, o.closed) * k : 0;201 const heightM = o.props.height ?? DEFAULT_WALL_HEIGHT_M, thicknessM = o.props.thickness ?? DEFAULT_WALL_THICKNESS_M;202 const gross = lengthM * heightM;203 const opA = opn.filter((x) => x.wallId === o.id).reduce((s, x) => s + x.areaM2, 0);204 return { id: o.id, name: o.name, lengthM: r3(lengthM), heightM, thicknessM, grossAreaM2: r3(gross), openingsAreaM2: r3(Math.min(gross, opA)), netAreaM2: r3(Math.max(0, gross - opA)), exterior: !!o.props.exterior };205 });206 const rm = rooms.map((o) => ({ id: o.id, name: o.name, areaM2: k ? r3(polygonAreaPx(o.points) * k * k) : 0, perimeterM: k ? r3(polylineLengthPx(o.points, true) * k) : 0, heightM: o.props.height ?? DEFAULT_WALL_HEIGHT_M }));207 return {208 walls: w, rooms: rm, openings: opn, footprintM2: fp && k ? r3(polygonAreaPx(fp.points) * k * k) : null,209 totals: { wallLengthM: r3(w.reduce((s, x) => s + x.lengthM, 0)), wallNetAreaM2: r3(w.reduce((s, x) => s + x.netAreaM2, 0)), extWallNetAreaM2: r3(w.filter((x) => x.exterior).reduce((s, x) => s + x.netAreaM2, 0)), floorAreaM2: r3(rm.reduce((s, x) => s + x.areaM2, 0)), doors: opn.filter((x) => x.kind === "door").length, windows: opn.filter((x) => x.kind === "window").length },210 };211}212213const r3 = (x: number) => Math.round(x * 1000) / 1000;214215/** Format d'une longueur en m (ou pi-po si impérial). */216export function fmtLength(m: number, imperial: boolean): string {217 if (!imperial) return `${(Math.round(m * 100) / 100).toLocaleString("fr-CA", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} m`;218 const totalIn = m / 0.0254; const ft = Math.floor(totalIn / 12); const inch = Math.round((totalIn - ft * 12) * 2) / 2;219 return inch >= 12 ? `${ft + 1}'-0"` : `${ft}'-${inch}"`;220}221export function fmtArea(m2: number, imperial: boolean): string {222 return imperial ? `${Math.round(m2 * 10.7639).toLocaleString("fr-CA")} pi²` : `${(Math.round(m2 * 100) / 100).toLocaleString("fr-CA", { maximumFractionDigits: 2 })} m²`;223}224