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