1d939a939a
Bündelt den über mehrere Sessions gewachsenen, uncommitteten Stand in
einem Basis-Commit, damit Folge-Features isoliert darauf aufsetzen.
Verifikation: tsc --noEmit sauber, vitest 600/600 grün.
Enthalten (Details in PENDENZEN.md ✅-Liste / HANDOVER.md):
- truck-Integration: Profil-Extrusion + Verjüngung + Boolean-CSG (csgrs),
Crate src-tauri/trucksolid, Werkzeug `extrude`, ExtrudedSolid-Modell.
- kernel2d-Port nach Rust/WASM (Phasen 1–5, Diff-Harness).
- render3d 3D-Live-Schnitt = 2D-Schnitt: geschichteter Bodenaufbau,
Prioritäts-Verschneidung (section_boolean.rs), einstellbare
Schichttrennlinien, per-Hatch-Strichstärke, relativeToWall-Orientierung.
- Interop-Export IFC4/STL/OBJ (Loch-Ausschnitt wallMeshCut), Schnellexport.
- Projektdatei .obp + OS-Lock (lock.rs, LockConflictDialog).
- Layout-Blätter (Modell/Editor/Panel/PDF), Ausschnitte, Override-Engine,
Tragwerk-Stützen (Column), BIM-Tree-Panel.
- Bauteil-Typsystem (Tür/Fenster/Treppe-Typen), Betontreppe mit schräger
Laufplatte, Text-/Textbox-Werkzeug, Mess-Werkzeug, 2D/3D-Griffe für
Öffnungen/Treppen, Snap-Symbol-Restyle.
430 lines
13 KiB
TypeScript
430 lines
13 KiB
TypeScript
// Reine, testbare Geometrie für die In-Viewport-Layout-Ansicht (Phase 2b):
|
|
// Bildschirm↔Blatt-mm-Transform (Pan/Zoom-to-Cursor/Einpassen), Viewport-
|
|
// Trefferlogik (Body + 8 Resize-Griffe), Resize-Mathe mit Mindestgrösse,
|
|
// Rechteck-Normalisierung (Aufziehen) und Clamp an die Blattkanten.
|
|
//
|
|
// KEINE React-/DOM-Abhängigkeit — nur Zahlen. Das UI (LayoutSheet.tsx) baut
|
|
// darauf auf. Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
|
|
|
import type { LayoutAnnotation, LayoutViewport } from "../model/types";
|
|
|
|
/**
|
|
* Affine Blatt-Transform: Bildschirm-Pixel = mm · scale + translate.
|
|
* `scale` ist px pro mm (immer > 0), `tx/ty` die Blatt-Ursprungslage (mm 0,0)
|
|
* in Canvas-Pixeln.
|
|
*/
|
|
export interface SheetTransform {
|
|
/** Pixel pro Millimeter (> 0). */
|
|
scale: number;
|
|
/** Bildschirm-X des Blatt-Ursprungs (mm x=0) in Canvas-Pixeln. */
|
|
tx: number;
|
|
/** Bildschirm-Y des Blatt-Ursprungs (mm y=0) in Canvas-Pixeln. */
|
|
ty: number;
|
|
}
|
|
|
|
/** Ein Punkt in Blatt-Millimetern. */
|
|
export interface Mm {
|
|
xMm: number;
|
|
yMm: number;
|
|
}
|
|
|
|
/** Ein achsparalleles Rechteck in Blatt-Millimetern. */
|
|
export interface RectMm {
|
|
xMm: number;
|
|
yMm: number;
|
|
widthMm: number;
|
|
heightMm: number;
|
|
}
|
|
|
|
/** Resize-Griff (8 Ränder) oder Körper (verschieben) eines Viewports. */
|
|
export type Handle =
|
|
| "nw"
|
|
| "n"
|
|
| "ne"
|
|
| "e"
|
|
| "se"
|
|
| "s"
|
|
| "sw"
|
|
| "w"
|
|
| "body";
|
|
|
|
export const MIN_SCALE = 0.05;
|
|
export const MAX_SCALE = 40;
|
|
|
|
function clamp(v: number, lo: number, hi: number): number {
|
|
return v < lo ? lo : v > hi ? hi : v;
|
|
}
|
|
|
|
// ── Transform ────────────────────────────────────────────────────────────────
|
|
|
|
/** Blatt-mm → Canvas-Pixel. */
|
|
export function mmToScreen(t: SheetTransform, p: Mm): { x: number; y: number } {
|
|
return { x: p.xMm * t.scale + t.tx, y: p.yMm * t.scale + t.ty };
|
|
}
|
|
|
|
/** Canvas-Pixel → Blatt-mm. */
|
|
export function screenToMm(t: SheetTransform, sx: number, sy: number): Mm {
|
|
return { xMm: (sx - t.tx) / t.scale, yMm: (sy - t.ty) / t.scale };
|
|
}
|
|
|
|
/**
|
|
* Einpass-Transform: Blatt (mm) zentriert in die Canvas-Fläche (px), mit
|
|
* `margin` (Anteil, z. B. 0.92 = 8 % Rand). Degenerierte Eingaben → scale=1.
|
|
*/
|
|
export function fitTransform(
|
|
sheetWMm: number,
|
|
sheetHMm: number,
|
|
canvasWPx: number,
|
|
canvasHPx: number,
|
|
margin = 0.92,
|
|
): SheetTransform {
|
|
const sx = (canvasWPx * margin) / sheetWMm;
|
|
const sy = (canvasHPx * margin) / sheetHMm;
|
|
let scale = Math.min(sx, sy);
|
|
if (!Number.isFinite(scale) || scale <= 0) scale = 1;
|
|
scale = clamp(scale, MIN_SCALE, MAX_SCALE);
|
|
const tx = (canvasWPx - sheetWMm * scale) / 2;
|
|
const ty = (canvasHPx - sheetHMm * scale) / 2;
|
|
return { scale, tx, ty };
|
|
}
|
|
|
|
/**
|
|
* Zoom um den Cursor: der mm-Punkt unter (sx,sy) bleibt fix. `factor` > 1 zoomt
|
|
* rein, < 1 raus; der Massstab wird auf [MIN_SCALE, MAX_SCALE] geklemmt.
|
|
*/
|
|
export function zoomAtCursor(
|
|
t: SheetTransform,
|
|
factor: number,
|
|
sx: number,
|
|
sy: number,
|
|
): SheetTransform {
|
|
const newScale = clamp(t.scale * factor, MIN_SCALE, MAX_SCALE);
|
|
// mm-Punkt unter dem Cursor VOR dem Zoom.
|
|
const m = screenToMm(t, sx, sy);
|
|
// Neue Translation, damit derselbe mm-Punkt wieder unter (sx,sy) liegt.
|
|
return { scale: newScale, tx: sx - m.xMm * newScale, ty: sy - m.yMm * newScale };
|
|
}
|
|
|
|
/** Verschiebt die Transform um (dxPx,dyPx) Canvas-Pixel (Pan). */
|
|
export function panBy(t: SheetTransform, dxPx: number, dyPx: number): SheetTransform {
|
|
return { scale: t.scale, tx: t.tx + dxPx, ty: t.ty + dyPx };
|
|
}
|
|
|
|
// ── Viewport-Rechtecke / Treffer ─────────────────────────────────────────────
|
|
|
|
/** Rechteck-Sicht eines Viewports (nur die Blattgeometrie). */
|
|
export function vpRect(vp: LayoutViewport): RectMm {
|
|
return { xMm: vp.xMm, yMm: vp.yMm, widthMm: vp.widthMm, heightMm: vp.heightMm };
|
|
}
|
|
|
|
/** Punkt-in-Rechteck (mm), inklusive Rand. */
|
|
export function rectContains(r: RectMm, xMm: number, yMm: number): boolean {
|
|
return (
|
|
xMm >= r.xMm &&
|
|
xMm <= r.xMm + r.widthMm &&
|
|
yMm >= r.yMm &&
|
|
yMm <= r.yMm + r.heightMm
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Griff (oder Körper) an einem mm-Punkt für EINEN Viewport. `tolMm` ist die
|
|
* Fangzone der Ecken/Kanten (in mm). Rückgabe null, wenn ausserhalb des
|
|
* Rechtecks (+Toleranz). Ecken haben Vorrang vor Kanten, Kanten vor Körper.
|
|
*/
|
|
export function handleAt(
|
|
r: RectMm,
|
|
xMm: number,
|
|
yMm: number,
|
|
tolMm: number,
|
|
): Handle | null {
|
|
const x0 = r.xMm;
|
|
const y0 = r.yMm;
|
|
const x1 = r.xMm + r.widthMm;
|
|
const y1 = r.yMm + r.heightMm;
|
|
// Ausserhalb des um tol vergrösserten Rechtecks → kein Treffer.
|
|
if (xMm < x0 - tolMm || xMm > x1 + tolMm || yMm < y0 - tolMm || yMm > y1 + tolMm) {
|
|
return null;
|
|
}
|
|
const nearL = Math.abs(xMm - x0) <= tolMm;
|
|
const nearR = Math.abs(xMm - x1) <= tolMm;
|
|
const nearT = Math.abs(yMm - y0) <= tolMm;
|
|
const nearB = Math.abs(yMm - y1) <= tolMm;
|
|
// Innerhalb der vertikalen/horizontalen Spanne (für Kanten-Treffer).
|
|
const inX = xMm >= x0 - tolMm && xMm <= x1 + tolMm;
|
|
const inY = yMm >= y0 - tolMm && yMm <= y1 + tolMm;
|
|
// Ecken zuerst.
|
|
if (nearL && nearT) return "nw";
|
|
if (nearR && nearT) return "ne";
|
|
if (nearR && nearB) return "se";
|
|
if (nearL && nearB) return "sw";
|
|
// Kanten.
|
|
if (nearT && inX) return "n";
|
|
if (nearB && inX) return "s";
|
|
if (nearL && inY) return "w";
|
|
if (nearR && inY) return "e";
|
|
// Körper (echt innen).
|
|
if (rectContains(r, xMm, yMm)) return "body";
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Topmost-Treffer über eine Viewport-Liste (letzte = oberste, wie Z-Order der
|
|
* Darstellung). Liefert Id + Griff des ERSTEN Treffers von oben, sonst null.
|
|
*/
|
|
export function pickViewport(
|
|
viewports: LayoutViewport[],
|
|
xMm: number,
|
|
yMm: number,
|
|
tolMm: number,
|
|
): { id: string; handle: Handle } | null {
|
|
for (let i = viewports.length - 1; i >= 0; i--) {
|
|
const vp = viewports[i];
|
|
const h = handleAt(vpRect(vp), xMm, yMm, tolMm);
|
|
if (h) return { id: vp.id, handle: h };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ── Resize / Move ────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Wendet einen Griff-Drag auf ein Start-Rechteck an: `handle` bestimmt, welche
|
|
* Kanten mit (dxMm,dyMm) wandern. Körper verschiebt das ganze Rechteck. Die
|
|
* Mindestbreite/-höhe `minMm` wird eingehalten (die feste Kante bleibt fix).
|
|
*/
|
|
export function applyHandleDrag(
|
|
start: RectMm,
|
|
handle: Handle,
|
|
dxMm: number,
|
|
dyMm: number,
|
|
minMm = 5,
|
|
): RectMm {
|
|
if (handle === "body") {
|
|
return { ...start, xMm: start.xMm + dxMm, yMm: start.yMm + dyMm };
|
|
}
|
|
let { xMm, yMm, widthMm, heightMm } = start;
|
|
const movesLeft = handle === "nw" || handle === "w" || handle === "sw";
|
|
const movesRight = handle === "ne" || handle === "e" || handle === "se";
|
|
const movesTop = handle === "nw" || handle === "n" || handle === "ne";
|
|
const movesBottom = handle === "sw" || handle === "s" || handle === "se";
|
|
|
|
if (movesLeft) {
|
|
// Rechte Kante fix bei start.xMm + start.widthMm.
|
|
const right = start.xMm + start.widthMm;
|
|
let nx = start.xMm + dxMm;
|
|
if (right - nx < minMm) nx = right - minMm;
|
|
xMm = nx;
|
|
widthMm = right - nx;
|
|
} else if (movesRight) {
|
|
let nw = start.widthMm + dxMm;
|
|
if (nw < minMm) nw = minMm;
|
|
widthMm = nw;
|
|
}
|
|
|
|
if (movesTop) {
|
|
const bottom = start.yMm + start.heightMm;
|
|
let ny = start.yMm + dyMm;
|
|
if (bottom - ny < minMm) ny = bottom - minMm;
|
|
yMm = ny;
|
|
heightMm = bottom - ny;
|
|
} else if (movesBottom) {
|
|
let nh = start.heightMm + dyMm;
|
|
if (nh < minMm) nh = minMm;
|
|
heightMm = nh;
|
|
}
|
|
|
|
return { xMm, yMm, widthMm, heightMm };
|
|
}
|
|
|
|
/**
|
|
* Normalisiert ein aufgezogenes Rechteck aus zwei Eckpunkten (mm) — Min-Ecke +
|
|
* absolute Breite/Höhe. Für die „Rechteck aufziehen"-Platzierung.
|
|
*/
|
|
export function normalizeRect(
|
|
x0: number,
|
|
y0: number,
|
|
x1: number,
|
|
y1: number,
|
|
): RectMm {
|
|
return {
|
|
xMm: Math.min(x0, x1),
|
|
yMm: Math.min(y0, y1),
|
|
widthMm: Math.abs(x1 - x0),
|
|
heightMm: Math.abs(y1 - y0),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Klemmt ein Rechteck so, dass es ganz auf dem Blatt (0..sheet) liegt. Ist das
|
|
* Rechteck breiter/höher als das Blatt, wird es auf Blattgrösse gestutzt und an
|
|
* (0,0) gelegt. Sonst bleibt die Grösse erhalten und nur die Position wandert.
|
|
*/
|
|
export function clampRectToSheet(
|
|
r: RectMm,
|
|
sheetWMm: number,
|
|
sheetHMm: number,
|
|
): RectMm {
|
|
let { xMm, yMm, widthMm, heightMm } = r;
|
|
if (widthMm > sheetWMm) {
|
|
widthMm = sheetWMm;
|
|
xMm = 0;
|
|
} else {
|
|
xMm = clamp(xMm, 0, sheetWMm - widthMm);
|
|
}
|
|
if (heightMm > sheetHMm) {
|
|
heightMm = sheetHMm;
|
|
yMm = 0;
|
|
} else {
|
|
yMm = clamp(yMm, 0, sheetHMm - heightMm);
|
|
}
|
|
return { xMm, yMm, widthMm, heightMm };
|
|
}
|
|
|
|
/** CSS-Cursor je Resize-Griff (Körper = move). */
|
|
export function handleCursor(handle: Handle): string {
|
|
switch (handle) {
|
|
case "nw":
|
|
case "se":
|
|
return "nwse-resize";
|
|
case "ne":
|
|
case "sw":
|
|
return "nesw-resize";
|
|
case "n":
|
|
case "s":
|
|
return "ns-resize";
|
|
case "e":
|
|
case "w":
|
|
return "ew-resize";
|
|
default:
|
|
return "move";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Die 8 Griff-Anker eines Rechtecks in mm (für die Darstellung der Griffe).
|
|
* Reihenfolge passend zu {@link Handle} (ohne „body").
|
|
*/
|
|
export function handleAnchors(r: RectMm): { handle: Handle; xMm: number; yMm: number }[] {
|
|
const { xMm: x, yMm: y, widthMm: w, heightMm: h } = r;
|
|
const cx = x + w / 2;
|
|
const cy = y + h / 2;
|
|
return [
|
|
{ handle: "nw", xMm: x, yMm: y },
|
|
{ handle: "n", xMm: cx, yMm: y },
|
|
{ handle: "ne", xMm: x + w, yMm: y },
|
|
{ handle: "e", xMm: x + w, yMm: cy },
|
|
{ handle: "se", xMm: x + w, yMm: y + h },
|
|
{ handle: "s", xMm: cx, yMm: y + h },
|
|
{ handle: "sw", xMm: x, yMm: y + h },
|
|
{ handle: "w", xMm: x, yMm: cy },
|
|
];
|
|
}
|
|
|
|
// ── Annotationen (Linie/Rechteck/Text) ──────────────────────────────────────
|
|
// Annotationen tragen KEINE Resize-Griffe (nur verschieben + löschen) — daher
|
|
// reicht ein einfacher Treffer-Test (kein Handle-Ergebnis wie bei Viewports).
|
|
|
|
/** Kürzester Abstand (mm) eines Punkts zu einem Liniensegment. */
|
|
export function distanceToSegment(
|
|
px: number,
|
|
py: number,
|
|
x1: number,
|
|
y1: number,
|
|
x2: number,
|
|
y2: number,
|
|
): number {
|
|
const dx = x2 - x1;
|
|
const dy = y2 - y1;
|
|
const lenSq = dx * dx + dy * dy;
|
|
if (lenSq === 0) return Math.hypot(px - x1, py - y1);
|
|
const t = clamp(((px - x1) * dx + (py - y1) * dy) / lenSq, 0, 1);
|
|
const cx = x1 + t * dx;
|
|
const cy = y1 + t * dy;
|
|
return Math.hypot(px - cx, py - cy);
|
|
}
|
|
|
|
/**
|
|
* Umhüllendes Rechteck (mm) einer Annotation — für Text nur eine GROBE Box
|
|
* (kein echtes Font-Measuring im SVG; Breite aus Zeichenzahl geschätzt), reicht
|
|
* aber für Trefferlogik + Auswahl-Rahmen. `yMm` einer Text-Annotation ist die
|
|
* Baseline, die Box liegt daher oberhalb davon.
|
|
*/
|
|
export function annotationBoundsMm(ann: LayoutAnnotation): RectMm {
|
|
if (ann.kind === "rect") {
|
|
return { xMm: ann.xMm, yMm: ann.yMm, widthMm: ann.widthMm, heightMm: ann.heightMm };
|
|
}
|
|
if (ann.kind === "line") {
|
|
return normalizeRect(ann.x1Mm, ann.y1Mm, ann.x2Mm, ann.y2Mm);
|
|
}
|
|
const widthMm = Math.max(ann.heightMm, ann.text.length * ann.heightMm * 0.6);
|
|
return {
|
|
xMm: ann.xMm,
|
|
yMm: ann.yMm - ann.heightMm,
|
|
widthMm,
|
|
heightMm: ann.heightMm * 1.25,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Trefferlogik (mm) für EINE Annotation: Linie → Abstand zum Segment ≤ tolMm;
|
|
* Rechteck → Rahmen ODER Innenfläche (wiederverwendet {@link handleAt}, jeder
|
|
* Nicht-null-Rückgabewert zählt als Treffer); Text → Bounding-Box + Toleranz.
|
|
*/
|
|
export function annotationHit(
|
|
ann: LayoutAnnotation,
|
|
xMm: number,
|
|
yMm: number,
|
|
tolMm: number,
|
|
): boolean {
|
|
if (ann.kind === "line") {
|
|
return distanceToSegment(xMm, yMm, ann.x1Mm, ann.y1Mm, ann.x2Mm, ann.y2Mm) <= tolMm;
|
|
}
|
|
if (ann.kind === "rect") {
|
|
const r: RectMm = { xMm: ann.xMm, yMm: ann.yMm, widthMm: ann.widthMm, heightMm: ann.heightMm };
|
|
return handleAt(r, xMm, yMm, tolMm) !== null;
|
|
}
|
|
const b = annotationBoundsMm(ann);
|
|
return (
|
|
xMm >= b.xMm - tolMm &&
|
|
xMm <= b.xMm + b.widthMm + tolMm &&
|
|
yMm >= b.yMm - tolMm &&
|
|
yMm <= b.yMm + b.heightMm + tolMm
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Topmost-Treffer über eine Annotations-Liste (letzte = oberste, wie
|
|
* {@link pickViewport}). Liefert die Id des ERSTEN Treffers von oben, sonst null.
|
|
*/
|
|
export function pickAnnotation(
|
|
annotations: LayoutAnnotation[],
|
|
xMm: number,
|
|
yMm: number,
|
|
tolMm: number,
|
|
): string | null {
|
|
for (let i = annotations.length - 1; i >= 0; i--) {
|
|
const a = annotations[i];
|
|
if (annotationHit(a, xMm, yMm, tolMm)) return a.id;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Verschiebt eine Annotation um ein mm-Delta — alle ihre Punkte wandern mit. */
|
|
export function translateAnnotation(
|
|
ann: LayoutAnnotation,
|
|
dxMm: number,
|
|
dyMm: number,
|
|
): LayoutAnnotation {
|
|
if (ann.kind === "line") {
|
|
return {
|
|
...ann,
|
|
x1Mm: ann.x1Mm + dxMm,
|
|
y1Mm: ann.y1Mm + dyMm,
|
|
x2Mm: ann.x2Mm + dxMm,
|
|
y2Mm: ann.y2Mm + dyMm,
|
|
};
|
|
}
|
|
return { ...ann, xMm: ann.xMm + dxMm, yMm: ann.yMm + dyMm };
|
|
}
|