PlanView.tsx aufgeteilt: Mathe/Overlays/Primitiv-Rendering nach src/plan/planView/.

Reine Verschiebung, keine Verhaltensänderung. PlanView.tsx (4169 → 2465
Zeilen) behält nur noch die Komponente selbst + Interaktions-Logik
(Maus/Tastatur/Griffe/Snapping/Undo).

- planView/geometry.ts: ViewBox, toScreen, Papier-Massstab-Umrechnung
  (meetScale/scaleFromView/viewForScale/printStrokeVb), Marquee-/Punkt-
  in-Polygon-Tests (pointInPolygon, marqueeHit, marqueeHitDrawings).
- planView/primitives.tsx: HatchPattern, buildDrawingRuns, DrawingRunShape,
  PrimitiveShape, renderPrimitive (der grosse Primitiv→SVG-Renderer).
- planView/overlays.tsx: DraftOverlay (Werkzeug-Vorschau, Cursor-HUD,
  Winkel-Führungslinie, Snap-Marker).
- planView/PlanRulers.tsx: die Lineale oben/links.

tsc -b sauber, vitest 891/891 grün, vite build ok.
This commit is contained in:
2026-08-19 22:54:58 +02:00
parent 1507c2bf00
commit 528f213f1f
5 changed files with 1772 additions and 1730 deletions
+25 -1730
View File
File diff suppressed because it is too large Load Diff
+136
View File
@@ -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 (
<g className="plan-ruler" pointerEvents="none">
{/* Leisten + Trennlinien. */}
<rect className="plan-ruler-strip" x={x0} y={y0} width={vb.w} height={TH} />
<rect className="plan-ruler-strip" x={x0} y={y0} width={TH} height={vb.h} />
<line className="plan-ruler-border" x1={x0} y1={y0 + TH} x2={x0 + vb.w} y2={y0 + TH} />
<line className="plan-ruler-border" x1={x0 + TH} y1={y0} x2={x0 + TH} y2={y0 + vb.h} />
{/* Oben: Ticks + Beschriftung. */}
{xTicks.map((t, i) =>
t.vbX < x0 + TH ? null : (
<g key={`x${i}`}>
<line
className="plan-ruler-tick"
x1={t.vbX}
y1={y0 + (t.major ? TH * 0.45 : TH * 0.72)}
x2={t.vbX}
y2={y0 + TH}
/>
{t.major && (
<text className="plan-ruler-text" x={t.vbX + 3 * vbPerPx} y={y0 + TH * 0.42} fontSize={font}>
{label(t.model)}
</text>
)}
</g>
),
)}
{/* Links: Ticks + gedrehte Beschriftung. */}
{yTicks.map((t, i) =>
t.vbY < y0 + TH ? null : (
<g key={`y${i}`}>
<line
className="plan-ruler-tick"
x1={x0 + (t.major ? TH * 0.45 : TH * 0.72)}
y1={t.vbY}
x2={x0 + TH}
y2={t.vbY}
/>
{t.major && (
<text
className="plan-ruler-text"
x={x0 + TH * 0.42}
y={t.vbY - 3 * vbPerPx}
fontSize={font}
transform={`rotate(-90 ${x0 + TH * 0.42} ${t.vbY - 3 * vbPerPx})`}
>
{label(t.model)}
</text>
)}
</g>
),
)}
{/* Cursor-Marker (VW-gelb) je Achse. */}
{cursor && (
<>
<rect
className="plan-ruler-cursor"
x={cursor.x * PX_PER_M - 1 * vbPerPx}
y={y0}
width={2 * vbPerPx}
height={TH}
/>
<rect
className="plan-ruler-cursor"
x={x0}
y={-cursor.y * PX_PER_M - 1 * vbPerPx}
width={TH}
height={2 * vbPerPx}
/>
</>
)}
{/* Ecke oben links. */}
<rect className="plan-ruler-corner" x={x0} y={y0} width={TH} height={TH} />
</g>
);
}
+355
View File
@@ -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<string, Vec2[][]>();
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<string, Vec2[]>();
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;
}
+388
View File
@@ -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 (
<g className="tool-overlay" pointerEvents="none">
{/* Vorschau-Formen. */}
{draft.preview.map((s, i) =>
s.kind === "line" ? (
<line
key={`p${i}`}
className="tool-preview-line"
x1={toScreen(s.a).x}
y1={toScreen(s.a).y}
x2={toScreen(s.b).x}
y2={toScreen(s.b).y}
vectorEffect="non-scaling-stroke"
/>
) : (
<polygon
key={`p${i}`}
className="tool-preview-fill"
points={s.pts.map(toScreen).map((p) => `${p.x},${p.y}`).join(" ")}
vectorEffect="non-scaling-stroke"
/>
),
)}
{/* Gesetzte Stützpunkte. */}
{draft.vertices.map((v, i) => {
const s = toScreen(v);
return (
<rect
key={`v${i}`}
className="tool-vertex"
x={s.x - vertSize}
y={s.y - vertSize}
width={vertSize * 2}
height={vertSize * 2}
vectorEffect="non-scaling-stroke"
/>
);
})}
{/* 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 && (
<AngleGuide
from={draft.snap.refA}
point={draft.snap.point}
angleDeg={draft.snap.angleDeg}
toScreen={toScreen}
vbPerPx={vbPerPx}
/>
)}
{/* Snap-Marker + optionale Ortho-Hilfslinie. */}
{draft.snap && (
<SnapMarker snap={draft.snap} toScreen={toScreen} size={markSize} color={snapColor} />
)}
{/* Maß-/Winkel-HUD am Cursor (VW-Stil `L: … W: …`, oder einfacher Text). */}
{draft.hud && <DraftHud hud={draft.hud} toScreen={toScreen} vbPerPx={vbPerPx} hudFields={hudFields} />}
</g>
);
}
/**
* 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<ToolDraft["hud"]>;
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 (
<g className="plan-hud" pointerEvents="none">
<rect className="plan-hud-box" x={x} y={y} width={w} height={h} rx={6 * vbPerPx} />
{segs.map((s, i) => {
const sx = cx;
cx += widths[i] + gap;
return (
<g key={i}>
{s.active && (
<rect
className="plan-hud-active"
x={sx - 2 * vbPerPx}
y={y + 1.5 * vbPerPx}
width={widths[i] + 4 * vbPerPx}
height={h - 3 * vbPerPx}
rx={5 * vbPerPx}
/>
)}
<text
className={
"plan-hud-text" + (s.active ? " active" : "") + (s.locked ? " locked" : "")
}
x={sx}
y={y + h / 2}
fontSize={fontSize}
dominantBaseline="middle"
>
{s.text}
</text>
</g>
);
})}
</g>
);
}
/**
* 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 (
<g className="plan-angle-guide" pointerEvents="none">
<line
className="plan-angle-guide-line"
x1={a.x}
y1={a.y}
x2={end.x}
y2={end.y}
vectorEffect="non-scaling-stroke"
/>
<line
className="plan-angle-ref-line"
x1={a.x}
y1={a.y}
x2={refEnd.x}
y2={refEnd.y}
vectorEffect="non-scaling-stroke"
/>
{len > 24 * vbPerPx && Math.abs(screenAng) > 0.02 && (
<path className="plan-angle-arc" d={arcPath} vectorEffect="non-scaling-stroke" />
)}
<rect
className="plan-angle-badge-box"
x={badgeAt.x - w / 2}
y={badgeAt.y - h / 2}
width={w}
height={h}
rx={6 * vbPerPx}
/>
<text
className="plan-angle-badge-text"
x={badgeAt.x}
y={badgeAt.y}
fontSize={fontSize}
textAnchor="middle"
dominantBaseline="middle"
>
{text}
</text>
</g>
);
}
/** 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 (
<g className="snap-marker">
{(snap.kind === "ortho" || snap.kind === "extension") && snap.refA && (
<line
className="snap-helper-line"
x1={toScreen(snap.refA).x}
y1={toScreen(snap.refA).y}
x2={s.x}
y2={s.y}
style={helperStyle}
vectorEffect="non-scaling-stroke"
/>
)}
{snap.kind === "grid" && (
<circle
className="snap-glyph snap-fill"
cx={s.x}
cy={s.y}
r={r * 0.5}
style={fillStyle}
vectorEffect="non-scaling-stroke"
/>
)}
{snap.kind === "midpoint" && (
<line
className="snap-glyph"
x1={s.x - r}
y1={s.y}
x2={s.x + r}
y2={s.y}
style={glyphStyle}
vectorEffect="non-scaling-stroke"
/>
)}
{snap.kind === "intersection" && (
<>
<line className="snap-glyph" x1={s.x - r} y1={s.y - r} x2={s.x + r} y2={s.y + r} style={glyphStyle} vectorEffect="non-scaling-stroke" />
<line className="snap-glyph" x1={s.x - r} y1={s.y + r} x2={s.x + r} y2={s.y - r} style={glyphStyle} vectorEffect="non-scaling-stroke" />
</>
)}
{(snap.kind === "center" || snap.kind === "quadrant") && (
<circle className="snap-glyph" cx={s.x} cy={s.y} r={r} style={glyphStyle} vectorEffect="non-scaling-stroke" />
)}
{snap.kind === "onEdge" && (
<polygon
className="snap-glyph"
points={`${s.x},${s.y - r} ${s.x + r},${s.y} ${s.x},${s.y + r} ${s.x - r},${s.y}`}
style={glyphStyle}
vectorEffect="non-scaling-stroke"
/>
)}
{(snap.kind === "endpoint" || snap.kind === "ortho" || snap.kind === "extension") && (
<circle
className="snap-glyph"
cx={s.x}
cy={s.y}
r={r}
style={glyphStyle}
vectorEffect="non-scaling-stroke"
/>
)}
</g>
);
}
+868
View File
@@ -0,0 +1,868 @@
// Reine SVG-Darstellung der Plan-Primitive: Schraffur-<pattern>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 <pattern>; solid/none nicht. */
export function needsPattern(pattern: HatchRender["pattern"]): boolean {
return (
pattern === "insulation" ||
pattern === "diagonal" ||
pattern === "crosshatch"
);
}
/**
* Ob ein Polygon ein SVG-`<pattern>` (in `<defs>`) 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-<pattern>. 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 <image>-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 (
<pattern
id={id}
patternUnits="userSpaceOnUse"
width={w}
height={h}
patternTransform={img.rotation ? `rotate(${img.rotation})` : undefined}
>
<image href={img.src} x={0} y={0} width={w} height={h} preserveAspectRatio="none" />
</pattern>
);
}
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 (
<pattern
id={id}
patternUnits="userSpaceOnUse"
width={w}
height={h}
patternTransform={`rotate(${hatch.angle})`}
>
<path
d={`M0 ${my} Q ${qx1} ${qy1} ${qx2} ${my} Q ${qx3} ${qy2} ${w} ${my}`}
fill="none"
stroke={hatch.color}
strokeWidth={sw}
strokeDasharray={dashes}
strokeLinecap={dashCap}
vectorEffect={vfx}
/>
</pattern>
);
}
// 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 (
<pattern
id={id}
patternUnits="userSpaceOnUse"
width={tile}
height={tile}
patternTransform={`rotate(${hatch.angle})`}
>
<line
x1={0}
y1={0}
x2={0}
y2={tile}
stroke={hatch.color}
strokeWidth={sw}
strokeDasharray={dashes}
strokeLinecap={dashCap}
vectorEffect={vfx}
/>
{hatch.pattern === "crosshatch" && (
<line
x1={0}
y1={0}
x2={tile}
y2={0}
stroke={hatch.color}
strokeWidth={sw}
strokeDasharray={dashes}
strokeLinecap={dashCap}
vectorEffect={vfx}
/>
)}
</pattern>
);
}
/**
* 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<Primitive, { kind: "line" }>,
b: Extract<Primitive, { kind: "line" }>,
): 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<Primitive, { kind: "line" }> | 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<T extends Primitive>(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 ? (
<polygon
points={pts}
className={run.cls}
fill="none"
stroke={stroke}
strokeWidth={sw}
strokeDasharray={dashStr}
strokeLinecap={cap}
vectorEffect={vfx}
/>
) : (
<polyline
points={pts}
className={run.cls}
fill="none"
stroke={stroke}
strokeWidth={sw}
strokeDasharray={dashStr}
strokeLinecap={cap}
vectorEffect={vfx}
/>
);
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 <polyline>-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 (
<polygon points={pts} fill="none" stroke={p.stroke} strokeWidth={sw} vectorEffect={vfx} />
);
}
const runs = visibleEdgeRuns(scr, noStroke);
return (
<>
{runs.map((run, ri) => (
<polyline
key={`e${ri}`}
points={run.map((s) => `${s.x},${s.y}`).join(" ")}
fill="none"
stroke={p.stroke}
strokeWidth={sw}
vectorEffect={vfx}
/>
))}
</>
);
};
// Bild-Schraffur: Grundfüllung + gekacheltes Bild-Muster (<pattern> aus
// <defs>, id=hatch-index) + Umriss — unabhängig vom `pattern`-Feld.
if (p.hatch.kind === "image" && p.hatch.image) {
return (
<g>
<polygon points={pts} fill={p.fill} stroke="none" />
<polygon points={pts} fill={`url(#hatch-${index})`} stroke="none" />
{outline()}
</g>
);
}
// Random-Vektor-Schraffur (Kies/Splitt): Grundfüllung + deterministische
// Streu-Striche (im MODELL-Raum erzeugt → toScreen; identischer Seed wie
// GL/PDF) + Umriss. KEIN <pattern> (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 (
<g>
<polygon points={pts} fill={p.fill} stroke="none" />
{runs.map((run, ri) => (
<polyline
key={`r${ri}`}
points={run.map(toScreen).map((s) => `${s.x},${s.y}`).join(" ")}
fill="none"
stroke={p.hatch.color}
strokeWidth={hsw}
vectorEffect={vfx}
/>
))}
{outline()}
</g>
);
}
// Ohne Schraffur: reine Component-Füllung bzw. „none" (nur Umriss).
if (p.hatch.pattern === "none") {
return (
<g>
<polygon points={pts} fill={p.fill} stroke="none" />
{outline()}
</g>
);
}
// Vollfüllung: Schraffurfarbe deckt die Component-Füllung (Poché).
if (p.hatch.pattern === "solid") {
return (
<g>
<polygon points={pts} fill={p.hatch.color} stroke="none" />
{outline()}
</g>
);
}
// Linien-/Kreuz-Schraffur: erst Grundfüllung, dann Muster, dann Umriss.
return (
<g>
<polygon points={pts} fill={p.fill} stroke="none" />
<polygon points={pts} fill={`url(#hatch-${index})`} stroke="none" />
{outline()}
</g>
);
}
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 (
<polyline
points={zpts.map((s) => `${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 (
<polyline
points={mpts.map((s) => `${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 (
<line
x1={a.x}
y1={a.y}
x2={b.x}
y2={b.y}
className={p.cls}
stroke={p.color}
strokeWidth={weight(p.weightMm)}
strokeDasharray={dashOf(p.dash)}
strokeLinecap={dashHasDot(p.dash) ? "round" : undefined}
vectorEffect={vfx}
/>
);
}
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 (
<path
d={d}
className={p.cls}
strokeWidth={weight(p.weightMm)}
strokeDasharray={dashOf(p.dash)}
strokeLinecap={dashHasDot(p.dash) ? "round" : undefined}
vectorEffect={vfx}
/>
);
}
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 (
<text style={{ pointerEvents: "none", userSelect: "none" }}>
{lines.map((line, k) => {
const anchor =
line.align === "center"
? "middle"
: line.align === "right"
? "end"
: "start";
return (
<tspan
key={k}
x={c.x}
y={lineYs[k]}
textAnchor={anchor}
>
{line.tspans.map((ts, j) => (
<tspan
key={j}
fontSize={ts.fontSize}
fontWeight={ts.fontWeight}
fontStyle={ts.fontStyle}
fontFamily={ts.fontFamily ?? "var(--font-ui, sans-serif)"}
fill={ts.fill}
textDecoration={ts.textDecoration}
>
{ts.text}
</tspan>
))}
</tspan>
);
})}
</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 (
<image
href={p.src}
x={toScreen(p.min).x}
y={topLeft.y}
width={w}
height={h}
opacity={(p.greyed ? 0.45 : 1) * p.opacity}
preserveAspectRatio="none"
/>
);
}
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 (
<text
opacity={p.greyed ? 0.45 : undefined}
transform={deg ? `rotate(${deg} ${c.x} ${c.y})` : undefined}
style={{ pointerEvents: "none", userSelect: "none" }}
>
{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 (
<tspan key={k} x={x} y={lineYs[k]} textAnchor={anchor}>
{line.tspans.length === 0
? "​"
: line.tspans.map((ts, j) => (
<tspan
key={j}
fontSize={ts.fontSize}
fontWeight={ts.fontWeight}
fontStyle={ts.fontStyle}
fontFamily={ts.fontFamily ?? "var(--font-ui, sans-serif)"}
fill={ts.fill}
textDecoration={ts.textDecoration}
>
{ts.text}
</tspan>
))}
</tspan>
);
})}
</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 (
<text
x={c.x}
y={c.y}
fontSize={fontSize}
fontFamily={mk?.font ?? "var(--font-ui, sans-serif)"}
fontWeight={mk?.bold ? 700 : undefined}
fontStyle={mk?.italic ? "italic" : undefined}
textDecoration={deco}
fill={p.color}
opacity={p.greyed ? 0.45 : undefined}
textAnchor="start"
transform={deg ? `rotate(${deg} ${c.x} ${c.y})` : undefined}
style={{ pointerEvents: "none", userSelect: "none" }}
>
{lines.length <= 1 ? (
p.text
) : (
lines.map((ln, li) => (
<tspan key={li} x={c.x} dy={li === 0 ? 0 : lineH}>
{ln === "" ? "​" : ln}
</tspan>
))
)}
</text>
);
}
case "drawingCircle": {
// Echtes `<circle>` — rund bei jedem Zoom (kein Vieleck). Füllung optional.
const c = toScreen(p.center);
const r = p.r * PX_PER_M;
return (
<circle
cx={c.x}
cy={c.y}
r={r}
fill={p.fill}
stroke={p.stroke}
strokeWidth={weight(p.weightMm)}
strokeDasharray={dashOf(p.dash)}
vectorEffect={vfx}
/>
);
}
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 (
<path
d={d}
fill="none"
stroke={p.stroke}
strokeWidth={weight(p.weightMm)}
strokeDasharray={dashOf(p.dash)}
strokeLinecap={dashHasDot(p.dash) ? "round" : undefined}
vectorEffect={vfx}
/>
);
}
}
}