1// UQO Éval — Métré : rendu SVG d'un plan d'architecte (murs, portes, fenêtres, pièces, cotations, textes, cartouche). Module PUR, partagé client/serveur.2import type { PlanObject, Point } from "./types";3import { polygonAreaPx, polylineLengthPx } from "./geometry";4import { DEFAULT_WALL_THICKNESS_M, centroid, closedWallRings, dimensionGeometry, doorPath, fmtArea, fmtLength, placeOpening, wallOutline, windowPath } from "./plan-geometry";56export interface RenderOptions {7 widthPx: number; heightPx: number; pxPerMeter: number | null; imperial: boolean;8 background?: string | null; // href d'image de fond (page importée / satellite)9 grid?: boolean; selectedId?: string | null; showLabels?: boolean; showDims?: boolean; hiddenLayers?: Set<string>;10 title?: { project: string; level: string; scale: string; date: string; author: string } | null; // cartouche (export)11}1213const esc = (s: string) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);14const pts = (p: Point[]) => p.map((q) => `${q.x.toFixed(2)},${q.y.toFixed(2)}`).join(" ");15const COLORS: Record<string, string> = { room: "#006085", slab: "#8a5a12", foundation: "#5b3a29", roof: "#b3423a", line: "#4b5258", area: "#6c4fb8", footprint: "#00465f", lot: "#2a7d2e", stairs: "#333", column: "#111" };1617/** Éléments SVG internes (sans balise <svg>) — utilisés par l'éditeur (dangerouslySetInnerHTML du calque de rendu) et l'export. */18export function renderPlanInner(objects: PlanObject[], o: RenderOptions): string {19 const k = o.pxPerMeter ?? 100;20 const hidden = o.hiddenLayers ?? new Set<string>();21 const vis = objects.filter((x) => !hidden.has(x.layer));22 const walls = vis.filter((x) => x.kind === "wall" && x.points.length >= 2);23 const byId = new Map(objects.map((x) => [x.id, x]));24 const parts: string[] = [];25 // grille (1 m / 0,1 m) si demandée26 if (o.grid && o.pxPerMeter) {27 const g = o.pxPerMeter; const minor = g / 10;28 parts.push(`<g class="grid" opacity="0.9">`);29 if (minor >= 6) for (let x = 0; x <= o.widthPx; x += minor) parts.push(`<line x1="${x}" y1="0" x2="${x}" y2="${o.heightPx}" stroke="#e4e7ea" stroke-width="0.5"/>`);30 if (minor >= 6) for (let y = 0; y <= o.heightPx; y += minor) parts.push(`<line x1="0" y1="${y}" x2="${o.widthPx}" y2="${y}" stroke="#e4e7ea" stroke-width="0.5"/>`);31 for (let x = 0; x <= o.widthPx; x += g) parts.push(`<line x1="${x}" y1="0" x2="${x}" y2="${o.heightPx}" stroke="#c9d1d6" stroke-width="0.8"/>`);32 for (let y = 0; y <= o.heightPx; y += g) parts.push(`<line x1="0" y1="${y}" x2="${o.widthPx}" y2="${y}" stroke="#c9d1d6" stroke-width="0.8"/>`);33 parts.push(`</g>`);34 }35 // lot et empreinte (dessous)36 for (const x of vis.filter((v) => v.kind === "lot" || v.kind === "footprint")) {37 const c = COLORS[x.kind]; parts.push(`<polygon data-id="${x.id}" points="${pts(x.points)}" fill="${c}0f" stroke="${c}" stroke-width="${x.id === o.selectedId ? 3 : 1.5}" stroke-dasharray="${x.kind === "lot" ? "10 6" : "none"}"/>`);38 if (o.showLabels !== false && o.pxPerMeter) { const c0 = centroid(x.points); parts.push(`<text x="${c0.x}" y="${c0.y}" font-size="${Math.max(10, k * 0.22)}" fill="${c}" text-anchor="middle" font-family="ui-sans-serif,system-ui" paint-order="stroke" stroke="#fff" stroke-width="3">${esc(x.name)} · ${fmtArea(polygonAreaPx(x.points) / (k * k), o.imperial)}</text>`); }39 }40 // pièces (surface teintée + nom + aire)41 for (const x of vis.filter((v) => v.kind === "room" || v.kind === "area" || v.kind === "slab" || v.kind === "foundation" || v.kind === "roof")) {42 const c = COLORS[x.kind] ?? "#006085";43 parts.push(`<polygon data-id="${x.id}" points="${pts(x.points)}" fill="${c}${x.id === o.selectedId ? "33" : "14"}" stroke="${c}" stroke-width="${x.id === o.selectedId ? 2.5 : 1}" stroke-dasharray="${x.kind === "room" ? "none" : "6 4"}"/>`);44 if (o.showLabels !== false && x.points.length >= 3) {45 const c0 = centroid(x.points); const fs = Math.max(10, k * 0.24);46 const area = o.pxPerMeter ? fmtArea(polygonAreaPx(x.points) / (k * k), o.imperial) : "";47 parts.push(`<text x="${c0.x}" y="${c0.y - fs * 0.2}" font-size="${fs}" font-weight="600" fill="#16181b" text-anchor="middle" font-family="ui-sans-serif,system-ui" paint-order="stroke" stroke="#fff" stroke-width="3">${esc(x.name)}</text>`);48 if (area) parts.push(`<text x="${c0.x}" y="${c0.y + fs * 1.0}" font-size="${fs * 0.85}" fill="#4b5258" text-anchor="middle" font-family="ui-monospace,monospace" paint-order="stroke" stroke="#fff" stroke-width="3">${area}</text>`);49 }50 }51 // murs : remplissage plein (contour décalé) ; les ouvertures découpent le mur (masque blanc)52 for (const w of walls) {53 const t = (w.props.thickness ?? DEFAULT_WALL_THICKNESS_M) * k;54 const sel = w.id === o.selectedId;55 if (w.closed && w.points.length >= 3) {56 const r = closedWallRings(w.points, t);57 parts.push(`<path data-id="${w.id}" d="M ${pts(r.outer).replace(/ /g, " L ")} Z M ${pts(r.inner).replace(/ /g, " L ")} Z" fill="${sel ? "#006085" : "#1f2326"}" fill-rule="evenodd" stroke="${sel ? "#2bccae" : "#000"}" stroke-width="${sel ? 2 : 0.8}"/>`);58 } else {59 const outline = wallOutline(w.points, t, false);60 parts.push(`<polygon data-id="${w.id}" points="${pts(outline)}" fill="${sel ? "#006085" : "#1f2326"}" stroke="${sel ? "#2bccae" : "#000"}" stroke-width="${sel ? 2 : 0.8}" stroke-linejoin="miter"/>`);61 }62 if (o.showDims !== false && o.pxPerMeter) {63 // cote de chaque segment, à l'extérieur du mur (côté gauche)64 const segs = w.closed ? [...w.points.map((p, i) => [p, w.points[(i + 1) % w.points.length]] as [Point, Point])] : w.points.slice(1).map((p, i) => [w.points[i], p] as [Point, Point]);65 for (const [a, b] of segs) {66 const L = Math.hypot(b.x - a.x, b.y - a.y); if (L < k * 0.3) continue;67 const g = dimensionGeometry(a, b, -(t / 2 + k * 0.45));68 parts.push(dimSvg(g, fmtLength(L / k, o.imperial), Math.max(9, k * 0.18), "#4b5258"));69 }70 }71 }72 // ouvertures : découpe blanche + symbole73 for (const x of vis.filter((v) => v.kind === "opening")) {74 const wall = x.props.deductFrom ? byId.get(x.props.deductFrom) ?? null : null;75 const pl = placeOpening(x, wall ?? null, k);76 const deg = (pl.angle * 180) / Math.PI;77 const sel = x.id === o.selectedId;78 const g = `<g data-id="${x.id}" transform="translate(${pl.center.x.toFixed(2)} ${pl.center.y.toFixed(2)}) rotate(${deg.toFixed(2)})">`;79 if (pl.kind === "window") { const wp = windowPath(pl); parts.push(`${g}<path d="${wp.cut}" fill="#fff" stroke="none"/><path d="${wp.glass}" stroke="${sel ? "#2bccae" : "#1f2326"}" stroke-width="${sel ? 2 : 1}" fill="none"/><path d="${wp.cut}" fill="none" stroke="#1f2326" stroke-width="0.8"/></g>`); }80 else if (pl.kind === "door") { const dp = doorPath(pl); parts.push(`${g}<path d="${dp.cut}" fill="#fff" stroke="none"/><path d="${dp.leaf}" stroke="${sel ? "#2bccae" : "#1f2326"}" stroke-width="${sel ? 2.5 : 1.6}"/><path d="${dp.arc}" stroke="${sel ? "#2bccae" : "#1f2326"}" stroke-width="0.8" fill="none" stroke-dasharray="3 2"/></g>`); }81 else { const dp = doorPath(pl); parts.push(`${g}<path d="${dp.cut}" fill="#fff" stroke="${sel ? "#2bccae" : "#1f2326"}" stroke-width="1" stroke-dasharray="4 3"/></g>`); }82 if (!pl.valid) parts.push(`<circle cx="${pl.center.x}" cy="${pl.center.y}" r="${Math.max(5, k * 0.12)}" fill="none" stroke="#b3423a" stroke-width="1.5" stroke-dasharray="3 2"/>`);83 }84 // escaliers (rectangle + marches + flèche), colonnes, lignes85 for (const x of vis.filter((v) => v.kind === "stairs" && v.points.length >= 3)) {86 const c = centroid(x.points); parts.push(`<polygon data-id="${x.id}" points="${pts(x.points)}" fill="#fff" stroke="#333" stroke-width="1.2"/>`);87 const [a, b, cpt] = x.points; const n = 12;88 for (let i = 1; i < n; i++) { const t = i / n; parts.push(`<line x1="${a.x + (b.x - a.x) * t}" y1="${a.y + (b.y - a.y) * t}" x2="${a.x + (b.x - a.x) * t + (cpt.x - b.x)}" y2="${a.y + (b.y - a.y) * t + (cpt.y - b.y)}" stroke="#555" stroke-width="0.8"/>`); }89 parts.push(`<text x="${c.x}" y="${c.y}" font-size="${Math.max(9, k * 0.16)}" text-anchor="middle" fill="#333" font-family="ui-sans-serif">${esc(x.name)}</text>`);90 }91 for (const x of vis.filter((v) => v.kind === "column")) { const p = x.points[0]; const s = (x.props.thickness ?? 0.3) * k; parts.push(`<rect data-id="${x.id}" x="${p.x - s / 2}" y="${p.y - s / 2}" width="${s}" height="${s}" fill="#111" stroke="${x.id === o.selectedId ? "#2bccae" : "#000"}"/>`); }92 for (const x of vis.filter((v) => v.kind === "line")) parts.push(`<polyline data-id="${x.id}" points="${pts(x.points)}" fill="none" stroke="${x.id === o.selectedId ? "#2bccae" : COLORS.line}" stroke-width="${x.id === o.selectedId ? 3 : 1.5}" stroke-linecap="round"/>`);93 // cotations manuelles94 if (o.showDims !== false) for (const x of vis.filter((v) => v.kind === "dimension" && v.points.length >= 2)) {95 const g = dimensionGeometry(x.points[0], x.points[1], x.props.offset ?? -k * 0.5);96 parts.push(`<g data-id="${x.id}">${dimSvg(g, o.pxPerMeter ? fmtLength(g.lengthPx / k, o.imperial) : `${Math.round(g.lengthPx)} px`, Math.max(10, k * 0.2), x.id === o.selectedId ? "#2bccae" : "#006085")}</g>`);97 }98 // textes99 for (const x of vis.filter((v) => v.kind === "text" || v.kind === "annotation")) { const p = x.points[0]; if (!p) continue; parts.push(`<text data-id="${x.id}" x="${p.x}" y="${p.y}" font-size="${x.props.fontSize ?? Math.max(11, k * 0.2)}" fill="${x.id === o.selectedId ? "#2bccae" : "#16181b"}" font-family="ui-sans-serif,system-ui" paint-order="stroke" stroke="#fff" stroke-width="3">${esc(x.props.text ?? x.name)}</text>`); }100 return parts.join("");101}102103function dimSvg(g: ReturnType<typeof dimensionGeometry>, label: string, fs: number, color: string): string {104 const tick = (p: Point) => `<line x1="${p.x - 3}" y1="${p.y + 3}" x2="${p.x + 3}" y2="${p.y - 3}" stroke="${color}" stroke-width="1" transform="rotate(${g.angleDeg} ${p.x} ${p.y})"/>`;105 return `<line x1="${g.ext1.a.x}" y1="${g.ext1.a.y}" x2="${g.ext1.b.x}" y2="${g.ext1.b.y}" stroke="${color}" stroke-width="0.6"/><line x1="${g.ext2.a.x}" y1="${g.ext2.a.y}" x2="${g.ext2.b.x}" y2="${g.ext2.b.y}" stroke="${color}" stroke-width="0.6"/><line x1="${g.line.a.x}" y1="${g.line.a.y}" x2="${g.line.b.x}" y2="${g.line.b.y}" stroke="${color}" stroke-width="0.8"/>${tick(g.line.a)}${tick(g.line.b)}<text x="${g.textPos.x}" y="${g.textPos.y}" font-size="${fs}" fill="${color}" text-anchor="middle" font-family="ui-monospace,monospace" transform="rotate(${g.angleDeg} ${g.textPos.x} ${g.textPos.y})" paint-order="stroke" stroke="#fff" stroke-width="3">${esc(label)}</text>`;106}107108/** Document SVG complet (export) : fond, plan, cartouche, flèche du nord, échelle graphique. */109export function renderPlanSvg(objects: PlanObject[], o: RenderOptions): string {110 const k = o.pxPerMeter ?? 100;111 const bh = o.title ? 110 : 0;112 const W = o.widthPx, H = o.heightPx + bh;113 const bg = o.background ? `<image href="${esc(o.background)}" width="${o.widthPx}" height="${o.heightPx}"/>` : "";114 let title = "";115 if (o.title) {116 const y = o.heightPx;117 const barM = Math.max(1, Math.pow(10, Math.floor(Math.log10(o.widthPx / k / 4))));118 const bar = barM * k;119 title = `<g transform="translate(0 ${y})"><rect width="${W}" height="${bh}" fill="#fff" stroke="#16181b" stroke-width="2"/>120 <rect x="0" y="0" width="${W}" height="8" fill="#16181b"/><rect x="0" y="8" width="${W}" height="3" fill="#2bccae"/>121 <text x="16" y="42" font-size="18" font-weight="700" font-family="ui-sans-serif,system-ui" fill="#16181b">${esc(o.title.project)}</text>122 <text x="16" y="64" font-size="12" font-family="ui-monospace,monospace" fill="#4b5258">${esc(o.title.level)} · ${esc(o.title.scale)} · ${esc(o.title.date)}</text>123 <text x="16" y="84" font-size="11" font-family="ui-monospace,monospace" fill="#8b9297">UQO ÉVAL — MÉTRÉ ESTIMATIF · ${esc(o.title.author)} · PLAN PÉDAGOGIQUE, NON SCELLÉ</text>124 <g transform="translate(${W - 220} 30)"><line x1="0" y1="20" x2="${bar}" y2="20" stroke="#16181b" stroke-width="3"/><line x1="0" y1="12" x2="0" y2="28" stroke="#16181b" stroke-width="2"/><line x1="${bar}" y1="12" x2="${bar}" y2="28" stroke="#16181b" stroke-width="2"/><text x="${bar / 2}" y="44" font-size="11" text-anchor="middle" font-family="ui-monospace,monospace">${barM} m</text></g>125 <g transform="translate(${W - 50} 50)"><polygon points="0,-26 8,8 0,2 -8,8" fill="#16181b"/><text x="0" y="28" font-size="12" text-anchor="middle" font-weight="700" font-family="ui-sans-serif">N</text></g></g>`;126 }127 return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}"><rect width="${W}" height="${H}" fill="#ffffff"/>${bg}${renderPlanInner(objects, { ...o, selectedId: null })}${title}</svg>`;128}129130/** Longueur totale des murs (px) — utilitaire d'affichage. */131export const wallsLengthPx = (objects: PlanObject[]) => objects.filter((o) => o.kind === "wall").reduce((s, w) => s + polylineLengthPx(w.points, w.closed), 0);132