Files
DOSSIER-STANDALONE/src/tools/snapping.ts
T
karim 8377b76d98 Cursor-HUD (VW-Stil) + weiches Winkel-Einrasten im Grundriss
Beim Zeichnen erscheint jetzt direkt am Cursor ein blau umrandetes
Massband-Kästchen mit Länge/Winkel (L: 3.118m  W: 60.000°, Winkel im
Bereich (−180,180]) statt nur in der Befehlszeile. Zusätzlich rastet
der Zugwinkel weich auf gängige Winkel (15°-Vielfache, Toleranz per
Default 2.5°) ein, sobald kein Objekt-Snap greift — mit gelblichem
Winkel-Badge und einer über den Cursor hinaus verlängerten, gestrichelten
Führungslinie (Vectorworks-Vorbild).

- tools/types.ts: ToolDraft.hud um length/angleDeg erweitert (text bleibt
  für Befehle ohne L/W-Paar), neuer segmentHud()-Helper.
- tools/snapping.ts: neue reine Funktion snapCommonAngle() + Einbindung in
  computeSnap (Objekt-Snap > harter Ortho-Zwang > weiches Winkelraster >
  Raster). Neues SnapSettings-Feld commonAngles + commonAngleTolerance,
  neuer SnapKind "angle".
- ui/StatusBar.tsx: Toggle + Toleranz-Eingabe in der Fang-Popover.
- plan/PlanView.tsx: DraftHud- und AngleGuide-Overlay (screen-space,
  zoomunabhängige Größe); die Winkel-Info reitet auf draft.snap mit.
- i18n: snap.commonAngles / snap.commonAngleTolerance (de/en).
- snapping.test.ts: snapCommonAngle-Fälle (exakt/Toleranz/Bereich/
  15°-Raster/Projektion) + Objekt-Snap-Vorrang vor dem Winkelraster.
2026-07-11 00:21:21 +02:00

