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:
+25
-1730
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user