diff --git a/src/plan/PlanView.tsx b/src/plan/PlanView.tsx index ec434dd..54c5316 100644 --- a/src/plan/PlanView.tsx +++ b/src/plan/PlanView.tsx @@ -14,15 +14,35 @@ import { useState, } from "react"; import type { HatchRender, Plan, Primitive } from "./generatePlan"; -import { docToLines, wrapDocToLines, type SvgLine } from "../text/renderHtml"; import type { EdgeGrip, Vec2 } from "../model/types"; -import type { SnapResult, ToolDraft, ToolHandlers, ToolId, ToolMods } from "../tools/types"; +import type { ToolHandlers, ToolId, ToolMods } from "../tools/types"; import { useGlPlanRenderer } from "./useGlPlanRenderer"; import { useWasmPlanRenderer } from "./useWasmPlanRenderer"; -import { quantizePen } from "../export/sceneToPrintSvg"; -import { buildRandomHatchRuns, zigzagPoints, motifPoints, DASH_MM_TO_M } from "./glPlan/glPlanHatch"; -import { dashHasDot } from "../ui/lineSegments"; import { DEFAULT_MARQUEE_COLOR, DEFAULT_SNAP_COLOR } from "../theme/accents"; +import { + type ViewBox, + PX_PER_M, + PAD, + toScreen, + clamp, + meetScale, + scaleFromView, + viewForScale, + fitBoxFor, + pointInPolygon, + pointSegDistPx, + marqueeHit, + marqueeHitDrawings, +} from "./planView/geometry"; +import { + polyUsesPattern, + HatchPattern, + buildDrawingRuns, + DrawingRunShape, + PrimitiveShape, +} from "./planView/primitives"; +import { DraftOverlay } from "./planView/overlays"; +import { PlanRulers } from "./planView/PlanRulers"; /** * Feld-Status der Befehlszeile fürs Cursor-HUD (Tab-Feld-Zyklus §2.7): Werte je @@ -34,66 +54,6 @@ export interface HudFieldsState { typed: string; } -const PX_PER_M = 90; // viewBox-Einheiten je Meter (Modell → SVG-Benutzerraum) -const PAD = 60; // Rand in viewBox-Einheiten - -/** - * Modell-Meter → SVG-Benutzerraum (viewBox-Einheiten) mit FIXEM Welt-Ursprung - * am Modell-Nullpunkt (0,0): `x·PX_PER_M`, Plan-Y zeigt nach oben → SVG-Y nach - * unten, daher `-y·PX_PER_M`. BEWUSST unabhängig von den Modell-Bounds — so - * verschiebt sich die Abbildung NICHT, wenn sich Elemente ändern (Verschieben/ - * Löschen). Der sichtbare Ausschnitt (viewBox) bleibt dann stehen; nur manuelles - * Pan/Zoom/Einpassen ändert ihn. (Inverse: {@link viewToModel} in der Komponente.) - */ -function toScreen(p: Vec2): Vec2 { - return { x: p.x * PX_PER_M, y: -p.y * PX_PER_M }; -} - -/** - * Zerlegt den Rand eines Polygons in zusammenhängende OFFENE Linienzüge, wobei die - * Kanten in `noStroke` ausgelassen werden (Kante `i` = pts[i]→pts[i+1]). Genutzt, - * um die inneren Gehrungs-Stirnkanten an einer Wandecke NICHT zu stricheln (keine - * 45°-Naht, keine über den Apex schießende Barbe), während die Füllung das volle - * Polygon bleibt. Jeder Lauf beginnt an einer sichtbaren Kante, deren Vorgänger - * ausgelassen ist. - */ -function visibleEdgeRuns(scr: Vec2[], noStroke: number[]): Vec2[][] { - const n = scr.length; - const skip = new Set(noStroke.map((i) => ((i % n) + n) % n)); - if (skip.size >= n || n < 2) return []; - const visible = (i: number) => !skip.has(((i % n) + n) % n); - const runs: Vec2[][] = []; - for (let s = 0; s < n; s++) { - if (!visible(s) || visible(s - 1 + n)) continue; // kein Lauf-Anfang - const run: Vec2[] = [scr[s]]; - let j = s; - while (visible(j) && j - s < n) { - run.push(scr[(j + 1) % n]); - j++; - } - runs.push(run); - } - return runs; -} - -/** - * Papier-Massstab (docs/design/plans-output.md §3): Der Plan IST Papier-Space. - * dpi = 96 (CSS definiert 1 CSS-px = 1/96 inch, UNABHÄNGIG von devicePixelRatio — - * das gilt genauso auf Retina/HiDPI-Displays und bei OS-Skalierung, da der - * Browser CSS-px genau dafür physisch konstant hält, egal wie viele Geräte-px - * dahinterstecken). Strichstärken sind in mm Papier definiert und werden über - * diese dpi in konstante Bildschirm-Pixel umgerechnet (zusammen mit - * vector-effect:non-scaling-stroke bleiben sie beim Zoomen papierkonstant). - * BUGFIX: früher fälschlich `96 · devicePixelRatio` — das verwechselte die - * Geräte-px-Dichte (für Tessellierung/Rasterung relevant, siehe - * `pxPerMeterForView` weiter unten) mit der physischen CSS-px-Grösse und - * verdoppelte/halbierte den gemessenen Massstab auf jedem Retina-Bildschirm - * (per Lineal am Bildschirm nachgemessen falsch). - */ -const dpi = (): number => 96; -/** mm Papier → Bildschirm-Pixel (für non-scaling-stroke / dasharray). */ -const mmToPx = (mm: number): number => (mm / 25.4) * dpi(); - // Zoom-Grenzen relativ zur eingepassten Default-Ansicht. Kleinere viewBox- // Breite = stärkerer Zoom. ZOOM_MAX → max. Hineinzoomen, ZOOM_MIN → Herauszoomen. // Grosszügig bemessen (CAD-Anspruch: vom Milimeter-Detail bis zum Siedlungs- @@ -112,14 +72,6 @@ const GRIP_HIT_PX = 9; // Toleranz (Bildschirm-Pixel) für die Linien-Auswahl von 2D-Zeichenelementen. const LINE_PICK_PX = 6; -/** Rechteckiger Ausschnitt in SVG-Benutzerkoordinaten (viewBox-Einheiten). */ -interface ViewBox { - x: number; - y: number; - w: number; - h: number; -} - /** Ein Polygon mit Schraffur + die ihm zugewiesene -ID. */ interface HatchedPoly { patternId: string; @@ -2510,1660 +2462,3 @@ export const PlanView = forwardRef( ); }); - -/** - * Zeichnet die Werkzeug-Vorschau: Vorschau-Formen (gestrichelte Akzentlinien / - * halbtransparente Flächen), gesetzte Stützpunkte (kleine Quadrate), den - * aktiven Snap-Marker, das Cursor-HUD (Länge/Winkel, VW-Stil) sowie — bei - * weichem Winkel-Snap (`snap.kind === "angle"`) — Winkel-Badge + verlängerte - * Führungslinie. `vbPerPx` rechnet eine gewünschte Bildschirm-Pixelgröße in - * viewBox-Einheiten um (bildschirmkonstante Marker/Schrift). Die Winkel-Info - * reitet auf `draft.snap` mit (refA/point/angleDeg) — kein eigenes Feld nötig, - * `computeSnap` liefert sie bereits vollständig. - */ -function DraftOverlay({ - draft, - toScreen, - vbPerPx, - snapColor, - hudFields = null, -}: { - draft: ToolDraft; - toScreen: (v: Vec2) => Vec2; - vbPerPx: number; - snapColor: string; - hudFields?: { - fields: { id: string; value: number | null; locked: boolean; active: boolean }[]; - typed: string; - } | null; -}) { - const vertSize = 4.5 * vbPerPx; // halbe Kantenlänge eines Stützpunkt-Quadrats (klein + einheitlich) - const markSize = 4.75 * vbPerPx; // halbe Kantenlänge des Snap-Markers (klein + fein) - return ( - - {/* Vorschau-Formen. */} - {draft.preview.map((s, i) => - s.kind === "line" ? ( - - ) : ( - `${p.x},${p.y}`).join(" ")} - vectorEffect="non-scaling-stroke" - /> - ), - )} - {/* Gesetzte Stützpunkte. */} - {draft.vertices.map((v, i) => { - const s = toScreen(v); - return ( - - ); - })} - {/* Weicher Winkel-Snap: verlängerte gestrichelte Führungslinie + Badge - UNTER dem Snap-Marker (der bleibt obenauf). */} - {draft.snap?.kind === "angle" && draft.snap.refA && draft.snap.angleDeg != null && ( - - )} - {/* Snap-Marker + optionale Ortho-Hilfslinie. */} - {draft.snap && ( - - )} - {/* Maß-/Winkel-HUD am Cursor (VW-Stil `L: … W: …`, oder einfacher Text). */} - {draft.hud && } - - ); -} - -/** - * Cursor-HUD im Vectorworks-Stil: weisser Kasten, blauer Rahmen, blauer - * Monospace-Text. Zeigt `L:`/`W:` (Länge 3 Nachkommastellen + „m", Winkel 3 - * Nachkommastellen + „°"), wenn beide vorhanden sind — sonst den freien - * `text` (Radius/Fläche/… bei Befehlen ohne Länge-Winkel-Paar). Leicht - * versetzt rechts-unten vom Cursorpunkt, bildschirmkonstante Größe. - */ -function DraftHud({ - hud, - toScreen, - vbPerPx, - hudFields = null, -}: { - hud: NonNullable; - toScreen: (v: Vec2) => Vec2; - vbPerPx: number; - hudFields?: { - fields: { id: string; value: number | null; locked: boolean; active: boolean }[]; - typed: string; - } | null; -}) { - // Segmente: mit Befehlszeilen-Feldern (Tab-Zyklus) spiegelt das HUD deren - // Zustand — getippter Text erscheint LIVE im aktiven Feld (VW-Verhalten), - // gelockte Werte fest, ungelockte folgen der Maus (draft.hud-Livewerte als - // Fallback je Feld-Id). Ohne Felder: bisherige L/W- bzw. Text-Anzeige. - interface Seg { - text: string; - active: boolean; - locked: boolean; - } - const liveFor = (id: string): number | null => - id === "length" - ? (hud.length ?? null) - : id === "angle" - ? (hud.angleDeg ?? null) - : id === "width" - ? (hud.dx ?? null) - : id === "height" - ? (hud.dy ?? null) - : null; - // Feld-Beschriftung: Länge/Winkel als L/W, Rechteck-Masse als Δx/Δy (VW). - const labelFor = (id: string): string => - id === "length" ? "L" : id === "angle" ? "W" : id === "width" ? "Δx" : id === "height" ? "Δy" : id; - const segs: Seg[] = []; - if (hudFields && hudFields.fields.length > 0) { - for (const f of hudFields.fields) { - const label = labelFor(f.id); - const unit = f.id === "angle" ? "°" : "m"; - const typedHere = f.active && hudFields.typed.trim() !== ""; - const value = typedHere - ? hudFields.typed - : f.value != null - ? f.value.toFixed(3) - : (liveFor(f.id)?.toFixed(3) ?? "—"); - segs.push({ text: `${label}: ${value}${unit}`, active: f.active, locked: f.locked }); - } - } else if (hud.dx != null && hud.dy != null) { - segs.push({ text: `Δx: ${hud.dx.toFixed(3)}m`, active: false, locked: false }); - segs.push({ text: `Δy: ${hud.dy.toFixed(3)}m`, active: false, locked: false }); - } else if (hud.length != null && hud.angleDeg != null) { - segs.push({ text: `L: ${hud.length.toFixed(3)}m`, active: false, locked: false }); - segs.push({ text: `W: ${hud.angleDeg.toFixed(3)}°`, active: false, locked: false }); - } else if (hud.text) { - segs.push({ text: hud.text, active: false, locked: false }); - } - if (segs.length === 0) return null; - - const p = toScreen(hud.at); - const fontSize = 12 * vbPerPx; - const padX = 6 * vbPerPx; - const padY = 4 * vbPerPx; - const gap = 10 * vbPerPx; - const charW = fontSize * 0.62; // Monospace-Schätzung für die Kastenbreite - const widths = segs.map((s) => s.text.length * charW); - const w = widths.reduce((a, b) => a + b, 0) + gap * (segs.length - 1) + padX * 2; - const h = fontSize + padY * 2; - const x = p.x + 14 * vbPerPx; - const y = p.y + 14 * vbPerPx; - let cx = x + padX; - return ( - - - {segs.map((s, i) => { - const sx = cx; - cx += widths[i] + gap; - return ( - - {s.active && ( - - )} - - {s.text} - - - ); - })} - - ); -} - -/** - * Weicher Winkel-Snap (§snapCommonAngle): gestrichelte Führungslinie vom - * letzten Punkt über den Cursor hinaus verlängert (dezent, wie in VW) + ein - * gelbliches Winkel-Badge nahe der Liniemitte. - */ -function AngleGuide({ - from, - point, - angleDeg, - toScreen, - vbPerPx, -}: { - from: Vec2; - point: Vec2; - angleDeg: number; - toScreen: (v: Vec2) => Vec2; - vbPerPx: number; -}) { - const a = toScreen(from); - const b = toScreen(point); - const dx = b.x - a.x; - const dy = b.y - a.y; - const len = Math.hypot(dx, dy); - const dir = len > 1e-6 ? { x: dx / len, y: dy / len } : { x: 1, y: 0 }; - // Führungslinie WEIT über den Cursor hinaus (VW-Verhalten: die Referenz läuft - // quer über den sichtbaren Ausschnitt; der SVG-Clip schneidet den Rest ab). - const extend = 4000 * vbPerPx; - const end = { x: b.x + dir.x * extend, y: b.y + dir.y * extend }; - // Winkelbogen (VW): gestrichelter Bogen von der HORIZONTALEN Referenz (0°) zur - // Strahlrichtung, Radius knapp innerhalb des Cursors. Screen-y läuft nach - // unten → der Screen-Winkel ist der negierte Modellwinkel; für den Bogenpfad - // rechnen wir direkt mit Screen-Vektoren. - const screenAng = Math.atan2(dy, dx); // (−π, π], y-down - const r = Math.min(len * 0.85, 160 * vbPerPx); - const arcStart = { x: a.x + r, y: a.y }; - const arcEnd = { x: a.x + r * Math.cos(screenAng), y: a.y + r * Math.sin(screenAng) }; - const sweep = screenAng > 0 ? 1 : 0; // y-down: positiver Screen-Winkel = im Uhrzeigersinn - const arcPath = `M ${arcStart.x} ${arcStart.y} A ${r} ${r} 0 0 ${sweep} ${arcEnd.x} ${arcEnd.y}`; - // Horizontale Referenz-Strichellinie (0°-Basis des Bogens), etwas über den - // Bogen hinaus. - const refEnd = { x: a.x + r + 60 * vbPerPx, y: a.y }; - // Badge aussen am Bogen bei halbem Winkel (VW-Position). - const halfAng = screenAng / 2; - const badgeR = r + 22 * vbPerPx; - const badgeAt = { x: a.x + badgeR * Math.cos(halfAng), y: a.y + badgeR * Math.sin(halfAng) }; - const fontSize = 11 * vbPerPx; - const padX = 5 * vbPerPx; - const padY = 3 * vbPerPx; - const text = `${angleDeg.toFixed(3)}°`; - const w = text.length * fontSize * 0.62 + padX * 2; - const h = fontSize + padY * 2; - return ( - - - - {len > 24 * vbPerPx && Math.abs(screenAng) > 0.02 && ( - - )} - - - {text} - - - ); -} - -/** Marker-Glyph je Snap-Art + (bei ortho) eine gestrichelte Hilfslinie. */ -function SnapMarker({ - snap, - toScreen, - size, - color, -}: { - snap: SnapResult; - toScreen: (v: Vec2) => Vec2; - size: number; - /** Farbe des Snap-Glyphs (einstellbar, Default = Akzent „Sora"). */ - color: string; -}) { - const s = toScreen(snap.point); - const r = size; - // Inline `style` überschreibt gezielt die CSS-Klassenfarbe (eine am Element - // selbst deklarierte Klassenregel gewinnt sonst gegen eine geerbte Farbe von - // einer umschließenden Gruppe — daher pro Glyph statt einmal an der Gruppe). - const glyphStyle = { stroke: color }; - const helperStyle = { stroke: color }; - const fillStyle = { stroke: color, fill: color }; - return ( - - {(snap.kind === "ortho" || snap.kind === "extension") && snap.refA && ( - - )} - {snap.kind === "grid" && ( - - )} - {snap.kind === "midpoint" && ( - - )} - {snap.kind === "intersection" && ( - <> - - - - )} - {(snap.kind === "center" || snap.kind === "quadrant") && ( - - )} - {snap.kind === "onEdge" && ( - - )} - {(snap.kind === "endpoint" || snap.kind === "ortho" || snap.kind === "extension") && ( - - )} - - ); -} - -/** Wert auf [lo, hi] begrenzen. */ -function clamp(value: number, lo: number, hi: number): number { - return Math.min(hi, Math.max(lo, value)); -} - -/** - * Effektive meet-Skala (Bildschirm-px je viewBox-Einheit) für einen Ausschnitt. - * preserveAspectRatio="xMidYMid meet" nimmt die kleinere der beiden Achsen. - * Liefert null, solange das SVG noch nicht gemessen werden kann. - */ -function meetScale(vb: ViewBox, el: SVGSVGElement | null): number | null { - const rect = el?.getBoundingClientRect(); - if (!rect || rect.width <= 0 || rect.height <= 0) return null; - return Math.min(rect.width / vb.w, rect.height / vb.h); -} - -/** - * LIVE Papier-Massstab 1:N aus dem Ausschnitt (docs/design/plans-output.md §3): - * 1 m belegt `scale·PX_PER_M` CSS-px; in mm = ·25.4/dpi; auf Papier 1:N sind - * das 1000/N mm ⇒ N = 1000·dpi / (scale·PX_PER_M·25.4). Letterbox-korrekt, da - * `scale` die tatsächliche meet-Skala ist. - */ -function scaleFromView(vb: ViewBox, el: SVGSVGElement | null): number | null { - const scale = meetScale(vb, el); - if (scale == null) return null; - return (1000 * dpi()) / (scale * PX_PER_M * 25.4); -} - -/** - * Ziel-Ausschnitt, der das Modell exakt im Papier-Massstab 1:N abbildet (Inverse - * von {@link scaleFromView}). Setzt die viewBox auf das Canvas-Seitenverhältnis - * (kein Letterbox → horizontale wie vertikale Skala = Zielskala) und hält die - * Bildmitte des bisherigen Ausschnitts. Ohne Messung null (kein Sprung blind). - */ -function viewForScale( - n: number, - prev: ViewBox, - el: SVGSVGElement | null, -): ViewBox | null { - const rect = el?.getBoundingClientRect(); - if (!rect || rect.width <= 0 || rect.height <= 0 || !(n > 0)) return null; - // Ziel-meet-Skala aus N, dann viewBox = Canvas-px / Skala (beide Achsen). - const targetScale = (1000 * dpi()) / (n * PX_PER_M * 25.4); - const w = rect.width / targetScale; - const h = rect.height / targetScale; - const cx = prev.x + prev.w / 2; - const cy = prev.y + prev.h / 2; - return { x: cx - w / 2, y: cy - h / 2, w, h }; -} - -/** - * Einpass-Ausschnitt für ein Polygon (in Modell-Metern): dessen Bounding-Box - * → viewBox-Einheiten (über toScreen) mit etwas Rand. Für „auf Auswahl einpassen". - */ -function fitBoxFor(pts: Vec2[], toScreen: (v: Vec2) => Vec2): ViewBox { - let minX = Infinity, - minY = Infinity, - maxX = -Infinity, - maxY = -Infinity; - for (const p of pts) { - const s = toScreen(p); - minX = Math.min(minX, s.x); - minY = Math.min(minY, s.y); - maxX = Math.max(maxX, s.x); - maxY = Math.max(maxY, s.y); - } - const w = maxX - minX; - const h = maxY - minY; - const margin = Math.max(w, h) * 0.25 + PAD; // etwas Luft um die Auswahl - return { - x: minX - margin, - y: minY - margin, - w: w + margin * 2, - h: h + margin * 2, - }; -} - -/** - * Punkt-in-Polygon (Ray-Casting, gerade Kreuzungszahl). Arbeitet in beliebigen - * 2D-Koordinaten — hier in Modell-Metern, damit die Trefferprüfung unabhängig - * vom Zoom ist. Rand zählt nicht garantiert; für die Wand-Auswahl ausreichend. - */ -function pointInPolygon(pt: Vec2, poly: Vec2[]): boolean { - let inside = false; - for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { - const xi = poly[i].x; - const yi = poly[i].y; - const xj = poly[j].x; - const yj = poly[j].y; - const intersect = - yi > pt.y !== yj > pt.y && - pt.x < ((xj - xi) * (pt.y - yi)) / (yj - yi) + xi; - if (intersect) inside = !inside; - } - return inside; -} - -/** Abstand Punkt→Strecke (alle in denselben Koordinaten, hier Client-Pixel). */ -function pointSegDistPx( - p: { x: number; y: number }, - a: { x: number; y: number }, - b: { x: number; y: number }, -): number { - const dx = b.x - a.x; - const dy = b.y - a.y; - const len2 = dx * dx + dy * dy; - if (len2 < 1e-9) return Math.hypot(p.x - a.x, p.y - a.y); - let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2; - t = Math.max(0, Math.min(1, t)); - return Math.hypot(p.x - (a.x + dx * t), p.y - (a.y + dy * t)); -} - -/** Achsenparalleles Rechteck in Modell-Metern (Auswahl-Marquee). */ -interface ModelRect { - minX: number; - minY: number; - maxX: number; - maxY: number; -} - -/** - * Bestimmt die vom Auswahl-Rechteck getroffenen Wand-IDs nach CAD-Konvention: - * • crossing = false (links→rechts): nur Wände, deren Polygone VOLLSTÄNDIG im - * Rechteck liegen (jeder Eckpunkt enthalten). - * • crossing = true (rechts→links): zusätzlich Wände, die das Rechteck nur - * BERÜHREN/kreuzen (Überlappung der Polygone mit dem Rechteck). - * Getestet wird je Wand über ALLE ihre Polygon-Bänder; ein Treffer eines Bandes - * genügt. Liefert eindeutige IDs in Auftreten-Reihenfolge. - */ -function marqueeHit( - prims: Primitive[], - rect: ModelRect, - crossing: boolean, -): string[] { - // Polygone je Wand sammeln (nur Primitive mit Wandbezug zählen für die Auswahl). - const byWall = new Map(); - for (const p of prims) { - if (p.kind !== "polygon" || p.wallId == null) continue; - const list = byWall.get(p.wallId); - if (list) list.push(p.pts); - else byWall.set(p.wallId, [p.pts]); - } - const out: string[] = []; - for (const [wallId, polys] of byWall) { - const hit = crossing - ? polys.some((poly) => polyTouchesRect(poly, rect)) - : polys.every((poly) => polyEnclosedByRect(poly, rect)); - if (hit) out.push(wallId); - } - return out; -} - -/** - * Vom Auswahl-Rechteck getroffene 2D-Zeichenelement-IDs (gleiche window/crossing- - * Konvention wie {@link marqueeHit}). Sammelt je Drawing2D ALLE seine Punkte aus - * den Linien-/Polygon-Primitiven; crossing = irgendein Punkt/Kante im Rechteck, - * window = alle Punkte vollständig eingeschlossen. - */ -function marqueeHitDrawings( - prims: Primitive[], - rect: ModelRect, - crossing: boolean, -): string[] { - const byDrawing = new Map(); - const collect = (id: string, pts: Vec2[]) => { - const list = byDrawing.get(id); - if (list) list.push(...pts); - else byDrawing.set(id, [...pts]); - }; - for (const p of prims) { - if (p.kind === "line" && p.drawingId) collect(p.drawingId, [p.a, p.b]); - else if (p.kind === "polygon" && p.drawingId) collect(p.drawingId, p.pts); - } - const out: string[] = []; - for (const [id, pts] of byDrawing) { - const hit = crossing - ? polyTouchesRect(pts, rect) - : polyEnclosedByRect(pts, rect); - if (hit) out.push(id); - } - return out; -} - -/** Alle Eckpunkte des Polygons liegen im Rechteck (vollständig eingeschlossen). */ -function polyEnclosedByRect(poly: Vec2[], r: ModelRect): boolean { - if (poly.length === 0) return false; - return poly.every( - (pt) => - pt.x >= r.minX && pt.x <= r.maxX && pt.y >= r.minY && pt.y <= r.maxY, - ); -} - -/** - * Polygon und Rechteck überlappen/berühren sich (crossing-Test). Wahr, wenn: - * • ein Polygon-Eckpunkt im Rechteck liegt, ODER - * • eine Rechteck-Ecke im Polygon liegt, ODER - * • eine Polygon-Kante eine Rechteck-Kante schneidet. - * Deckt damit auch den Fall ab, dass das Rechteck ganz innerhalb der Wand liegt. - */ -function polyTouchesRect(poly: Vec2[], r: ModelRect): boolean { - // 1) Polygon-Punkt im Rechteck? - for (const pt of poly) { - if (pt.x >= r.minX && pt.x <= r.maxX && pt.y >= r.minY && pt.y <= r.maxY) { - return true; - } - } - // 2) Rechteck-Ecke im Polygon? (Rechteck ganz in der Wand.) - const corners: Vec2[] = [ - { x: r.minX, y: r.minY }, - { x: r.maxX, y: r.minY }, - { x: r.maxX, y: r.maxY }, - { x: r.minX, y: r.maxY }, - ]; - for (const c of corners) { - if (pointInPolygon(c, poly)) return true; - } - // 3) Kanten-Schnitt Polygon × Rechteck. - const rectEdges: [Vec2, Vec2][] = [ - [corners[0], corners[1]], - [corners[1], corners[2]], - [corners[2], corners[3]], - [corners[3], corners[0]], - ]; - for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { - const a = poly[j]; - const b = poly[i]; - for (const [c, d] of rectEdges) { - if (segmentsIntersect(a, b, c, d)) return true; - } - } - return false; -} - -/** Orientierung des Tripels (a,b,c): >0 CCW, <0 CW, 0 kollinear. */ -function cross3(a: Vec2, b: Vec2, c: Vec2): number { - return (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x); -} - -/** Schneiden sich die Strecken a-b und c-d (inkl. Berührung)? */ -function segmentsIntersect(a: Vec2, b: Vec2, c: Vec2, d: Vec2): boolean { - const d1 = cross3(c, d, a); - const d2 = cross3(c, d, b); - const d3 = cross3(a, b, c); - const d4 = cross3(a, b, d); - if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && - ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))) { - return true; - } - // Kollineare Berührung. - if (d1 === 0 && onSegment(c, d, a)) return true; - if (d2 === 0 && onSegment(c, d, b)) return true; - if (d3 === 0 && onSegment(a, b, c)) return true; - if (d4 === 0 && onSegment(a, b, d)) return true; - return false; -} - -/** Liegt p auf der Strecke a-b (bei bereits kollinearen Punkten)? */ -function onSegment(a: Vec2, b: Vec2, p: Vec2): boolean { - return ( - Math.min(a.x, b.x) <= p.x && - p.x <= Math.max(a.x, b.x) && - Math.min(a.y, b.y) <= p.y && - p.y <= Math.max(a.y, b.y) - ); -} - -/** Basis-Kachelmaß (viewBox-Einheiten) einer Bild-Schraffur bei scaleX/scaleY = 1. */ -const IMG_TILE_VB = 40; - -/** Lineare/Kreuz-Muster brauchen ein ; solid/none nicht. */ -function needsPattern(pattern: HatchRender["pattern"]): boolean { - return ( - pattern === "insulation" || - pattern === "diagonal" || - pattern === "crosshatch" - ); -} - -/** - * Ob ein Polygon ein SVG-`` (in ``) braucht: - * • Bild-Schraffur (`kind==="image"`) → ja (gekacheltes Bild-Muster). - * • Random-Vektor (`lines==="random"`) → NEIN: die Streu-Striche werden als - * geclippte Polylinien direkt gezeichnet (Determinismus, Parität zu GL/PDF). - * • sonst die klassischen Parallel-/Kreuz-/Dämmungs-Muster. - */ -function polyUsesPattern(h: HatchRender): boolean { - if (h.kind === "image") return !!h.image; - if (h.lines === "random") return false; - return needsPattern(h.pattern); -} - -/** - * Ein parametrisiertes Schraffur-. Maßstab skaliert die Kachelgröße, - * Winkel dreht das Muster (patternTransform), Farbe/Linienstärke kommen aus dem - * aufgelösten Hatch/LineStyle. Die Musterlinie ist ein GEWÖHNLICHER Papier-mm- - * Stift (wie jede andere Linie) — dieselbe Display-/Print-Logik wie - * `renderPrimitive`: Display = konstante Haarlinie (Bildschirm-px, via - * non-scaling-stroke), Print = echte, PEN_STEPS-gequantelte Papier-mm. - */ -function HatchPattern({ - id, - hatch, - hairline, - paperScale, -}: { - id: string; - hatch: HatchRender; - hairline: boolean; - paperScale: number | null; -}) { - const print = !hairline && paperScale != null && paperScale > 0; - const sw = hairline - ? HAIRLINE_PX - : print - ? printStrokeVb(quantizePen(hatch.lineWeight), paperScale!) - : mmToPx(hatch.lineWeight); - const vfx = print ? undefined : ("non-scaling-stroke" as const); - const dashes = hatch.dash?.length - ? hatch.dash - .map((mm) => (print ? printStrokeVb(mm, paperScale!) : mmToPx(mm))) - .join(" ") - : undefined; - // Punktmuster in den Musterlinien: runde Kappe → Dots (Punkt/Strich-Punkt). - const dashCap = dashHasDot(hatch.dash) ? ("round" as const) : undefined; - - if (hatch.kind === "image" && hatch.image) { - // Bild-Schraffur: gekacheltes -Muster. Kachel-Basismaß in viewBox- - // Einheiten, unabhängig in L×B verzerrt (scaleX/scaleY), um `rotation` gedreht. - // preserveAspectRatio="none" erlaubt die Verzerrung. - const img = hatch.image; - const w = Math.max(1, IMG_TILE_VB * (img.scaleX > 0 ? img.scaleX : 1)); - const h = Math.max(1, IMG_TILE_VB * (img.scaleY > 0 ? img.scaleY : 1)); - return ( - - - - ); - } - - if (hatch.pattern === "insulation") { - // Weiche Zickzack-/Wellenlinie (SIA-nah). Kachel 14×10 × Maßstab. - const w = 14 * hatch.scale; - const h = 10 * hatch.scale; - const my = 5 * hatch.scale; - const qy1 = -1 * hatch.scale; - const qy2 = 11 * hatch.scale; - const qx1 = 3.5 * hatch.scale; - const qx2 = 7 * hatch.scale; - const qx3 = 10.5 * hatch.scale; - return ( - - - - ); - } - - // Diagonal + Crosshatch: dünne Linien, Winkel via patternTransform. - // Kachel 8×8 × Maßstab; crosshatch fügt eine zweite, senkrechte Schar hinzu. - const tile = 8 * hatch.scale; - return ( - - - {hatch.pattern === "crosshatch" && ( - - )} - - ); -} - -/** - * Ein zusammenhängender 2D-Zeichen-Linienzug (aus gemergten line-Primitiven): - * verkettete Punkte + gemeinsamer Stil. `closed` ⇒ als Ring (polygon) zeichnen, - * damit auch die Schluss-Ecke gehrt. Reine Darstellung. - */ -interface DrawingRun { - pts: Vec2[]; - closed: boolean; - cls: string; - weightMm: number; - dash: number[] | null | undefined; - color?: string; - greyed?: boolean; - /** Zickzack-Parameter (Papier-mm); gesetzt ⇒ Lauf als Zickzack-Pfad zeichnen. */ - zigzag?: { amplitude: number; wavelength: number }; - /** Custom-Motiv (Papier-mm); gesetzt ⇒ Lauf als gekacheltes Motiv zeichnen. */ - motif?: { points: Vec2[]; length: number }; -} - -/** Kleiner Toleranz-Test auf Punktgleichheit (Modell-Meter). */ -const samePt = (a: Vec2, b: Vec2): boolean => - Math.abs(a.x - b.x) < 1e-9 && Math.abs(a.y - b.y) < 1e-9; - -/** Ob zwei line-Primitive denselben Stil (für einen gemeinsamen Zug) tragen. */ -function sameLineStyle( - a: Extract, - b: Extract, -): boolean { - return ( - a.drawingId === b.drawingId && - a.cls === b.cls && - a.color === b.color && - a.weightMm === b.weightMm && - !!a.greyed === !!b.greyed && - JSON.stringify(a.dash ?? null) === JSON.stringify(b.dash ?? null) - ); -} - -/** - * Bündelt aufeinanderfolgende 2D-Zeichen-Linien (mit drawingId) zu verketteten - * Zügen (End-an-Start). Erkennt Ringschluss (letzter == erster Punkt) → `closed`. - * Linien ohne drawingId (Wand-/Tür-/Kontext-Striche) bleiben unberührt (nicht - * enthalten). Reihenfolge-stabil in Auftreten der Primitive. - */ -function buildDrawingRuns(prims: Primitive[]): DrawingRun[] { - const runs: DrawingRun[] = []; - let curPts: Vec2[] | null = null; - let curSrc: Extract | null = null; - - const flush = () => { - if (curPts && curSrc && curPts.length >= 2) { - const closed = curPts.length > 2 && samePt(curPts[0], curPts[curPts.length - 1]); - runs.push({ - pts: closed ? curPts.slice(0, -1) : curPts, - closed, - cls: curSrc.cls, - weightMm: curSrc.weightMm, - dash: curSrc.dash, - color: curSrc.color, - greyed: curSrc.greyed, - }); - } - curPts = null; - curSrc = null; - }; - - for (const p of prims) { - if (p.kind !== "line" || !p.drawingId) { - flush(); - continue; - } - // Zickzack-Linien werden NICHT verkettet (jede als eigener Zickzack-Lauf) — - // sonst würde die Tessellierung über eine Gehrung hinweg zerreißen. - if (p.zigzag) { - flush(); - runs.push({ - pts: [p.a, p.b], - closed: false, - cls: p.cls, - weightMm: p.weightMm, - dash: p.dash, - color: p.color, - greyed: p.greyed, - zigzag: p.zigzag, - }); - continue; - } - // Custom-Motiv-Linien ebenfalls einzeln (analog Zickzack). - if (p.motif) { - flush(); - runs.push({ - pts: [p.a, p.b], - closed: false, - cls: p.cls, - weightMm: p.weightMm, - dash: p.dash, - color: p.color, - greyed: p.greyed, - motif: p.motif, - }); - continue; - } - if (curPts && curSrc && sameLineStyle(curSrc, p) && samePt(curPts[curPts.length - 1], p.a)) { - // An den laufenden Zug anhängen. - curPts.push(p.b); - } else { - // Neuen Zug beginnen. - flush(); - curPts = [p.a, p.b]; - curSrc = p; - } - } - flush(); - return runs; -} - -/** - * Fester Grauton gedimmter Primitive im Darstellungsmodus „andere grau" — - * ECHTE Umfärbung (nicht nur Deckkraft), da dünne Haarlinien auf dem hellen - * Zeichenblatt (--sheet: #f0f0f0) bei reiner Transparenz kaum erkennbar - * gedimmt wirken. Gleicher Ton in allen drei Render-Pfaden (SVG/WebGL/nativ, - * siehe glPlanCompile.ts `GREY_RGB` und toRenderScene.ts `GREY_RGB`). - */ -const GREYED_COLOR = "#8c8c8c"; - -/** Ersetzt Fill-/Stroke-/Farbfelder (inkl. Schraffur) eines Primitivs durch den festen Grauton. */ -function greyColor(c: string | undefined): string | undefined { - return !c || c === "none" || c === "transparent" ? c : GREYED_COLOR; -} - -/** Flache Kopie eines Primitivs mit allen Farbfeldern auf `GREYED_COLOR` umgefärbt. */ -function greyedCopy(p: T): T { - const q: any = { ...p }; - if ("fill" in q) q.fill = greyColor(q.fill); - if ("stroke" in q) q.stroke = greyColor(q.stroke); - if ("color" in q) q.color = greyColor(q.color); - if ("hatch" in q && q.hatch) q.hatch = { ...q.hatch, color: greyColor(q.hatch.color) }; - return q; -} - -/** Zeichnet einen gebündelten 2D-Zeichen-Zug als EIN Element (gehrte Ecken). */ -function DrawingRunShape({ - run, - toScreen, - hairline, - paperScale, -}: { - run: DrawingRun; - toScreen: (v: Vec2) => Vec2; - hairline: boolean; - paperScale: number | null; -}) { - const print = !hairline && paperScale != null && paperScale > 0; - const sw = hairline - ? HAIRLINE_PX - : print - ? printStrokeVb(quantizePen(run.weightMm), paperScale!) - : mmToPx(run.weightMm); - const vfx = print ? undefined : ("non-scaling-stroke" as const); - const dashStr = - run.dash && run.dash.length - ? run.dash - .map((mm) => (print ? printStrokeVb(mm, paperScale!) : mmToPx(mm))) - .join(" ") - : undefined; - // Zickzack-/Custom-Motiv-Lauf: im MODELL-Raum tessellieren (Papier-mm → - // Modell-Meter via DASH_MM_TO_M), dann toScreen. Sonst die Roh-Stützpunkte. - const modelPts = run.zigzag - ? zigzagPoints( - run.pts[0], - run.pts[run.pts.length - 1], - run.zigzag.amplitude * DASH_MM_TO_M, - run.zigzag.wavelength * DASH_MM_TO_M, - ) - : run.motif - ? motifPoints(run.pts[0], run.pts[run.pts.length - 1], run.motif, DASH_MM_TO_M) - : run.pts; - const pts = modelPts.map(toScreen).map((s) => `${s.x},${s.y}`).join(" "); - // Punktmuster (dash enthält eine 0): runde Kappe, damit die 0-Längen-Segmente - // als Dots erscheinen (Punktlinie / Strich-Punkt). - const cap = dashHasDot(run.dash) ? ("round" as const) : undefined; - const stroke = run.greyed ? greyColor(run.color) : run.color; - const shape = run.closed ? ( - - ) : ( - - ); - return shape; -} - -function PrimitiveShape({ - p, - index, - toScreen, - hairline, - paperScale, -}: { - p: Primitive; - index: number; - toScreen: (v: Vec2) => Vec2; - hairline: boolean; - paperScale: number | null; -}) { - // Gedimmte Elemente werden auf den festen Grauton umgefärbt (Strich, Füllung - // UND Schraffur) — deutlich erkennbar unabhängig von Papierfarbe/Linienstärke. - const shape = renderPrimitive(p.greyed ? greyedCopy(p) : p, index, toScreen, hairline, paperScale); - return shape; -} - -/** Konstante Haarlinien-Breite (Bildschirm-px) im Display-Modus. */ -const HAIRLINE_PX = 1; - -/** - * Print-Strichbreite: eine echte Papier-mm-Breite `mm` in VIEWBOX-EINHEITEN, - * bezogen auf den (beim Rad-Zoom stabilen) Papier-Massstab-Nenner `n` (1:N). - * • Auf Papier 1:N bildet 1 Welt-Meter auf `1000/N` Papier-mm ab, d. h. eine - * `mm`-Strichbreite belegt `mm·N/1000` Welt-Meter. - * • toScreen bildet 1 Welt-Meter → PX_PER_M viewBox-Einheiten ab, also - * `strokeVb = mm·N/1000·PX_PER_M`. - * Ohne non-scaling-stroke gezeichnet, skaliert dieser Wert MIT der Geometrie: - * reinzoomen (mehr px je viewBox-Einheit) = dicker, rauszoomen = dünner. Und - * von 1:10 auf 1:100 (N: 10→100) wird jede Linie bei gleichem Zoom ×10 dicker. - * Fällt `n` weg (noch nicht gemessen), bleibt es bei einer minimalen sichtbaren - * Breite (kein „unsichtbar"). - */ -function printStrokeVb(mm: number, n: number): number { - return Math.max(1e-4, (mm * n) / 1000) * PX_PER_M; -} - -/** Erzeugt das reine SVG eines Primitivs (ohne Dimm-Hülle). */ -function renderPrimitive( - p: Primitive, - index: number, - toScreen: (v: Vec2) => Vec2, - hairline: boolean, - paperScale: number | null, -) { - // Print-Modus nur, wenn ein Referenz-Massstab vorliegt; sonst (bzw. Display) - // gilt der bisherige non-scaling-Pfad. Beide Modi setzen die GLEICHEN - // Präsentationsattribute (nur Wert + vectorEffect unterscheiden sich). - const print = !hairline && paperScale != null && paperScale > 0; - // Strichstärke: - // • Display → konstante Haarlinie (Bildschirm-px), papierunabhängig. - // • Print → echte Papier-mm, VORHER auf die PEN_STEPS-Stiftstufe - // gequantelt (identisch zum PDF-Export, `quantizePen` aus - // `sceneToPrintSvg.ts`), dann in viewBox-Einheiten (skaliert mit dem - // Zoom) — so zeigt die Vorschau exakt die Strichbreite, die auch gedruckt - // wird. - const weight = (mm: number): number => - hairline ? HAIRLINE_PX : print ? printStrokeVb(quantizePen(mm), paperScale!) : mmToPx(mm); - // vectorEffect: non-scaling NUR, wenn NICHT im Print-Modus (Print skaliert die - // Linie bewusst mit der Geometrie). undefined ⇒ Attribut entfällt. - const vfx = print ? undefined : ("non-scaling-stroke" as const); - // Strichmuster: im Print-Modus ebenfalls in viewBox-Einheiten (skaliert mit), - // sonst wie bisher in Bildschirm-px (non-scaling). - const dashOf = (dash: number[] | null | undefined): string | undefined => { - if (!dash || !dash.length) return undefined; - return dash - .map((mm) => (print ? printStrokeVb(mm, paperScale!) : mmToPx(mm))) - .join(" "); - }; - switch (p.kind) { - case "polygon": { - const scr = p.pts.map(toScreen); - const pts = scr.map((s) => `${s.x},${s.y}`).join(" "); - // Umriss-Strichstärke in mm Papier (Display: konstante px / Print: skaliert). - const sw = weight(p.strokeWidthMm); - // Innere Gehrungs-Stirnkanten am Wandknoten (noStrokeEdges) NICHT stricheln: - // die Füllung bleibt das volle Polygon, aber die Diagonale am Knoten (Naht) - // und die überschießende Eck-Barbe entfallen. Sichtbare Kanten werden als - // zusammenhängende offene -Läufe gezeichnet (keine Gehrung über - // die weggelassene Kante). Leeres/kein noStrokeEdges ⇒ voller Umriss. - const noStroke = p.noStrokeEdges; - const outline = () => { - if (!noStroke || noStroke.length === 0) { - return ( - - ); - } - const runs = visibleEdgeRuns(scr, noStroke); - return ( - <> - {runs.map((run, ri) => ( - `${s.x},${s.y}`).join(" ")} - fill="none" - stroke={p.stroke} - strokeWidth={sw} - vectorEffect={vfx} - /> - ))} - - ); - }; - // Bild-Schraffur: Grundfüllung + gekacheltes Bild-Muster ( aus - // , id=hatch-index) + Umriss — unabhängig vom `pattern`-Feld. - if (p.hatch.kind === "image" && p.hatch.image) { - return ( - - - - {outline()} - - ); - } - // Random-Vektor-Schraffur (Kies/Splitt): Grundfüllung + deterministische - // Streu-Striche (im MODELL-Raum erzeugt → toScreen; identischer Seed wie - // GL/PDF) + Umriss. KEIN (die Striche werden direkt gezeichnet). - if (p.hatch.kind !== "image" && p.hatch.lines === "random") { - const hsw = weight(p.hatch.lineWeight > 0 ? p.hatch.lineWeight : 0.13); - const runs = buildRandomHatchRuns(p.pts, p.hatch); - return ( - - - {runs.map((run, ri) => ( - `${s.x},${s.y}`).join(" ")} - fill="none" - stroke={p.hatch.color} - strokeWidth={hsw} - vectorEffect={vfx} - /> - ))} - {outline()} - - ); - } - // Ohne Schraffur: reine Component-Füllung bzw. „none" (nur Umriss). - if (p.hatch.pattern === "none") { - return ( - - - {outline()} - - ); - } - // Vollfüllung: Schraffurfarbe deckt die Component-Füllung (Poché). - if (p.hatch.pattern === "solid") { - return ( - - - {outline()} - - ); - } - // Linien-/Kreuz-Schraffur: erst Grundfüllung, dann Muster, dann Umriss. - return ( - - - - {outline()} - - ); - } - case "line": { - const a = toScreen(p.a); - const b = toScreen(p.b); - // Zickzack-Linie: als Polylinie (im MODELL-Raum tesselliert → toScreen; - // amplitude/wavelength Papier-mm → Modell-Meter via DASH_MM_TO_M, wie - // GL/PDF). ADDITIV — gerade/gestrichelte Linien bleiben unverändert. - if (p.zigzag) { - const zpts = zigzagPoints( - p.a, - p.b, - p.zigzag.amplitude * DASH_MM_TO_M, - p.zigzag.wavelength * DASH_MM_TO_M, - ).map(toScreen); - return ( - `${s.x},${s.y}`).join(" ")} - className={p.cls} - fill="none" - stroke={p.color} - strokeWidth={weight(p.weightMm)} - vectorEffect={vfx} - /> - ); - } - // Custom-Motiv: gekachelt im MODELL-Raum (Papier-mm → Modell-Meter), analog - // Zickzack. ADDITIV — gerade/gestrichelte Linien bleiben unverändert. - if (p.motif) { - const mpts = motifPoints(p.a, p.b, p.motif, DASH_MM_TO_M).map(toScreen); - return ( - `${s.x},${s.y}`).join(" ")} - className={p.cls} - fill="none" - stroke={p.color} - strokeWidth={weight(p.weightMm)} - vectorEffect={vfx} - /> - ); - } - // Strichstärke + Strichmuster in mm Papier. Die Farbe kommt weiterhin aus - // der CSS-Klasse (z. B. .door-leaf / .wall-axis). - return ( - - ); - } - case "arc": { - const c = toScreen(p.center); - const from = toScreen(p.from); - const to = toScreen(p.to); - const r = p.r * PX_PER_M; - // Kurzer Weg (≤180°): large-arc = 0. Drehrichtung aus Kreuzprodukt - // im Bildschirmraum (y nach unten). - const v1 = { x: from.x - c.x, y: from.y - c.y }; - const v2 = { x: to.x - c.x, y: to.y - c.y }; - const cross = v1.x * v2.y - v1.y * v2.x; - const sweep = cross > 0 ? 1 : 0; - const d = `M ${from.x} ${from.y} A ${r} ${r} 0 0 ${sweep} ${to.x} ${to.y}`; - return ( - - ); - } - case "text": { - const c = toScreen(p.at); - // Punkt → viewBox-Einheiten. Physisch: basePt pt bei Massstab 1:N belegt - // basePt/72·0.0254·N Modell-Meter; im Display-Modus gilt der Referenz- - // massstab 1:100 (der Stempel skaliert dann mit dem Zoom, am Modell - // verankert). unitPerPt bildet 1 pt auf viewBox-Einheiten ab. - const nRef = print ? paperScale! : 100; - const unitPerPt = (1 / 72) * 0.0254 * nRef * PX_PER_M; - const baseFs = p.basePt * unitPerPt; - const lineGap = baseFs * 1.3; - // Rich-Text-Zeilen (Stempel-Doc) + LIVE-Zusatzzeilen (Fläche/SIA) darunter. - const docLines: SvgLine[] = docToLines(p.doc, { - x: 0, - y: 0, - lineHeight: lineGap, - unitPerPt, - basePt: p.basePt, - color: p.color, - }); - const extra: SvgLine[] = p.extraLines.map((line) => ({ - align: line.align, - lineHeight: lineGap, - tspans: [{ text: line.text, fontSize: baseFs * 0.8, fill: p.color }], - })); - const lines = [...docLines, ...extra]; - if (lines.length === 0) return null; - // Block vertikal um den Anker zentrieren. Absätze können eine eigene - // Zeilenhöhe tragen (Text-Gruppe, Zeilenhöhe-Regler) — darum kumulierte - // Vorschübe statt eines einzigen festen lineGap. - const totalH = baseFs + lines.slice(1).reduce((sum, l) => sum + l.lineHeight, 0); - const startY = c.y - totalH / 2 + baseFs * 0.8; - const lineYs: number[] = []; - { - let y = startY; - lines.forEach((line, k) => { - if (k > 0) y += line.lineHeight; - lineYs.push(y); - }); - } - return ( - - {lines.map((line, k) => { - const anchor = - line.align === "center" - ? "middle" - : line.align === "right" - ? "end" - : "start"; - return ( - - {line.tspans.map((ts, j) => ( - - {ts.text} - - ))} - - ); - })} - - ); - } - case "drawingImage": { - // Eingebettetes Rasterbild (Foto/Logo/gerasterte PDF-Seite), achsparallel. - // Screen-Y ist gespiegelt (toScreen) → oben-links = (min.x, max.y). - const topLeft = toScreen(p.max); - const w = (p.max.x - p.min.x) * PX_PER_M; - const h = (p.max.y - p.min.y) * PX_PER_M; - if (w <= 0 || h <= 0) return null; - return ( - - ); - } - case "drawingText": { - // Modellverankerter Einzeltext (DXF-Import / Text-Werkzeug): Höhe in Metern → - // viewBox-Einheiten (skaliert mit dem Zoom), Baseline am Ankerpunkt (links). - const c = toScreen(p.at); - const fontSize = p.heightM * PX_PER_M; - // Modell-Winkel CCW; Screen-Y ist gespiegelt und SVG-rotate CW-positiv → -deg. - const deg = -(p.angle * 180) / Math.PI; - - // Reiches Textfeld (InDesign-artig, mehrere Absätze/gemischte Formatierung): - // hat Vorrang vor dem Alt-Pfad (einfacher String + einheitliche `marks`) - // weiter unten. Bricht bei gesetzter Spaltenbreite wortweise um (Run-Grenzen - // bleiben erhalten, s. `wrapDocToLines`). - if (p.doc) { - const unitPerPt = (0.0254 / 72) * PX_PER_M; - const basePt = (p.heightM / 0.0254) * 72; - const lineHeight = fontSize * 1.25; - const wrapWidthUnits = p.wrapWidth && p.wrapWidth > 0 ? p.wrapWidth * PX_PER_M : undefined; - const docLines: SvgLine[] = wrapDocToLines(p.doc, { - x: 0, - y: 0, - lineHeight, - unitPerPt, - basePt, - color: p.color, - ...(wrapWidthUnits ? { wrapWidthUnits } : {}), - }); - if (docLines.length === 0) return null; - const lineYs: number[] = []; - { - let y = c.y; - docLines.forEach((line, k) => { - if (k > 0) y += line.lineHeight; - lineYs.push(y); - }); - } - return ( - - {docLines.map((line, k) => { - const anchor = - line.align === "center" ? "middle" : line.align === "right" ? "end" : "start"; - const x = !wrapWidthUnits - ? c.x - : line.align === "center" - ? c.x + wrapWidthUnits / 2 - : line.align === "right" - ? c.x + wrapWidthUnits - : c.x; - return ( - - {line.tspans.length === 0 - ? "​" - : line.tspans.map((ts, j) => ( - - {ts.text} - - ))} - - ); - })} - - ); - } - - // Alt-Pfad: einfacher String + einheitliche `marks` (Text-Werkzeug/DXF-Import - // ohne Rich-Doc) — unverändert. - // Spaltentext: bei gesetzter `wrapWidth` den Text wortweise auf die Breite - // umbrechen (Zeichenbreite ≈ 0.55·fontSize geschätzt), sonst eine Zeile. - const lineH = fontSize * 1.25; - const lines: string[] = []; - if (p.wrapWidth && p.wrapWidth > 0) { - const maxPx = p.wrapWidth * PX_PER_M; - const charPx = fontSize * 0.55; - const maxChars = Math.max(1, Math.floor(maxPx / charPx)); - for (const paragraph of p.text.split("\n")) { - let line = ""; - for (const word of paragraph.split(/\s+/)) { - if (line === "") line = word; - else if ((line.length + 1 + word.length) <= maxChars) line += " " + word; - else { lines.push(line); line = word; } - } - lines.push(line); - } - } else { - lines.push(p.text); - } - // Einheitliche Formatierung (Text-Werkzeug + Oberleisten-Formatier-Gruppe): - // Schriftfamilie/fett/kursiv/Unterstreichung aus `marks`; Grösse bleibt über - // heightM (Modell-Meter). Farbe steckt bereits in p.color (marks.color-Vorrang - // in generatePlan aufgelöst). - const mk = p.marks; - const deco = - mk && (mk.underline || mk.strike) - ? [mk.underline ? "underline" : "", mk.strike ? "line-through" : ""].join(" ").trim() - : undefined; - return ( - - {lines.length <= 1 ? ( - p.text - ) : ( - lines.map((ln, li) => ( - - {ln === "" ? "​" : ln} - - )) - )} - - ); - } - case "drawingCircle": { - // Echtes `` — rund bei jedem Zoom (kein Vieleck). Füllung optional. - const c = toScreen(p.center); - const r = p.r * PX_PER_M; - return ( - - ); - } - case "drawingArc": { - // Glatter SVG-Bogen. Winkel in Radiant (CCW im Modell); Screen-Y ist - // gespiegelt → ein modell-CCW-Bogen läuft am Bildschirm im Uhrzeigersinn - // (SVG sweep-flag 1). large-arc aus der Spannweite (>π). - const TAU = Math.PI * 2; - const rPx = p.r * PX_PER_M; - const from = toScreen({ - x: p.center.x + p.r * Math.cos(p.a0), - y: p.center.y + p.r * Math.sin(p.a0), - }); - const to = toScreen({ - x: p.center.x + p.r * Math.cos(p.a1), - y: p.center.y + p.r * Math.sin(p.a1), - }); - const sweep = (((p.a1 - p.a0) % TAU) + TAU) % TAU; - const largeArc = sweep > Math.PI ? 1 : 0; - const d = `M ${from.x} ${from.y} A ${rPx} ${rPx} 0 ${largeArc} 1 ${to.x} ${to.y}`; - return ( - - ); - } - } -} - -/** Schrittweite (Meter) fürs Lineal: 1/2/5·10^n, sodass Major-Ticks ≥ ~64 px auseinander liegen. */ -function rulerStep(pxPerMeter: number): number { - const minPx = 64; - let step = Math.pow(10, Math.ceil(Math.log10(minPx / pxPerMeter))); - for (const f of [0.2, 0.5, 1]) { - if (step * f * pxPerMeter >= minPx) return step * f; - } - return step; -} - -/** - * Lineale oben + links (Vectorworks-Stil): helle Leisten mit Major-Ticks + - * Meter-Beschriftung („5.000m"), Minor-Ticks und einem Cursor-Marker je Achse. - * Alles screen-fix (Grössen über vbPerPx), pointerEvents none — die Leisten - * liegen optisch über dem Plan, schlucken aber keine Klicks. - */ -function PlanRulers({ - vb, - vbPerPx, - pxPerMeter, - cursor, -}: { - /** Sichtbarer Ausschnitt in viewBox-Einheiten. */ - vb: { x0: number; y0: number; w: number; h: number }; - vbPerPx: number; - pxPerMeter: number; - cursor: Vec2 | null; -}) { - const TH = 20 * vbPerPx; // Leisten-Dicke (screen-px → viewBox) - const step = rulerStep(pxPerMeter); - const minor = step / 5; - const font = 9 * vbPerPx; - // Negative Null vermeiden („−0.000m" bei Startwerten knapp unter 0). - const label = (v: number): string => `${(Math.abs(v) < 1e-9 ? 0 : v).toFixed(3)}m`; - - // Sichtbarer Modellbereich (Plan-Y nach oben: vbY = −y·PX_PER_M). - const mx0 = vb.x0 / PX_PER_M; - const mx1 = (vb.x0 + vb.w) / PX_PER_M; - const myTop = -vb.y0 / PX_PER_M; - const myBottom = -(vb.y0 + vb.h) / PX_PER_M; - - const xTicks: { vbX: number; model: number; major: boolean }[] = []; - const startX = Math.floor(mx0 / minor) * minor; - for (let v = startX; v <= mx1 + 1e-9; v += minor) { - const major = Math.abs(v / step - Math.round(v / step)) < 1e-6; - xTicks.push({ vbX: v * PX_PER_M, model: v, major }); - } - const yTicks: { vbY: number; model: number; major: boolean }[] = []; - const startY = Math.floor(myBottom / minor) * minor; - for (let v = startY; v <= myTop + 1e-9; v += minor) { - const major = Math.abs(v / step - Math.round(v / step)) < 1e-6; - yTicks.push({ vbY: -v * PX_PER_M, model: v, major }); - } - - const x0 = vb.x0; - const y0 = vb.y0; - return ( - - {/* Leisten + Trennlinien. */} - - - - - {/* Oben: Ticks + Beschriftung. */} - {xTicks.map((t, i) => - t.vbX < x0 + TH ? null : ( - - - {t.major && ( - - {label(t.model)} - - )} - - ), - )} - {/* Links: Ticks + gedrehte Beschriftung. */} - {yTicks.map((t, i) => - t.vbY < y0 + TH ? null : ( - - - {t.major && ( - - {label(t.model)} - - )} - - ), - )} - {/* Cursor-Marker (VW-gelb) je Achse. */} - {cursor && ( - <> - - - - )} - {/* Ecke oben links. */} - - - ); -} diff --git a/src/plan/planView/PlanRulers.tsx b/src/plan/planView/PlanRulers.tsx new file mode 100644 index 0000000..32b38bd --- /dev/null +++ b/src/plan/planView/PlanRulers.tsx @@ -0,0 +1,136 @@ +// Lineale oben + links (Vectorworks-Stil) am Rand der Plan-Ansicht. + +import type { Vec2 } from "../../model/types"; +import { PX_PER_M } from "./geometry"; + +/** Schrittweite (Meter) fürs Lineal: 1/2/5·10^n, sodass Major-Ticks ≥ ~64 px auseinander liegen. */ +function rulerStep(pxPerMeter: number): number { + const minPx = 64; + let step = Math.pow(10, Math.ceil(Math.log10(minPx / pxPerMeter))); + for (const f of [0.2, 0.5, 1]) { + if (step * f * pxPerMeter >= minPx) return step * f; + } + return step; +} + +/** + * Lineale oben + links (Vectorworks-Stil): helle Leisten mit Major-Ticks + + * Meter-Beschriftung („5.000m"), Minor-Ticks und einem Cursor-Marker je Achse. + * Alles screen-fix (Grössen über vbPerPx), pointerEvents none — die Leisten + * liegen optisch über dem Plan, schlucken aber keine Klicks. + */ +export function PlanRulers({ + vb, + vbPerPx, + pxPerMeter, + cursor, +}: { + /** Sichtbarer Ausschnitt in viewBox-Einheiten. */ + vb: { x0: number; y0: number; w: number; h: number }; + vbPerPx: number; + pxPerMeter: number; + cursor: Vec2 | null; +}) { + const TH = 20 * vbPerPx; // Leisten-Dicke (screen-px → viewBox) + const step = rulerStep(pxPerMeter); + const minor = step / 5; + const font = 9 * vbPerPx; + // Negative Null vermeiden („−0.000m" bei Startwerten knapp unter 0). + const label = (v: number): string => `${(Math.abs(v) < 1e-9 ? 0 : v).toFixed(3)}m`; + + // Sichtbarer Modellbereich (Plan-Y nach oben: vbY = −y·PX_PER_M). + const mx0 = vb.x0 / PX_PER_M; + const mx1 = (vb.x0 + vb.w) / PX_PER_M; + const myTop = -vb.y0 / PX_PER_M; + const myBottom = -(vb.y0 + vb.h) / PX_PER_M; + + const xTicks: { vbX: number; model: number; major: boolean }[] = []; + const startX = Math.floor(mx0 / minor) * minor; + for (let v = startX; v <= mx1 + 1e-9; v += minor) { + const major = Math.abs(v / step - Math.round(v / step)) < 1e-6; + xTicks.push({ vbX: v * PX_PER_M, model: v, major }); + } + const yTicks: { vbY: number; model: number; major: boolean }[] = []; + const startY = Math.floor(myBottom / minor) * minor; + for (let v = startY; v <= myTop + 1e-9; v += minor) { + const major = Math.abs(v / step - Math.round(v / step)) < 1e-6; + yTicks.push({ vbY: -v * PX_PER_M, model: v, major }); + } + + const x0 = vb.x0; + const y0 = vb.y0; + return ( + + {/* Leisten + Trennlinien. */} + + + + + {/* Oben: Ticks + Beschriftung. */} + {xTicks.map((t, i) => + t.vbX < x0 + TH ? null : ( + + + {t.major && ( + + {label(t.model)} + + )} + + ), + )} + {/* Links: Ticks + gedrehte Beschriftung. */} + {yTicks.map((t, i) => + t.vbY < y0 + TH ? null : ( + + + {t.major && ( + + {label(t.model)} + + )} + + ), + )} + {/* Cursor-Marker (VW-gelb) je Achse. */} + {cursor && ( + <> + + + + )} + {/* Ecke oben links. */} + + + ); +} diff --git a/src/plan/planView/geometry.ts b/src/plan/planView/geometry.ts new file mode 100644 index 0000000..49821b3 --- /dev/null +++ b/src/plan/planView/geometry.ts @@ -0,0 +1,355 @@ +// Reine Geometrie-/Massstabs-Helfer für PlanView: Modell↔Screen-Abbildung, +// Papier-Massstab-Umrechnung, Punkt-/Polygon-Tests fürs Marquee-Picking. +// Keine JSX, kein Komponenten-/Closure-Zugriff — pure Funktionen + Konstanten. + +import type { Vec2 } from "../../model/types"; +import type { Primitive } from "../generatePlan"; + +/** Rechteckiger Ausschnitt in SVG-Benutzerkoordinaten (viewBox-Einheiten). */ +export interface ViewBox { + x: number; + y: number; + w: number; + h: number; +} + +export const PX_PER_M = 90; // viewBox-Einheiten je Meter (Modell → SVG-Benutzerraum) +export const PAD = 60; // Rand in viewBox-Einheiten + +/** + * Modell-Meter → SVG-Benutzerraum (viewBox-Einheiten) mit FIXEM Welt-Ursprung + * am Modell-Nullpunkt (0,0): `x·PX_PER_M`, Plan-Y zeigt nach oben → SVG-Y nach + * unten, daher `-y·PX_PER_M`. BEWUSST unabhängig von den Modell-Bounds — so + * verschiebt sich die Abbildung NICHT, wenn sich Elemente ändern (Verschieben/ + * Löschen). Der sichtbare Ausschnitt (viewBox) bleibt dann stehen; nur manuelles + * Pan/Zoom/Einpassen ändert ihn. (Inverse: `viewToModel` in `PlanView.tsx`.) + */ +export function toScreen(p: Vec2): Vec2 { + return { x: p.x * PX_PER_M, y: -p.y * PX_PER_M }; +} + +/** + * Zerlegt den Rand eines Polygons in zusammenhängende OFFENE Linienzüge, wobei die + * Kanten in `noStroke` ausgelassen werden (Kante `i` = pts[i]→pts[i+1]). Genutzt, + * um die inneren Gehrungs-Stirnkanten an einer Wandecke NICHT zu stricheln (keine + * 45°-Naht, keine über den Apex schießende Barbe), während die Füllung das volle + * Polygon bleibt. Jeder Lauf beginnt an einer sichtbaren Kante, deren Vorgänger + * ausgelassen ist. + */ +export function visibleEdgeRuns(scr: Vec2[], noStroke: number[]): Vec2[][] { + const n = scr.length; + const skip = new Set(noStroke.map((i) => ((i % n) + n) % n)); + if (skip.size >= n || n < 2) return []; + const visible = (i: number) => !skip.has(((i % n) + n) % n); + const runs: Vec2[][] = []; + for (let s = 0; s < n; s++) { + if (!visible(s) || visible(s - 1 + n)) continue; // kein Lauf-Anfang + const run: Vec2[] = [scr[s]]; + let j = s; + while (visible(j) && j - s < n) { + run.push(scr[(j + 1) % n]); + j++; + } + runs.push(run); + } + return runs; +} + +/** + * Papier-Massstab (docs/design/plans-output.md §3): Der Plan IST Papier-Space. + * dpi = 96 (CSS definiert 1 CSS-px = 1/96 inch, UNABHÄNGIG von devicePixelRatio — + * das gilt genauso auf Retina/HiDPI-Displays und bei OS-Skalierung, da der + * Browser CSS-px genau dafür physisch konstant hält, egal wie viele Geräte-px + * dahinterstecken). Strichstärken sind in mm Papier definiert und werden über + * diese dpi in konstante Bildschirm-Pixel umgerechnet (zusammen mit + * vector-effect:non-scaling-stroke bleiben sie beim Zoomen papierkonstant). + */ +export const dpi = (): number => 96; +/** mm Papier → Bildschirm-Pixel (für non-scaling-stroke / dasharray). */ +export const mmToPx = (mm: number): number => (mm / 25.4) * dpi(); + +/** Konstante Haarlinien-Breite (Bildschirm-px) im Display-Modus. */ +export const HAIRLINE_PX = 1; + +/** + * Print-Strichbreite: eine echte Papier-mm-Breite `mm` in VIEWBOX-EINHEITEN, + * bezogen auf den (beim Rad-Zoom stabilen) Papier-Massstab-Nenner `n` (1:N). + * • Auf Papier 1:N bildet 1 Welt-Meter auf `1000/N` Papier-mm ab, d. h. eine + * `mm`-Strichbreite belegt `mm·N/1000` Welt-Meter. + * • toScreen bildet 1 Welt-Meter → PX_PER_M viewBox-Einheiten ab, also + * `strokeVb = mm·N/1000·PX_PER_M`. + * Ohne non-scaling-stroke gezeichnet, skaliert dieser Wert MIT der Geometrie: + * reinzoomen (mehr px je viewBox-Einheit) = dicker, rauszoomen = dünner. Und + * von 1:10 auf 1:100 (N: 10→100) wird jede Linie bei gleichem Zoom ×10 dicker. + * Fällt `n` weg (noch nicht gemessen), bleibt es bei einer minimalen sichtbaren + * Breite (kein „unsichtbar"). + */ +export function printStrokeVb(mm: number, n: number): number { + return Math.max(1e-4, (mm * n) / 1000) * PX_PER_M; +} + +/** + * Effektive meet-Skala (Bildschirm-px je viewBox-Einheit) für einen Ausschnitt. + * preserveAspectRatio="xMidYMid meet" nimmt die kleinere der beiden Achsen. + * Liefert null, solange das SVG noch nicht gemessen werden kann. + */ +export function meetScale(vb: ViewBox, el: SVGSVGElement | null): number | null { + const rect = el?.getBoundingClientRect(); + if (!rect || rect.width <= 0 || rect.height <= 0) return null; + return Math.min(rect.width / vb.w, rect.height / vb.h); +} + +/** + * LIVE Papier-Massstab 1:N aus dem Ausschnitt (docs/design/plans-output.md §3): + * 1 m belegt `scale·PX_PER_M` CSS-px; in mm = ·25.4/dpi; auf Papier 1:N sind + * das 1000/N mm ⇒ N = 1000·dpi / (scale·PX_PER_M·25.4). Letterbox-korrekt, da + * `scale` die tatsächliche meet-Skala ist. + */ +export function scaleFromView(vb: ViewBox, el: SVGSVGElement | null): number | null { + const scale = meetScale(vb, el); + if (scale == null) return null; + return (1000 * dpi()) / (scale * PX_PER_M * 25.4); +} + +/** + * Ziel-Ausschnitt, der das Modell exakt im Papier-Massstab 1:N abbildet (Inverse + * von {@link scaleFromView}). Setzt die viewBox auf das Canvas-Seitenverhältnis + * (kein Letterbox → horizontale wie vertikale Skala = Zielskala) und hält die + * Bildmitte des bisherigen Ausschnitts. Ohne Messung null (kein Sprung blind). + */ +export function viewForScale( + n: number, + prev: ViewBox, + el: SVGSVGElement | null, +): ViewBox | null { + const rect = el?.getBoundingClientRect(); + if (!rect || rect.width <= 0 || rect.height <= 0 || !(n > 0)) return null; + // Ziel-meet-Skala aus N, dann viewBox = Canvas-px / Skala (beide Achsen). + const targetScale = (1000 * dpi()) / (n * PX_PER_M * 25.4); + const w = rect.width / targetScale; + const h = rect.height / targetScale; + const cx = prev.x + prev.w / 2; + const cy = prev.y + prev.h / 2; + return { x: cx - w / 2, y: cy - h / 2, w, h }; +} + +/** + * Einpass-Ausschnitt für ein Polygon (in Modell-Metern): dessen Bounding-Box + * → viewBox-Einheiten (über toScreen) mit etwas Rand. Für „auf Auswahl einpassen". + */ +export function fitBoxFor(pts: Vec2[], toScreenFn: (v: Vec2) => Vec2): ViewBox { + let minX = Infinity, + minY = Infinity, + maxX = -Infinity, + maxY = -Infinity; + for (const p of pts) { + const s = toScreenFn(p); + minX = Math.min(minX, s.x); + minY = Math.min(minY, s.y); + maxX = Math.max(maxX, s.x); + maxY = Math.max(maxY, s.y); + } + const w = maxX - minX; + const h = maxY - minY; + const margin = Math.max(w, h) * 0.25 + PAD; // etwas Luft um die Auswahl + return { + x: minX - margin, + y: minY - margin, + w: w + margin * 2, + h: h + margin * 2, + }; +} + +/** Wert auf [lo, hi] begrenzen. */ +export function clamp(value: number, lo: number, hi: number): number { + return Math.min(hi, Math.max(lo, value)); +} + +/** + * Punkt-in-Polygon (Ray-Casting, gerade Kreuzungszahl). Arbeitet in beliebigen + * 2D-Koordinaten — hier in Modell-Metern, damit die Trefferprüfung unabhängig + * vom Zoom ist. Rand zählt nicht garantiert; für die Wand-Auswahl ausreichend. + */ +export function pointInPolygon(pt: Vec2, poly: Vec2[]): boolean { + let inside = false; + for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { + const xi = poly[i].x; + const yi = poly[i].y; + const xj = poly[j].x; + const yj = poly[j].y; + const intersect = + yi > pt.y !== yj > pt.y && + pt.x < ((xj - xi) * (pt.y - yi)) / (yj - yi) + xi; + if (intersect) inside = !inside; + } + return inside; +} + +/** Abstand Punkt→Strecke (alle in denselben Koordinaten, hier Client-Pixel). */ +export function pointSegDistPx( + p: { x: number; y: number }, + a: { x: number; y: number }, + b: { x: number; y: number }, +): number { + const dx = b.x - a.x; + const dy = b.y - a.y; + const len2 = dx * dx + dy * dy; + if (len2 < 1e-9) return Math.hypot(p.x - a.x, p.y - a.y); + let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2; + t = Math.max(0, Math.min(1, t)); + return Math.hypot(p.x - (a.x + dx * t), p.y - (a.y + dy * t)); +} + +/** Achsenparalleles Rechteck in Modell-Metern (Auswahl-Marquee). */ +export interface ModelRect { + minX: number; + minY: number; + maxX: number; + maxY: number; +} + +/** + * Bestimmt die vom Auswahl-Rechteck getroffenen Wand-IDs nach CAD-Konvention: + * • crossing = false (links→rechts): nur Wände, deren Polygone VOLLSTÄNDIG im + * Rechteck liegen (jeder Eckpunkt enthalten). + * • crossing = true (rechts→links): zusätzlich Wände, die das Rechteck nur + * BERÜHREN/kreuzen (Überlappung der Polygone mit dem Rechteck). + * Getestet wird je Wand über ALLE ihre Polygon-Bänder; ein Treffer eines Bandes + * genügt. Liefert eindeutige IDs in Auftreten-Reihenfolge. + */ +export function marqueeHit( + prims: Primitive[], + rect: ModelRect, + crossing: boolean, +): string[] { + // Polygone je Wand sammeln (nur Primitive mit Wandbezug zählen für die Auswahl). + const byWall = new Map(); + for (const p of prims) { + if (p.kind !== "polygon" || p.wallId == null) continue; + const list = byWall.get(p.wallId); + if (list) list.push(p.pts); + else byWall.set(p.wallId, [p.pts]); + } + const out: string[] = []; + for (const [wallId, polys] of byWall) { + const hit = crossing + ? polys.some((poly) => polyTouchesRect(poly, rect)) + : polys.every((poly) => polyEnclosedByRect(poly, rect)); + if (hit) out.push(wallId); + } + return out; +} + +/** + * Vom Auswahl-Rechteck getroffene 2D-Zeichenelement-IDs (gleiche window/crossing- + * Konvention wie {@link marqueeHit}). Sammelt je Drawing2D ALLE seine Punkte aus + * den Linien-/Polygon-Primitiven; crossing = irgendein Punkt/Kante im Rechteck, + * window = alle Punkte vollständig eingeschlossen. + */ +export function marqueeHitDrawings( + prims: Primitive[], + rect: ModelRect, + crossing: boolean, +): string[] { + const byDrawing = new Map(); + const collect = (id: string, pts: Vec2[]) => { + const list = byDrawing.get(id); + if (list) list.push(...pts); + else byDrawing.set(id, [...pts]); + }; + for (const p of prims) { + if (p.kind === "line" && p.drawingId) collect(p.drawingId, [p.a, p.b]); + else if (p.kind === "polygon" && p.drawingId) collect(p.drawingId, p.pts); + } + const out: string[] = []; + for (const [id, pts] of byDrawing) { + const hit = crossing + ? polyTouchesRect(pts, rect) + : polyEnclosedByRect(pts, rect); + if (hit) out.push(id); + } + return out; +} + +/** Orientierung des Tripels (a,b,c): >0 CCW, <0 CW, 0 kollinear. */ +function cross3(a: Vec2, b: Vec2, c: Vec2): number { + return (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x); +} + +/** Liegt p auf der Strecke a-b (bei bereits kollinearen Punkten)? */ +function onSegment(a: Vec2, b: Vec2, p: Vec2): boolean { + return ( + Math.min(a.x, b.x) <= p.x && + p.x <= Math.max(a.x, b.x) && + Math.min(a.y, b.y) <= p.y && + p.y <= Math.max(a.y, b.y) + ); +} + +/** Schneiden sich die Strecken a-b und c-d (inkl. Berührung)? */ +function segmentsIntersect(a: Vec2, b: Vec2, c: Vec2, d: Vec2): boolean { + const d1 = cross3(c, d, a); + const d2 = cross3(c, d, b); + const d3 = cross3(a, b, c); + const d4 = cross3(a, b, d); + if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && + ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))) { + return true; + } + // Kollineare Berührung. + if (d1 === 0 && onSegment(c, d, a)) return true; + if (d2 === 0 && onSegment(c, d, b)) return true; + if (d3 === 0 && onSegment(a, b, c)) return true; + if (d4 === 0 && onSegment(a, b, d)) return true; + return false; +} + +/** Alle Eckpunkte des Polygons liegen im Rechteck (vollständig eingeschlossen). */ +export function polyEnclosedByRect(poly: Vec2[], r: ModelRect): boolean { + if (poly.length === 0) return false; + return poly.every( + (pt) => + pt.x >= r.minX && pt.x <= r.maxX && pt.y >= r.minY && pt.y <= r.maxY, + ); +} + +/** + * Polygon und Rechteck überlappen/berühren sich (crossing-Test). Wahr, wenn: + * • ein Polygon-Eckpunkt im Rechteck liegt, ODER + * • eine Rechteck-Ecke im Polygon liegt, ODER + * • eine Polygon-Kante eine Rechteck-Kante schneidet. + * Deckt damit auch den Fall ab, dass das Rechteck ganz innerhalb der Wand liegt. + */ +export function polyTouchesRect(poly: Vec2[], r: ModelRect): boolean { + // 1) Polygon-Punkt im Rechteck? + for (const pt of poly) { + if (pt.x >= r.minX && pt.x <= r.maxX && pt.y >= r.minY && pt.y <= r.maxY) { + return true; + } + } + // 2) Rechteck-Ecke im Polygon? (Rechteck ganz in der Wand.) + const corners: Vec2[] = [ + { x: r.minX, y: r.minY }, + { x: r.maxX, y: r.minY }, + { x: r.maxX, y: r.maxY }, + { x: r.minX, y: r.maxY }, + ]; + for (const c of corners) { + if (pointInPolygon(c, poly)) return true; + } + // 3) Kanten-Schnitt Polygon × Rechteck. + const rectEdges: [Vec2, Vec2][] = [ + [corners[0], corners[1]], + [corners[1], corners[2]], + [corners[2], corners[3]], + [corners[3], corners[0]], + ]; + for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { + const a = poly[j]; + const b = poly[i]; + for (const [c, d] of rectEdges) { + if (segmentsIntersect(a, b, c, d)) return true; + } + } + return false; +} diff --git a/src/plan/planView/overlays.tsx b/src/plan/planView/overlays.tsx new file mode 100644 index 0000000..da2f337 --- /dev/null +++ b/src/plan/planView/overlays.tsx @@ -0,0 +1,388 @@ +// Werkzeug-/Snap-Overlays der Plan-Ansicht: Vorschau-Formen, Cursor-HUD, +// Winkel-Führungslinie, Snap-Marker. Reine Darstellung, bekommt `toScreen` + +// Massstab-Parameter als Props gereicht. + +import type { Vec2 } from "../../model/types"; +import type { SnapResult, ToolDraft } from "../../tools/types"; + +/** + * Zeichnet die Werkzeug-Vorschau: Vorschau-Formen (gestrichelte Akzentlinien / + * halbtransparente Flächen), gesetzte Stützpunkte (kleine Quadrate), den + * aktiven Snap-Marker, das Cursor-HUD (Länge/Winkel, VW-Stil) sowie — bei + * weichem Winkel-Snap (`snap.kind === "angle"`) — Winkel-Badge + verlängerte + * Führungslinie. `vbPerPx` rechnet eine gewünschte Bildschirm-Pixelgröße in + * viewBox-Einheiten um (bildschirmkonstante Marker/Schrift). Die Winkel-Info + * reitet auf `draft.snap` mit (refA/point/angleDeg) — kein eigenes Feld nötig, + * `computeSnap` liefert sie bereits vollständig. + */ +export function DraftOverlay({ + draft, + toScreen, + vbPerPx, + snapColor, + hudFields = null, +}: { + draft: ToolDraft; + toScreen: (v: Vec2) => Vec2; + vbPerPx: number; + snapColor: string; + hudFields?: { + fields: { id: string; value: number | null; locked: boolean; active: boolean }[]; + typed: string; + } | null; +}) { + const vertSize = 4.5 * vbPerPx; // halbe Kantenlänge eines Stützpunkt-Quadrats (klein + einheitlich) + const markSize = 4.75 * vbPerPx; // halbe Kantenlänge des Snap-Markers (klein + fein) + return ( + + {/* Vorschau-Formen. */} + {draft.preview.map((s, i) => + s.kind === "line" ? ( + + ) : ( + `${p.x},${p.y}`).join(" ")} + vectorEffect="non-scaling-stroke" + /> + ), + )} + {/* Gesetzte Stützpunkte. */} + {draft.vertices.map((v, i) => { + const s = toScreen(v); + return ( + + ); + })} + {/* Weicher Winkel-Snap: verlängerte gestrichelte Führungslinie + Badge + UNTER dem Snap-Marker (der bleibt obenauf). */} + {draft.snap?.kind === "angle" && draft.snap.refA && draft.snap.angleDeg != null && ( + + )} + {/* Snap-Marker + optionale Ortho-Hilfslinie. */} + {draft.snap && ( + + )} + {/* Maß-/Winkel-HUD am Cursor (VW-Stil `L: … W: …`, oder einfacher Text). */} + {draft.hud && } + + ); +} + +/** + * Cursor-HUD im Vectorworks-Stil: weisser Kasten, blauer Rahmen, blauer + * Monospace-Text. Zeigt `L:`/`W:` (Länge 3 Nachkommastellen + „m", Winkel 3 + * Nachkommastellen + „°"), wenn beide vorhanden sind — sonst den freien + * `text` (Radius/Fläche/… bei Befehlen ohne Länge-Winkel-Paar). Leicht + * versetzt rechts-unten vom Cursorpunkt, bildschirmkonstante Größe. + */ +function DraftHud({ + hud, + toScreen, + vbPerPx, + hudFields = null, +}: { + hud: NonNullable; + toScreen: (v: Vec2) => Vec2; + vbPerPx: number; + hudFields?: { + fields: { id: string; value: number | null; locked: boolean; active: boolean }[]; + typed: string; + } | null; +}) { + // Segmente: mit Befehlszeilen-Feldern (Tab-Zyklus) spiegelt das HUD deren + // Zustand — getippter Text erscheint LIVE im aktiven Feld (VW-Verhalten), + // gelockte Werte fest, ungelockte folgen der Maus (draft.hud-Livewerte als + // Fallback je Feld-Id). Ohne Felder: bisherige L/W- bzw. Text-Anzeige. + interface Seg { + text: string; + active: boolean; + locked: boolean; + } + const liveFor = (id: string): number | null => + id === "length" + ? (hud.length ?? null) + : id === "angle" + ? (hud.angleDeg ?? null) + : id === "width" + ? (hud.dx ?? null) + : id === "height" + ? (hud.dy ?? null) + : null; + // Feld-Beschriftung: Länge/Winkel als L/W, Rechteck-Masse als Δx/Δy (VW). + const labelFor = (id: string): string => + id === "length" ? "L" : id === "angle" ? "W" : id === "width" ? "Δx" : id === "height" ? "Δy" : id; + const segs: Seg[] = []; + if (hudFields && hudFields.fields.length > 0) { + for (const f of hudFields.fields) { + const label = labelFor(f.id); + const unit = f.id === "angle" ? "°" : "m"; + const typedHere = f.active && hudFields.typed.trim() !== ""; + const value = typedHere + ? hudFields.typed + : f.value != null + ? f.value.toFixed(3) + : (liveFor(f.id)?.toFixed(3) ?? "—"); + segs.push({ text: `${label}: ${value}${unit}`, active: f.active, locked: f.locked }); + } + } else if (hud.dx != null && hud.dy != null) { + segs.push({ text: `Δx: ${hud.dx.toFixed(3)}m`, active: false, locked: false }); + segs.push({ text: `Δy: ${hud.dy.toFixed(3)}m`, active: false, locked: false }); + } else if (hud.length != null && hud.angleDeg != null) { + segs.push({ text: `L: ${hud.length.toFixed(3)}m`, active: false, locked: false }); + segs.push({ text: `W: ${hud.angleDeg.toFixed(3)}°`, active: false, locked: false }); + } else if (hud.text) { + segs.push({ text: hud.text, active: false, locked: false }); + } + if (segs.length === 0) return null; + + const p = toScreen(hud.at); + const fontSize = 12 * vbPerPx; + const padX = 6 * vbPerPx; + const padY = 4 * vbPerPx; + const gap = 10 * vbPerPx; + const charW = fontSize * 0.62; // Monospace-Schätzung für die Kastenbreite + const widths = segs.map((s) => s.text.length * charW); + const w = widths.reduce((a, b) => a + b, 0) + gap * (segs.length - 1) + padX * 2; + const h = fontSize + padY * 2; + const x = p.x + 14 * vbPerPx; + const y = p.y + 14 * vbPerPx; + let cx = x + padX; + return ( + + + {segs.map((s, i) => { + const sx = cx; + cx += widths[i] + gap; + return ( + + {s.active && ( + + )} + + {s.text} + + + ); + })} + + ); +} + +/** + * Weicher Winkel-Snap (§snapCommonAngle): gestrichelte Führungslinie vom + * letzten Punkt über den Cursor hinaus verlängert (dezent, wie in VW) + ein + * gelbliches Winkel-Badge nahe der Liniemitte. + */ +function AngleGuide({ + from, + point, + angleDeg, + toScreen, + vbPerPx, +}: { + from: Vec2; + point: Vec2; + angleDeg: number; + toScreen: (v: Vec2) => Vec2; + vbPerPx: number; +}) { + const a = toScreen(from); + const b = toScreen(point); + const dx = b.x - a.x; + const dy = b.y - a.y; + const len = Math.hypot(dx, dy); + const dir = len > 1e-6 ? { x: dx / len, y: dy / len } : { x: 1, y: 0 }; + // Führungslinie WEIT über den Cursor hinaus (VW-Verhalten: die Referenz läuft + // quer über den sichtbaren Ausschnitt; der SVG-Clip schneidet den Rest ab). + const extend = 4000 * vbPerPx; + const end = { x: b.x + dir.x * extend, y: b.y + dir.y * extend }; + // Winkelbogen (VW): gestrichelter Bogen von der HORIZONTALEN Referenz (0°) zur + // Strahlrichtung, Radius knapp innerhalb des Cursors. Screen-y läuft nach + // unten → der Screen-Winkel ist der negierte Modellwinkel; für den Bogenpfad + // rechnen wir direkt mit Screen-Vektoren. + const screenAng = Math.atan2(dy, dx); // (−π, π], y-down + const r = Math.min(len * 0.85, 160 * vbPerPx); + const arcStart = { x: a.x + r, y: a.y }; + const arcEnd = { x: a.x + r * Math.cos(screenAng), y: a.y + r * Math.sin(screenAng) }; + const sweep = screenAng > 0 ? 1 : 0; // y-down: positiver Screen-Winkel = im Uhrzeigersinn + const arcPath = `M ${arcStart.x} ${arcStart.y} A ${r} ${r} 0 0 ${sweep} ${arcEnd.x} ${arcEnd.y}`; + // Horizontale Referenz-Strichellinie (0°-Basis des Bogens), etwas über den + // Bogen hinaus. + const refEnd = { x: a.x + r + 60 * vbPerPx, y: a.y }; + // Badge aussen am Bogen bei halbem Winkel (VW-Position). + const halfAng = screenAng / 2; + const badgeR = r + 22 * vbPerPx; + const badgeAt = { x: a.x + badgeR * Math.cos(halfAng), y: a.y + badgeR * Math.sin(halfAng) }; + const fontSize = 11 * vbPerPx; + const padX = 5 * vbPerPx; + const padY = 3 * vbPerPx; + const text = `${angleDeg.toFixed(3)}°`; + const w = text.length * fontSize * 0.62 + padX * 2; + const h = fontSize + padY * 2; + return ( + + + + {len > 24 * vbPerPx && Math.abs(screenAng) > 0.02 && ( + + )} + + + {text} + + + ); +} + +/** Marker-Glyph je Snap-Art + (bei ortho) eine gestrichelte Hilfslinie. */ +function SnapMarker({ + snap, + toScreen, + size, + color, +}: { + snap: SnapResult; + toScreen: (v: Vec2) => Vec2; + size: number; + /** Farbe des Snap-Glyphs (einstellbar, Default = Akzent „Sora"). */ + color: string; +}) { + const s = toScreen(snap.point); + const r = size; + // Inline `style` überschreibt gezielt die CSS-Klassenfarbe (eine am Element + // selbst deklarierte Klassenregel gewinnt sonst gegen eine geerbte Farbe von + // einer umschließenden Gruppe — daher pro Glyph statt einmal an der Gruppe). + const glyphStyle = { stroke: color }; + const helperStyle = { stroke: color }; + const fillStyle = { stroke: color, fill: color }; + return ( + + {(snap.kind === "ortho" || snap.kind === "extension") && snap.refA && ( + + )} + {snap.kind === "grid" && ( + + )} + {snap.kind === "midpoint" && ( + + )} + {snap.kind === "intersection" && ( + <> + + + + )} + {(snap.kind === "center" || snap.kind === "quadrant") && ( + + )} + {snap.kind === "onEdge" && ( + + )} + {(snap.kind === "endpoint" || snap.kind === "ortho" || snap.kind === "extension") && ( + + )} + + ); +} diff --git a/src/plan/planView/primitives.tsx b/src/plan/planView/primitives.tsx new file mode 100644 index 0000000..1149077 --- /dev/null +++ b/src/plan/planView/primitives.tsx @@ -0,0 +1,868 @@ +// Reine SVG-Darstellung der Plan-Primitive: Schraffur-s, gebündelte +// 2D-Zeichen-Läufe, das „andere grau"-Umfärben und der grosse Primitiv→SVG- +// Renderer. Bekommt `toScreen`/Massstab-Parameter gereicht, kein eigener +// Zugriff auf PlanView-internen State. + +import type { HatchRender, Primitive } from "../generatePlan"; +import { docToLines, wrapDocToLines, type SvgLine } from "../../text/renderHtml"; +import type { Vec2 } from "../../model/types"; +import { quantizePen } from "../../export/sceneToPrintSvg"; +import { buildRandomHatchRuns, zigzagPoints, motifPoints, DASH_MM_TO_M } from "../glPlan/glPlanHatch"; +import { dashHasDot } from "../../ui/lineSegments"; +import { HAIRLINE_PX, PX_PER_M, mmToPx, printStrokeVb, visibleEdgeRuns } from "./geometry"; + +/** Basis-Kachelmaß (viewBox-Einheiten) einer Bild-Schraffur bei scaleX/scaleY = 1. */ +const IMG_TILE_VB = 40; + +/** Lineare/Kreuz-Muster brauchen ein ; solid/none nicht. */ +export function needsPattern(pattern: HatchRender["pattern"]): boolean { + return ( + pattern === "insulation" || + pattern === "diagonal" || + pattern === "crosshatch" + ); +} + +/** + * Ob ein Polygon ein SVG-`` (in ``) braucht: + * • Bild-Schraffur (`kind==="image"`) → ja (gekacheltes Bild-Muster). + * • Random-Vektor (`lines==="random"`) → NEIN: die Streu-Striche werden als + * geclippte Polylinien direkt gezeichnet (Determinismus, Parität zu GL/PDF). + * • sonst die klassischen Parallel-/Kreuz-/Dämmungs-Muster. + */ +export function polyUsesPattern(h: HatchRender): boolean { + if (h.kind === "image") return !!h.image; + if (h.lines === "random") return false; + return needsPattern(h.pattern); +} + +/** + * Ein parametrisiertes Schraffur-. Maßstab skaliert die Kachelgröße, + * Winkel dreht das Muster (patternTransform), Farbe/Linienstärke kommen aus dem + * aufgelösten Hatch/LineStyle. Die Musterlinie ist ein GEWÖHNLICHER Papier-mm- + * Stift (wie jede andere Linie) — dieselbe Display-/Print-Logik wie + * `renderPrimitive`: Display = konstante Haarlinie (Bildschirm-px, via + * non-scaling-stroke), Print = echte, PEN_STEPS-gequantelte Papier-mm. + */ +export function HatchPattern({ + id, + hatch, + hairline, + paperScale, +}: { + id: string; + hatch: HatchRender; + hairline: boolean; + paperScale: number | null; +}) { + const print = !hairline && paperScale != null && paperScale > 0; + const sw = hairline + ? HAIRLINE_PX + : print + ? printStrokeVb(quantizePen(hatch.lineWeight), paperScale!) + : mmToPx(hatch.lineWeight); + const vfx = print ? undefined : ("non-scaling-stroke" as const); + const dashes = hatch.dash?.length + ? hatch.dash + .map((mm) => (print ? printStrokeVb(mm, paperScale!) : mmToPx(mm))) + .join(" ") + : undefined; + // Punktmuster in den Musterlinien: runde Kappe → Dots (Punkt/Strich-Punkt). + const dashCap = dashHasDot(hatch.dash) ? ("round" as const) : undefined; + + if (hatch.kind === "image" && hatch.image) { + // Bild-Schraffur: gekacheltes -Muster. Kachel-Basismaß in viewBox- + // Einheiten, unabhängig in L×B verzerrt (scaleX/scaleY), um `rotation` gedreht. + // preserveAspectRatio="none" erlaubt die Verzerrung. + const img = hatch.image; + const w = Math.max(1, IMG_TILE_VB * (img.scaleX > 0 ? img.scaleX : 1)); + const h = Math.max(1, IMG_TILE_VB * (img.scaleY > 0 ? img.scaleY : 1)); + return ( + + + + ); + } + + if (hatch.pattern === "insulation") { + // Weiche Zickzack-/Wellenlinie (SIA-nah). Kachel 14×10 × Maßstab. + const w = 14 * hatch.scale; + const h = 10 * hatch.scale; + const my = 5 * hatch.scale; + const qy1 = -1 * hatch.scale; + const qy2 = 11 * hatch.scale; + const qx1 = 3.5 * hatch.scale; + const qx2 = 7 * hatch.scale; + const qx3 = 10.5 * hatch.scale; + return ( + + + + ); + } + + // Diagonal + Crosshatch: dünne Linien, Winkel via patternTransform. + // Kachel 8×8 × Maßstab; crosshatch fügt eine zweite, senkrechte Schar hinzu. + const tile = 8 * hatch.scale; + return ( + + + {hatch.pattern === "crosshatch" && ( + + )} + + ); +} + +/** + * Ein zusammenhängender 2D-Zeichen-Linienzug (aus gemergten line-Primitiven): + * verkettete Punkte + gemeinsamer Stil. `closed` ⇒ als Ring (polygon) zeichnen, + * damit auch die Schluss-Ecke gehrt. Reine Darstellung. + */ +export interface DrawingRun { + pts: Vec2[]; + closed: boolean; + cls: string; + weightMm: number; + dash: number[] | null | undefined; + color?: string; + greyed?: boolean; + /** Zickzack-Parameter (Papier-mm); gesetzt ⇒ Lauf als Zickzack-Pfad zeichnen. */ + zigzag?: { amplitude: number; wavelength: number }; + /** Custom-Motiv (Papier-mm); gesetzt ⇒ Lauf als gekacheltes Motiv zeichnen. */ + motif?: { points: Vec2[]; length: number }; +} + +/** Kleiner Toleranz-Test auf Punktgleichheit (Modell-Meter). */ +const samePt = (a: Vec2, b: Vec2): boolean => + Math.abs(a.x - b.x) < 1e-9 && Math.abs(a.y - b.y) < 1e-9; + +/** Ob zwei line-Primitive denselben Stil (für einen gemeinsamen Zug) tragen. */ +function sameLineStyle( + a: Extract, + b: Extract, +): boolean { + return ( + a.drawingId === b.drawingId && + a.cls === b.cls && + a.color === b.color && + a.weightMm === b.weightMm && + !!a.greyed === !!b.greyed && + JSON.stringify(a.dash ?? null) === JSON.stringify(b.dash ?? null) + ); +} + +/** + * Bündelt aufeinanderfolgende 2D-Zeichen-Linien (mit drawingId) zu verketteten + * Zügen (End-an-Start). Erkennt Ringschluss (letzter == erster Punkt) → `closed`. + * Linien ohne drawingId (Wand-/Tür-/Kontext-Striche) bleiben unberührt (nicht + * enthalten). Reihenfolge-stabil in Auftreten der Primitive. + */ +export function buildDrawingRuns(prims: Primitive[]): DrawingRun[] { + const runs: DrawingRun[] = []; + let curPts: Vec2[] | null = null; + let curSrc: Extract | null = null; + const flush = () => { + if (curPts && curSrc && curPts.length >= 2) { + const closed = curPts.length > 2 && samePt(curPts[0], curPts[curPts.length - 1]); + runs.push({ + pts: closed ? curPts.slice(0, -1) : curPts, + closed, + cls: curSrc.cls, + weightMm: curSrc.weightMm, + dash: curSrc.dash, + color: curSrc.color, + greyed: curSrc.greyed, + }); + } + curPts = null; + curSrc = null; + }; + + for (const p of prims) { + if (p.kind !== "line" || !p.drawingId) { + flush(); + continue; + } + // Zickzack-Linien werden NICHT verkettet (jede als eigener Zickzack-Lauf) — + // sonst würde die Tessellierung über eine Gehrung hinweg zerreißen. + if (p.zigzag) { + flush(); + runs.push({ + pts: [p.a, p.b], + closed: false, + cls: p.cls, + weightMm: p.weightMm, + dash: p.dash, + color: p.color, + greyed: p.greyed, + zigzag: p.zigzag, + }); + continue; + } + // Custom-Motiv-Linien ebenfalls einzeln (analog Zickzack). + if (p.motif) { + flush(); + runs.push({ + pts: [p.a, p.b], + closed: false, + cls: p.cls, + weightMm: p.weightMm, + dash: p.dash, + color: p.color, + greyed: p.greyed, + motif: p.motif, + }); + continue; + } + if (curPts && curSrc && sameLineStyle(curSrc, p) && samePt(curPts[curPts.length - 1], p.a)) { + // An den laufenden Zug anhängen. + curPts.push(p.b); + } else { + // Neuen Zug beginnen. + flush(); + curPts = [p.a, p.b]; + curSrc = p; + } + } + flush(); + return runs; +} + +/** + * Fester Grauton gedimmter Primitive im Darstellungsmodus „andere grau" — + * ECHTE Umfärbung (nicht nur Deckkraft), da dünne Haarlinien auf dem hellen + * Zeichenblatt (--sheet: #f0f0f0) bei reiner Transparenz kaum erkennbar + * gedimmt wirken. Gleicher Ton in allen drei Render-Pfaden (SVG/WebGL/nativ, + * siehe glPlanCompile.ts `GREY_RGB` und toRenderScene.ts `GREY_RGB`). + */ +export const GREYED_COLOR = "#8c8c8c"; + +/** Ersetzt Fill-/Stroke-/Farbfelder (inkl. Schraffur) eines Primitivs durch den festen Grauton. */ +export function greyColor(c: string | undefined): string | undefined { + return !c || c === "none" || c === "transparent" ? c : GREYED_COLOR; +} + +/** Flache Kopie eines Primitivs mit allen Farbfeldern auf `GREYED_COLOR` umgefärbt. */ +export function greyedCopy(p: T): T { + const q: any = { ...p }; + if ("fill" in q) q.fill = greyColor(q.fill); + if ("stroke" in q) q.stroke = greyColor(q.stroke); + if ("color" in q) q.color = greyColor(q.color); + if ("hatch" in q && q.hatch) q.hatch = { ...q.hatch, color: greyColor(q.hatch.color) }; + return q; +} + +/** Zeichnet einen gebündelten 2D-Zeichen-Zug als EIN Element (gehrte Ecken). */ +export function DrawingRunShape({ + run, + toScreen, + hairline, + paperScale, +}: { + run: DrawingRun; + toScreen: (v: Vec2) => Vec2; + hairline: boolean; + paperScale: number | null; +}) { + const print = !hairline && paperScale != null && paperScale > 0; + const sw = hairline + ? HAIRLINE_PX + : print + ? printStrokeVb(quantizePen(run.weightMm), paperScale!) + : mmToPx(run.weightMm); + const vfx = print ? undefined : ("non-scaling-stroke" as const); + const dashStr = + run.dash && run.dash.length + ? run.dash + .map((mm) => (print ? printStrokeVb(mm, paperScale!) : mmToPx(mm))) + .join(" ") + : undefined; + // Zickzack-/Custom-Motiv-Lauf: im MODELL-Raum tessellieren (Papier-mm → + // Modell-Meter via DASH_MM_TO_M), dann toScreen. Sonst die Roh-Stützpunkte. + const modelPts = run.zigzag + ? zigzagPoints( + run.pts[0], + run.pts[run.pts.length - 1], + run.zigzag.amplitude * DASH_MM_TO_M, + run.zigzag.wavelength * DASH_MM_TO_M, + ) + : run.motif + ? motifPoints(run.pts[0], run.pts[run.pts.length - 1], run.motif, DASH_MM_TO_M) + : run.pts; + const pts = modelPts.map(toScreen).map((s) => `${s.x},${s.y}`).join(" "); + // Punktmuster (dash enthält eine 0): runde Kappe, damit die 0-Längen-Segmente + // als Dots erscheinen (Punktlinie / Strich-Punkt). + const cap = dashHasDot(run.dash) ? ("round" as const) : undefined; + const stroke = run.greyed ? greyColor(run.color) : run.color; + const shape = run.closed ? ( + + ) : ( + + ); + return shape; +} + +export function PrimitiveShape({ + p, + index, + toScreen, + hairline, + paperScale, +}: { + p: Primitive; + index: number; + toScreen: (v: Vec2) => Vec2; + hairline: boolean; + paperScale: number | null; +}) { + // Gedimmte Elemente werden auf den festen Grauton umgefärbt (Strich, Füllung + // UND Schraffur) — deutlich erkennbar unabhängig von Papierfarbe/Linienstärke. + const shape = renderPrimitive(p.greyed ? greyedCopy(p) : p, index, toScreen, hairline, paperScale); + return shape; +} + +/** Erzeugt das reine SVG eines Primitivs (ohne Dimm-Hülle). */ +export function renderPrimitive( + p: Primitive, + index: number, + toScreen: (v: Vec2) => Vec2, + hairline: boolean, + paperScale: number | null, +) { + // Print-Modus nur, wenn ein Referenz-Massstab vorliegt; sonst (bzw. Display) + // gilt der bisherige non-scaling-Pfad. Beide Modi setzen die GLEICHEN + // Präsentationsattribute (nur Wert + vectorEffect unterscheiden sich). + const print = !hairline && paperScale != null && paperScale > 0; + // Strichstärke: + // • Display → konstante Haarlinie (Bildschirm-px), papierunabhängig. + // • Print → echte Papier-mm, VORHER auf die PEN_STEPS-Stiftstufe + // gequantelt (identisch zum PDF-Export, `quantizePen` aus + // `sceneToPrintSvg.ts`), dann in viewBox-Einheiten (skaliert mit dem + // Zoom) — so zeigt die Vorschau exakt die Strichbreite, die auch gedruckt + // wird. + const weight = (mm: number): number => + hairline ? HAIRLINE_PX : print ? printStrokeVb(quantizePen(mm), paperScale!) : mmToPx(mm); + // vectorEffect: non-scaling NUR, wenn NICHT im Print-Modus (Print skaliert die + // Linie bewusst mit der Geometrie). undefined ⇒ Attribut entfällt. + const vfx = print ? undefined : ("non-scaling-stroke" as const); + // Strichmuster: im Print-Modus ebenfalls in viewBox-Einheiten (skaliert mit), + // sonst wie bisher in Bildschirm-px (non-scaling). + const dashOf = (dash: number[] | null | undefined): string | undefined => { + if (!dash || !dash.length) return undefined; + return dash + .map((mm) => (print ? printStrokeVb(mm, paperScale!) : mmToPx(mm))) + .join(" "); + }; + switch (p.kind) { + case "polygon": { + const scr = p.pts.map(toScreen); + const pts = scr.map((s) => `${s.x},${s.y}`).join(" "); + // Umriss-Strichstärke in mm Papier (Display: konstante px / Print: skaliert). + const sw = weight(p.strokeWidthMm); + // Innere Gehrungs-Stirnkanten am Wandknoten (noStrokeEdges) NICHT stricheln: + // die Füllung bleibt das volle Polygon, aber die Diagonale am Knoten (Naht) + // und die überschießende Eck-Barbe entfallen. Sichtbare Kanten werden als + // zusammenhängende offene -Läufe gezeichnet (keine Gehrung über + // die weggelassene Kante). Leeres/kein noStrokeEdges ⇒ voller Umriss. + const noStroke = p.noStrokeEdges; + const outline = () => { + if (!noStroke || noStroke.length === 0) { + return ( + + ); + } + const runs = visibleEdgeRuns(scr, noStroke); + return ( + <> + {runs.map((run, ri) => ( + `${s.x},${s.y}`).join(" ")} + fill="none" + stroke={p.stroke} + strokeWidth={sw} + vectorEffect={vfx} + /> + ))} + + ); + }; + // Bild-Schraffur: Grundfüllung + gekacheltes Bild-Muster ( aus + // , id=hatch-index) + Umriss — unabhängig vom `pattern`-Feld. + if (p.hatch.kind === "image" && p.hatch.image) { + return ( + + + + {outline()} + + ); + } + // Random-Vektor-Schraffur (Kies/Splitt): Grundfüllung + deterministische + // Streu-Striche (im MODELL-Raum erzeugt → toScreen; identischer Seed wie + // GL/PDF) + Umriss. KEIN (die Striche werden direkt gezeichnet). + if (p.hatch.kind !== "image" && p.hatch.lines === "random") { + const hsw = weight(p.hatch.lineWeight > 0 ? p.hatch.lineWeight : 0.13); + const runs = buildRandomHatchRuns(p.pts, p.hatch); + return ( + + + {runs.map((run, ri) => ( + `${s.x},${s.y}`).join(" ")} + fill="none" + stroke={p.hatch.color} + strokeWidth={hsw} + vectorEffect={vfx} + /> + ))} + {outline()} + + ); + } + // Ohne Schraffur: reine Component-Füllung bzw. „none" (nur Umriss). + if (p.hatch.pattern === "none") { + return ( + + + {outline()} + + ); + } + // Vollfüllung: Schraffurfarbe deckt die Component-Füllung (Poché). + if (p.hatch.pattern === "solid") { + return ( + + + {outline()} + + ); + } + // Linien-/Kreuz-Schraffur: erst Grundfüllung, dann Muster, dann Umriss. + return ( + + + + {outline()} + + ); + } + case "line": { + const a = toScreen(p.a); + const b = toScreen(p.b); + // Zickzack-Linie: als Polylinie (im MODELL-Raum tesselliert → toScreen; + // amplitude/wavelength Papier-mm → Modell-Meter via DASH_MM_TO_M, wie + // GL/PDF). ADDITIV — gerade/gestrichelte Linien bleiben unverändert. + if (p.zigzag) { + const zpts = zigzagPoints( + p.a, + p.b, + p.zigzag.amplitude * DASH_MM_TO_M, + p.zigzag.wavelength * DASH_MM_TO_M, + ).map(toScreen); + return ( + `${s.x},${s.y}`).join(" ")} + className={p.cls} + fill="none" + stroke={p.color} + strokeWidth={weight(p.weightMm)} + vectorEffect={vfx} + /> + ); + } + // Custom-Motiv: gekachelt im MODELL-Raum (Papier-mm → Modell-Meter), analog + // Zickzack. ADDITIV — gerade/gestrichelte Linien bleiben unverändert. + if (p.motif) { + const mpts = motifPoints(p.a, p.b, p.motif, DASH_MM_TO_M).map(toScreen); + return ( + `${s.x},${s.y}`).join(" ")} + className={p.cls} + fill="none" + stroke={p.color} + strokeWidth={weight(p.weightMm)} + vectorEffect={vfx} + /> + ); + } + // Strichstärke + Strichmuster in mm Papier. Die Farbe kommt weiterhin aus + // der CSS-Klasse (z. B. .door-leaf / .wall-axis). + return ( + + ); + } + case "arc": { + const c = toScreen(p.center); + const from = toScreen(p.from); + const to = toScreen(p.to); + const r = p.r * PX_PER_M; + // Kurzer Weg (≤180°): large-arc = 0. Drehrichtung aus Kreuzprodukt + // im Bildschirmraum (y nach unten). + const v1 = { x: from.x - c.x, y: from.y - c.y }; + const v2 = { x: to.x - c.x, y: to.y - c.y }; + const cross = v1.x * v2.y - v1.y * v2.x; + const sweep = cross > 0 ? 1 : 0; + const d = `M ${from.x} ${from.y} A ${r} ${r} 0 0 ${sweep} ${to.x} ${to.y}`; + return ( + + ); + } + case "text": { + const c = toScreen(p.at); + // Punkt → viewBox-Einheiten. Physisch: basePt pt bei Massstab 1:N belegt + // basePt/72·0.0254·N Modell-Meter; im Display-Modus gilt der Referenz- + // massstab 1:100 (der Stempel skaliert dann mit dem Zoom, am Modell + // verankert). unitPerPt bildet 1 pt auf viewBox-Einheiten ab. + const nRef = print ? paperScale! : 100; + const unitPerPt = (1 / 72) * 0.0254 * nRef * PX_PER_M; + const baseFs = p.basePt * unitPerPt; + const lineGap = baseFs * 1.3; + // Rich-Text-Zeilen (Stempel-Doc) + LIVE-Zusatzzeilen (Fläche/SIA) darunter. + const docLines: SvgLine[] = docToLines(p.doc, { + x: 0, + y: 0, + lineHeight: lineGap, + unitPerPt, + basePt: p.basePt, + color: p.color, + }); + const extra: SvgLine[] = p.extraLines.map((line) => ({ + align: line.align, + lineHeight: lineGap, + tspans: [{ text: line.text, fontSize: baseFs * 0.8, fill: p.color }], + })); + const lines = [...docLines, ...extra]; + if (lines.length === 0) return null; + // Block vertikal um den Anker zentrieren. Absätze können eine eigene + // Zeilenhöhe tragen (Text-Gruppe, Zeilenhöhe-Regler) — darum kumulierte + // Vorschübe statt eines einzigen festen lineGap. + const totalH = baseFs + lines.slice(1).reduce((sum, l) => sum + l.lineHeight, 0); + const startY = c.y - totalH / 2 + baseFs * 0.8; + const lineYs: number[] = []; + { + let y = startY; + lines.forEach((line, k) => { + if (k > 0) y += line.lineHeight; + lineYs.push(y); + }); + } + return ( + + {lines.map((line, k) => { + const anchor = + line.align === "center" + ? "middle" + : line.align === "right" + ? "end" + : "start"; + return ( + + {line.tspans.map((ts, j) => ( + + {ts.text} + + ))} + + ); + })} + + ); + } + case "drawingImage": { + // Eingebettetes Rasterbild (Foto/Logo/gerasterte PDF-Seite), achsparallel. + // Screen-Y ist gespiegelt (toScreen) → oben-links = (min.x, max.y). + const topLeft = toScreen(p.max); + const w = (p.max.x - p.min.x) * PX_PER_M; + const h = (p.max.y - p.min.y) * PX_PER_M; + if (w <= 0 || h <= 0) return null; + return ( + + ); + } + case "drawingText": { + // Modellverankerter Einzeltext (DXF-Import / Text-Werkzeug): Höhe in Metern → + // viewBox-Einheiten (skaliert mit dem Zoom), Baseline am Ankerpunkt (links). + const c = toScreen(p.at); + const fontSize = p.heightM * PX_PER_M; + // Modell-Winkel CCW; Screen-Y ist gespiegelt und SVG-rotate CW-positiv → -deg. + const deg = -(p.angle * 180) / Math.PI; + + // Reiches Textfeld (InDesign-artig, mehrere Absätze/gemischte Formatierung): + // hat Vorrang vor dem Alt-Pfad (einfacher String + einheitliche `marks`) + // weiter unten. Bricht bei gesetzter Spaltenbreite wortweise um (Run-Grenzen + // bleiben erhalten, s. `wrapDocToLines`). + if (p.doc) { + const unitPerPt = (0.0254 / 72) * PX_PER_M; + const basePt = (p.heightM / 0.0254) * 72; + const lineHeight = fontSize * 1.25; + const wrapWidthUnits = p.wrapWidth && p.wrapWidth > 0 ? p.wrapWidth * PX_PER_M : undefined; + const docLines: SvgLine[] = wrapDocToLines(p.doc, { + x: 0, + y: 0, + lineHeight, + unitPerPt, + basePt, + color: p.color, + ...(wrapWidthUnits ? { wrapWidthUnits } : {}), + }); + if (docLines.length === 0) return null; + const lineYs: number[] = []; + { + let y = c.y; + docLines.forEach((line, k) => { + if (k > 0) y += line.lineHeight; + lineYs.push(y); + }); + } + return ( + + {docLines.map((line, k) => { + const anchor = + line.align === "center" ? "middle" : line.align === "right" ? "end" : "start"; + const x = !wrapWidthUnits + ? c.x + : line.align === "center" + ? c.x + wrapWidthUnits / 2 + : line.align === "right" + ? c.x + wrapWidthUnits + : c.x; + return ( + + {line.tspans.length === 0 + ? "​" + : line.tspans.map((ts, j) => ( + + {ts.text} + + ))} + + ); + })} + + ); + } + + // Alt-Pfad: einfacher String + einheitliche `marks` (Text-Werkzeug/DXF-Import + // ohne Rich-Doc) — unverändert. + // Spaltentext: bei gesetzter `wrapWidth` den Text wortweise auf die Breite + // umbrechen (Zeichenbreite ≈ 0.55·fontSize geschätzt), sonst eine Zeile. + const lineH = fontSize * 1.25; + const lines: string[] = []; + if (p.wrapWidth && p.wrapWidth > 0) { + const maxPx = p.wrapWidth * PX_PER_M; + const charPx = fontSize * 0.55; + const maxChars = Math.max(1, Math.floor(maxPx / charPx)); + for (const paragraph of p.text.split("\n")) { + let line = ""; + for (const word of paragraph.split(/\s+/)) { + if (line === "") line = word; + else if ((line.length + 1 + word.length) <= maxChars) line += " " + word; + else { lines.push(line); line = word; } + } + lines.push(line); + } + } else { + lines.push(p.text); + } + // Einheitliche Formatierung (Text-Werkzeug + Oberleisten-Formatier-Gruppe): + // Schriftfamilie/fett/kursiv/Unterstreichung aus `marks`; Grösse bleibt über + // heightM (Modell-Meter). Farbe steckt bereits in p.color (marks.color-Vorrang + // in generatePlan aufgelöst). + const mk = p.marks; + const deco = + mk && (mk.underline || mk.strike) + ? [mk.underline ? "underline" : "", mk.strike ? "line-through" : ""].join(" ").trim() + : undefined; + return ( + + {lines.length <= 1 ? ( + p.text + ) : ( + lines.map((ln, li) => ( + + {ln === "" ? "​" : ln} + + )) + )} + + ); + } + case "drawingCircle": { + // Echtes `` — rund bei jedem Zoom (kein Vieleck). Füllung optional. + const c = toScreen(p.center); + const r = p.r * PX_PER_M; + return ( + + ); + } + case "drawingArc": { + // Glatter SVG-Bogen. Winkel in Radiant (CCW im Modell); Screen-Y ist + // gespiegelt → ein modell-CCW-Bogen läuft am Bildschirm im Uhrzeigersinn + // (SVG sweep-flag 1). large-arc aus der Spannweite (>π). + const TAU = Math.PI * 2; + const rPx = p.r * PX_PER_M; + const from = toScreen({ + x: p.center.x + p.r * Math.cos(p.a0), + y: p.center.y + p.r * Math.sin(p.a0), + }); + const to = toScreen({ + x: p.center.x + p.r * Math.cos(p.a1), + y: p.center.y + p.r * Math.sin(p.a1), + }); + const sweep = (((p.a1 - p.a0) % TAU) + TAU) % TAU; + const largeArc = sweep > Math.PI ? 1 : 0; + const d = `M ${from.x} ${from.y} A ${rPx} ${rPx} 0 ${largeArc} 1 ${to.x} ${to.y}`; + return ( + + ); + } + } +}