329 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Snapping (docs/design/drawing-tools.md §5). Läuft auf jedem Pointer-Move
// BEVOR der Punkt an das Werkzeug geht: sammelt Snap-Kandidaten, wählt den
// besten innerhalb der Bildschirm-Toleranz und liefert Marker-Info.
//
// Phase 2: endpoint · midpoint · intersection · onEdge (Lot) · grid · ortho.
// Prioritäts-Reihenfolge (Vorrang bei nahem Abstand): endpoint > intersection >
// midpoint > onEdge > grid; ortho/Winkelraster ist eine Projektion (überlagert).
import type { Project, Vec2, Wall } from "../model/types";
import { getWallType, wallTypeThickness } from "../model/types";
import { add, leftNormal, lineIntersect, normalize, scale, sub } from "../model/geometry";
import { wallReferenceOffset } from "../model/wall";
import { centroid as roomCentroid } from "../geometry/roomArea";
import type { SnapKind, SnapResult, SnapSettings } from "./types";
const dist = (a: Vec2, b: Vec2): number => Math.hypot(a.x - b.x, a.y - b.y);
const mid = (a: Vec2, b: Vec2): Vec2 => ({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 });
/** Bildschirm-px-Bonus je Snap-Art: höher = gewinnt knappe Vergleiche. */
const PRIORITY: Record<SnapKind, number> = {
endpoint: 7,
intersection: 5,
midpoint: 3,
center: 3,
quadrant: 3,
onEdge: 0,
grid: -2,
ortho: 0,
extension: 0,
angle: 0,
};
export interface SnapInput {
raw: Vec2;
project: Project;
levelId: string;
visibleCodes: Set<string>;
settings: SnapSettings;
draftPoints: Vec2[];
lastPoint: Vec2 | null;
pxPerMeter: number;
shift: boolean;
ctrl: boolean;
}
/** Eine Strecke als Snap-Quelle (Wandachse oder 2D-Segment). */
type Seg = [Vec2, Vec2];
/**
* Schichttrennlinien einer Wand: für jede kumulierte Schichtgrenze (inkl.
* beider Aussenflächen) eine zur Achse parallele Strecke, um den Betrag
* entlang `leftNormal(u)` verschoben. Spiegelt EXAKT die Offset-Berechnung aus
* `generatePlan.ts` (`addWallPoche`): Start bei `refOff − total/2`
* (`wallReferenceOffset`), danach je Schicht `+= layer.thickness` aufsummiert.
* Liefert `layers.length + 1` Segmente (leer bei unbekanntem Wandtyp).
*/
export function wallLayerBoundarySegments(project: Project, wall: Wall): Seg[] {
let wt;
try {
wt = getWallType(project, wall);
} catch {
return [];
}
const total = wallTypeThickness(wt);
const refOff = wallReferenceOffset(wall, total);
const u = normalize(sub(wall.end, wall.start));
const n = leftNormal(u);
const out: Seg[] = [];
let off = refOff - total / 2;
const boundary = (o: number): Seg => [
add(wall.start, scale(n, o)),
add(wall.end, scale(n, o)),
];
out.push(boundary(off)); // äussere Fläche
for (const layer of wt.layers) {
off += layer.thickness;
out.push(boundary(off)); // Schichtfuge (letzte = innere Fläche)
}
return out;
}
/** Sammelt alle sichtbaren Strecken des Geschosses (Wandachsen + 2D-Segmente). */
function collectSegments(input: SnapInput): Seg[] {
const segs: Seg[] = [];
for (const w of input.project.walls) {
if (w.floorId !== input.levelId) continue;
if (!input.visibleCodes.has(w.categoryCode)) continue;
segs.push([w.start, w.end]);
for (const seg of wallLayerBoundarySegments(input.project, w)) segs.push(seg);
}
for (const d of input.project.drawings2d) {
if (d.levelId !== input.levelId) continue;
if (!input.visibleCodes.has(d.categoryCode)) continue;
const g = d.geom;
if (g.shape === "line") segs.push([g.a, g.b]);
else if (g.shape === "polyline") {
for (let i = 0; i < g.pts.length - 1; i++) segs.push([g.pts[i], g.pts[i + 1]]);
if (g.closed && g.pts.length > 2) segs.push([g.pts[g.pts.length - 1], g.pts[0]]);
} else if (g.shape === "rect") {
const c1 = g.min, c3 = g.max;
const c2 = { x: g.max.x, y: g.min.y }, c4 = { x: g.min.x, y: g.max.y };
segs.push([c1, c2], [c2, c3], [c3, c4], [c4, c1]);
}
}
// Bereits gesetzte Draft-Segmente (Polylinie/Wand im Bau).
for (let i = 0; i < input.draftPoints.length - 1; i++) {
segs.push([input.draftPoints[i], input.draftPoints[i + 1]]);
}
return segs;
}
/** Kreis-/Bogen-Geometrie für Center-/Quadrant-/Endpunkt-Snaps. `arc=null` =
* Vollkreis (keine Endpunkte, alle vier Quadranten). */
interface CircleGeom {
center: Vec2;
r: number;
arc: { a0: number; a1: number } | null;
}
/** Sammelt sichtbare Kreise/Bögen des aktiven Geschosses (aus `drawings2d`). */
function collectCircles(input: SnapInput): CircleGeom[] {
const out: CircleGeom[] = [];
for (const d of input.project.drawings2d) {
if (d.levelId !== input.levelId) continue;
if (!input.visibleCodes.has(d.categoryCode)) continue;
const g = d.geom;
if (g.shape === "circle") out.push({ center: g.center, r: g.r, arc: null });
else if (g.shape === "arc")
out.push({ center: g.center, r: g.r, arc: { a0: g.a0, a1: g.a1 } });
}
return out;
}
/** Lotfußpunkt von p auf die Strecke a-b, auf das Segment geklemmt. */
function perpFoot(p: Vec2, a: Vec2, b: Vec2): Vec2 {
const dx = b.x - a.x, dy = b.y - a.y;
const len2 = dx * dx + dy * dy;
if (len2 < 1e-12) return a;
let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2;
t = Math.max(0, Math.min(1, t));
return { x: a.x + dx * t, y: a.y + dy * t };
}
/**
* Bestimmt den effektiven Snap für den rohen Cursor-Punkt (oder null).
* Punkt-Snaps haben Vorrang vor Ortho und Raster (Prioritäts-Bonus).
*/
export function computeSnap(input: SnapInput): SnapResult | null {
const { raw, settings, pxPerMeter, shift, ctrl } = input;
if (ctrl || !settings.enabled) return null;
const tolM = settings.tolerancePx / Math.max(pxPerMeter, 1e-6);
// Bester Kandidat nach (distPx − Prioritäts-Bonus).
let best: SnapResult | null = null;
let bestScore = Infinity;
const consider = (point: Vec2, kind: SnapKind, refA?: Vec2) => {
const d = dist(point, raw);
if (d > tolM) return;
const distPx = d * pxPerMeter;
const score = distPx - PRIORITY[kind];
if (score < bestScore) {
bestScore = score;
best = { point, kind, distPx, refA };
}
};
const segs =
settings.midpoint || settings.intersection || settings.onEdge || settings.endpoint
? collectSegments(input)
: [];
// endpoint (Strecken-Endpunkte + Draft-Knoten + Raum-Stempel-Anker).
if (settings.endpoint) {
for (const [a, b] of segs) {
consider(a, "endpoint");
consider(b, "endpoint");
}
for (const p of input.draftPoints) consider(p, "endpoint");
// Raum-Stempel-Anker (fixer Anker, sonst Flächenschwerpunkt) des Geschosses.
for (const room of input.project.rooms ?? []) {
if (room.floorId !== input.levelId) continue;
if (!input.visibleCodes.has(room.categoryCode)) continue;
if (room.boundary.length < 3) continue;
consider(room.stampAnchor ?? roomCentroid(room.boundary), "endpoint");
}
}
// midpoint.
if (settings.midpoint) {
for (const [a, b] of segs) consider(mid(a, b), "midpoint");
}
// intersection (paarweise Geraden-Schnitt der Strecken, nahe am Cursor).
if (settings.intersection) {
for (let i = 0; i < segs.length; i++) {
for (let j = i + 1; j < segs.length; j++) {
const [a, b] = segs[i];
const [c, d] = segs[j];
const x = lineIntersect(a, { x: b.x - a.x, y: b.y - a.y }, c, {
x: d.x - c.x,
y: d.y - c.y,
});
if (x) consider(x, "intersection");
}
}
}
// onEdge (Lotfußpunkt auf nahe Strecken; niedrige Priorität).
if (settings.onEdge) {
for (const [a, b] of segs) consider(perpFoot(raw, a, b), "onEdge");
}
// Kreis-/Bogen-Snaps: Mittelpunkt + Quadranten (Ost/Nord/West/Süd); bei Bögen
// nur die Quadranten IM Bogen-Bereich, plus die beiden Bogen-Endpunkte. Center/
// Quadrant hängen an der `center`-Einstellung, die Endpunkte an `endpoint`.
if (settings.center || settings.endpoint) {
const TAU = Math.PI * 2;
const norm = (x: number) => ((x % TAU) + TAU) % TAU;
const onCircle = (c: CircleGeom, ang: number): Vec2 => ({
x: c.center.x + c.r * Math.cos(ang),
y: c.center.y + c.r * Math.sin(ang),
});
for (const c of collectCircles(input)) {
if (settings.center) {
consider(c.center, "center");
// Volle CCW-Spanne (0 ⇒ Vollkreis); Quadrant nur, wenn im Bogen liegt.
const sweep = c.arc ? norm(c.arc.a1 - c.arc.a0) || TAU : TAU;
for (let q = 0; q < 4; q++) {
const ang = q * (Math.PI / 2);
if (c.arc && norm(ang - c.arc.a0) > sweep + 1e-9) continue;
consider(onCircle(c, ang), "quadrant");
}
}
if (settings.endpoint && c.arc) {
consider(onCircle(c, c.arc.a0), "endpoint");
consider(onCircle(c, c.arc.a1), "endpoint");
}
}
}
if (best) return best;
// Ortho / Winkelraster (hart, Projektion relativ zum letzten Punkt). Shift
// erzwingt zusätzlich — beides UNVERÄNDERT gegenüber dem weichen Winkelraster
// unten, das nur greift, wenn weder Shift noch die Ortho-Einstellung aktiv ist.
if ((settings.ortho || shift) && input.lastPoint) {
const point = applyAngleConstraint(input.lastPoint, raw, settings.angleStep);
return { point, kind: "ortho", refA: input.lastPoint, distPx: 0 };
}
// Weiches Winkelraster (gängige Winkel, 15°-Vielfache) — nur wenn kein
// Objekt-Snap UND kein harter Ortho-Zwang gegriffen hat (Vorrang-Reihenfolge).
if (settings.commonAngles && input.lastPoint) {
const snapped = snapCommonAngle(input.lastPoint, raw, settings.commonAngleTolerance);
if (snapped) {
return {
point: snapped.point,
kind: "angle",
refA: input.lastPoint,
angleDeg: snapped.angleDeg,
distPx: 0,
};
}
}
// Raster.
if (settings.grid) {
const g = snapToGrid(raw, settings.gridSize);
if (dist(g, raw) <= tolM) {
return { point: g, kind: "grid", distPx: dist(g, raw) * pxPerMeter };
}
}
return null;
}
/** Projiziert `to` auf die nächste erlaubte Richtung vom Punkt `from`. */
export function applyAngleConstraint(from: Vec2, to: Vec2, stepDeg: number): Vec2 {
const dx = to.x - from.x;
const dy = to.y - from.y;
const ang = Math.atan2(dy, dx);
const step = (stepDeg * Math.PI) / 180;
const k = Math.round(ang / step) * step;
const len = Math.hypot(dx, dy);
return { x: from.x + Math.cos(k) * len, y: from.y + Math.sin(k) * len };
}
/** Rundet einen Punkt auf das Raster (Meter). */
function snapToGrid(p: Vec2, size: number): Vec2 {
if (!(size > 0)) return p;
return { x: Math.round(p.x / size) * size, y: Math.round(p.y / size) * size };
}
/** Winkel (Grad) auf den Bereich (−180,180] normalisieren (VW-Konvention). */
function normalizeAngleDeg(deg: number): number {
let a = deg % 360;
if (a <= -180) a += 360;
if (a > 180) a -= 360;
return a;
}
/** Schrittweite des weichen Winkelrasters: deckt 0/15/30/45/60/75/90/… ab. */
const COMMON_ANGLE_STEP_DEG = 15;
/**
* Weiches Winkel-Einrasten (Vectorworks-Stil): rastet den Roh-Winkel von `from`
* nach `raw` auf den NÄCHSTEN gängigen Winkel (15°-Vielfache), wenn die
* Abweichung ≤ `toleranceDeg` — sonst `null`. Die LÄNGE bleibt die Roh-Distanz,
* projiziert auf den gerasterten Strahl (nicht die rohe Luftlinie). Reine
* Geometrie, unabhängig von Objekt-Snaps/Settings (Integration in
* {@link computeSnap} entscheidet über den Vorrang).
*/
export function snapCommonAngle(
from: Vec2,
raw: Vec2,
toleranceDeg = 2.5,
): { point: Vec2; angleDeg: number } | null {
const dx = raw.x - from.x;
const dy = raw.y - from.y;
const rawLen = Math.hypot(dx, dy);
if (rawLen < 1e-9) return null;
const rawAngleDeg = (Math.atan2(dy, dx) * 180) / Math.PI; // bereits (−180,180]
const snappedRaw = Math.round(rawAngleDeg / COMMON_ANGLE_STEP_DEG) * COMMON_ANGLE_STEP_DEG;
const delta = snappedRaw - rawAngleDeg; // klein (≤ halbe Schrittweite), keine Wraparound-Sorgen
if (Math.abs(delta) > toleranceDeg) return null;
const angleDeg = normalizeAngleDeg(snappedRaw);
const rad = (angleDeg * Math.PI) / 180;
const length = rawLen * Math.cos((delta * Math.PI) / 180); // Projektion auf den Strahl
const point = { x: from.x + Math.cos(rad) * length, y: from.y + Math.sin(rad) * length };
return { point, angleDeg };
}