// Offset — DAS Architektur-Primitiv (docs §2.10/§3.4 Tier-1, §3.6 persistente // Distanz). Erzeugt eine parallel versetzte Kopie einer 2D-Kurve (line/polyline/ // rect). Schritte: // 1) Kurve bestimmen: ist genau EIN passendes Drawing2D (line/polyline/rect) // selektiert, wird es genommen; sonst „Kurve wählen:" → Klick auf eine Kurve. // 2) „Seite/Distanz:" → Distanz tippen (PERSISTENT als Default über Aufrufe, // modulweite Variable) ODER Seite per Klick (Vorzeichen aus der Klickseite // relativ zur Kurve). → commit neue versetzte Drawing2D-Kurve. // // Distanz-Vorzeichen: Konvention `+d = links` (kernel2d/leftNormal). Die // Klickseite bestimmt links/rechts: liegt der Klick links der Kurve (bzw. der // nächsten Kante), wird +|d| genommen, sonst −|d|. Eine getippte Zahl nutzt das // Vorzeichen der zuletzt gewählten Seite (Default links/+), bzw. ihr eigenes // Vorzeichen, wenn negativ getippt. import type { Drawing2D, Drawing2DGeom } from "../../model/types"; import { uniqueId } from "../../tools/types"; import { leftNormal, normalize, sub } from "../../model/geometry"; import { offsetPolyline, offsetSegment, pointSegmentDistance, polylineEdges, } from "../../geometry/kernel2d"; import type { CmdInput, Command, CommandContext, CommandResult, CommandSelection, CommandState, DraftShape, Project, Vec2, } from "../types"; const EPS = 1e-6; const HIT_TOL = 0.25; // Modell-Meter: Pick-Toleranz für die Kurvenwahl /** * Persistente Offset-Distanz über Aufrufe hinweg (§2.4/§3.6 — Nutzer erwarten, * dass die letzte Distanz als Default wiederkommt). Modulweite Variable, |d|; * das Vorzeichen (Seite) wird je Offset frisch aus Klick/Eingabe bestimmt. */ let lastDistance = 0.5; /** Zuletzt gewählte Seite als Vorzeichen (+1 = links, −1 = rechts). */ let lastSign = 1; // ── Kurven-Abstraktion: line/polyline/rect → Punktliste + closed ────────────── interface Curve { pts: Vec2[]; closed: boolean; } /** Wandelt eine offset-fähige 2D-Form in eine Punktliste (+closed) oder null. */ function geomToCurve(g: Drawing2DGeom): Curve | null { if (g.shape === "line") return { pts: [g.a, g.b], closed: false }; if (g.shape === "polyline") return { pts: g.pts, closed: g.closed }; if (g.shape === "rect") { // Rechteck als geschlossene 4-Punkt-Polylinie offsetten (Task). return { pts: [ g.min, { x: g.max.x, y: g.min.y }, g.max, { x: g.min.x, y: g.max.y }, ], closed: true, }; } return null; // circle/arc/text: hier nicht offset-fähig } /** Ist diese Form offset-fähig (line/polyline/rect)? */ function isOffsetable(d: Drawing2D): boolean { return d.geom.shape === "line" || d.geom.shape === "polyline" || d.geom.shape === "rect"; } /** Genau ein passendes Drawing2D vorselektiert? Dann liefere es. */ function singleSelectedCurve(project: Project, sel: CommandSelection): Drawing2D | null { if (!sel.drawingId || sel.wallIds.length > 0) return null; const d = project.drawings2d.find((x) => x.id === sel.drawingId); if (d && isOffsetable(d)) return d; return null; } /** Nächstes offset-fähiges Drawing2D zum Klickpunkt (innerhalb Toleranz). */ function pickCurveAt(project: Project, levelId: string, pt: Vec2): Drawing2D | null { let best: Drawing2D | null = null; let bestD = HIT_TOL; for (const d of project.drawings2d) { if (d.levelId !== levelId) continue; const curve = geomToCurve(d.geom); if (!curve) continue; for (const [a, b] of polylineEdges(curve.pts, curve.closed)) { const dd = pointSegmentDistance(pt, a, b); if (dd < bestD) { bestD = dd; best = d; } } } return best; } /** * Vorzeichen (+links / −rechts) für einen Klickpunkt relativ zur Kurve: die * nächste Kante bestimmen, dann das Vorzeichen der Skalarprojektion von * (klick − Kantenmitte) auf die LINKS-Normale der Kante. So zeigt das Resultat * auf die angeklickte Seite (Konvention `+d = links`). */ function sideSign(curve: Curve, pt: Vec2): number { let bestD = Infinity; let sign = lastSign; for (const [a, b] of polylineEdges(curve.pts, curve.closed)) { const mid: Vec2 = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }; const dd = pointSegmentDistance(pt, a, b); if (dd < bestD) { bestD = dd; const u = normalize(sub(b, a)); const n = leftNormal(u); const rel = sub(pt, mid); const s = rel.x * n.x + rel.y * n.y; sign = s >= 0 ? 1 : -1; } } return sign; } /** Offset einer Kurve um die vorzeichenbehaftete Distanz `d` → neue Punktliste. */ function offsetCurve(curve: Curve, d: number): Vec2[] { if (!curve.closed && curve.pts.length === 2) { const [a, b] = offsetSegment(curve.pts[0], curve.pts[1], d); return [a, b]; } return offsetPolyline(curve.pts, d, curve.closed); } /** Versetzte Form als neues Drawing2D (gleiche Attribute, neue ID/Geometrie). */ function offsetGeom(src: Drawing2D, d: number): Drawing2DGeom | null { const g = src.geom; if (g.shape === "line") { const [a, b] = offsetSegment(g.a, g.b, d); return { shape: "line", a, b }; } if (g.shape === "polyline") { return { shape: "polyline", pts: offsetPolyline(g.pts, d, g.closed), closed: g.closed }; } if (g.shape === "rect") { const curve = geomToCurve(g)!; return { shape: "polyline", pts: offsetPolyline(curve.pts, d, true), closed: true }; } return null; } /** Vorschau-Formen der versetzten Kurve (für den Draft). */ function offsetDraftShapes(curve: Curve, d: number): DraftShape[] { const pts = offsetCurve(curve, d); if (pts.length < 2) return []; if (!curve.closed && pts.length === 2) return [{ kind: "line", a: pts[0], b: pts[1] }]; return [{ kind: "poly", pts, closed: curve.closed }]; } function appendOffset(p: Project, src: Drawing2D, d: number): Project { const geom = offsetGeom(src, d); if (!geom) return p; const copy: Drawing2D = { ...src, id: uniqueId("dr2d"), geom }; return { ...p, drawings2d: [...p.drawings2d, copy] }; } // ── Zustand ─────────────────────────────────────────────────────────────────── // „pick": Kurve wählen (keine passende Vorselektion). „side": Kurve steht fest, // jetzt Seite/Distanz. „done": Befehl beendet. interface OffPick extends CommandState { phase: "pick"; } interface OffSide extends CommandState { phase: "side"; drawingId: string; cursor: Vec2 | null; } interface OffDone extends CommandState { phase: "done"; } type OffState = OffPick | OffSide | OffDone; const finish = (): [CommandState, CommandResult] => [ { phase: "done", lastPoint: null } as OffDone, { draft: null, done: true }, ]; /** Aktuelle Kurve aus dem Side-Zustand (oder null, wenn weg). */ function curveOf(project: Project, drawingId: string): { src: Drawing2D; curve: Curve } | null { const src = project.drawings2d.find((x) => x.id === drawingId); if (!src) return null; const curve = geomToCurve(src.geom); if (!curve) return null; return { src, curve }; } export const offsetCommand: Command = { name: "offset", labelKey: "cmd.offset.label", prompt: (s) => { const os = s as OffState; if (os.phase === "side") return "cmd.offset.side"; return "cmd.offset.pick"; }, // Side-Schritt: Punkt (Seite klicken) ODER Zahl (Distanz tippen). Auch der // „pick"-Schritt nimmt eine Zahl an, damit eine getippte Distanz sofort greift, // wenn eine Kurve vorselektiert ist (dann wird „pick" live wie „side" behandelt). accepts: () => ["point", "number"], options: () => [], // init() kennt den Kontext nicht; die Vorselektion wird im ersten onMove/ // onInput ausgewertet (geht direkt auf „side", wenn genau eine Kurve gewählt // ist). Bis dahin „pick". init: (): OffPick => ({ phase: "pick", lastPoint: null }), onInput: (state, input, ctx): [CommandState, CommandResult] => { const s = state as OffState; if (s.phase === "done") return finish(); // Vorselektion: gibt es genau eine passende Kurve, direkt in den Side-Schritt // springen (auch wenn die Engine erst onInput aufruft). if (s.phase === "pick") { const pre = singleSelectedCurve(ctx.project, ctx.selection); if (pre) { // Eingabe im selben Schritt direkt als Side/Distanz behandeln. return offsetSideInput( { phase: "side", drawingId: pre.id, cursor: null, lastPoint: null }, input, ctx, ); } // Keine Vorselektion → Kurve per Klick wählen. if (input.kind === "point") { const hit = pickCurveAt(ctx.project, ctx.level.id, input.point); if (!hit) return [s, { draft: null }]; const ns: OffSide = { phase: "side", drawingId: hit.id, cursor: null, lastPoint: null }; return [ns, { draft: null }]; } return [s, { draft: null }]; } // phase === "side" return offsetSideInput(s as OffSide, input, ctx); }, onMove: (state, point, _snap, ctx): [CommandState, CommandResult] => { const s = state as OffState; // Vorselektion live: in „pick" mit genau einer Kurve → als Side behandeln. if (s.phase === "pick") { const pre = singleSelectedCurve(ctx.project, ctx.selection); if (pre) { return offsetSideMove({ phase: "side", drawingId: pre.id, cursor: point, lastPoint: null }, point, ctx); } return [s, { draft: null }]; } if (s.phase === "side") return offsetSideMove(s as OffSide, point, ctx); return [s, { draft: null }]; }, onConfirm: (): [CommandState, CommandResult] => finish(), onCancel: (): [CommandState, CommandResult] => finish(), // KEIN Tab-Feld-Zyklus: die Offset-Distanz ist ein reiner Skalar, der als // `number`-Eingabe in den Schritt läuft (nicht als feld-erzeugter Punkt). So // committet eine getippte Distanz direkt; die Seite kommt per Klick/Vorzeichen. }; // ── Side-Schritt-Logik (für Vorselektion UND echten pick wiederverwendet) ───── /** Eingabe im Side-Schritt: Punkt (Seite klicken) ODER Zahl (Distanz tippen). */ function offsetSideInput( s: OffSide, input: CmdInput, ctx: CommandContext, ): [CommandState, CommandResult] { const cc = curveOf(ctx.project, s.drawingId); if (!cc) return finish(); const { src, curve } = cc; if (input.kind === "number") { // Getippte Distanz: Betrag merken; Vorzeichen aus der zuletzt gewählten // Seite (bzw. dem getippten Vorzeichen, falls negativ). const typed = input.value; const mag = Math.abs(typed); if (mag < EPS) return [s, { draft: null }]; lastDistance = mag; const sign = typed < 0 ? -1 : lastSign; lastSign = sign; const d = sign * mag; return [ { phase: "done", lastPoint: null } as OffDone, { draft: null, done: true, commit: (p) => appendOffset(p, src, d) }, ]; } if (input.kind === "point") { // Seite per Klick: Vorzeichen aus der Klickseite, Betrag = letzte Distanz. const sign = sideSign(curve, input.point); lastSign = sign; const d = sign * lastDistance; return [ { phase: "done", lastPoint: null } as OffDone, { draft: null, done: true, commit: (p) => appendOffset(p, src, d) }, ]; } return [s, { draft: null }]; } /** Hover im Side-Schritt: Vorschau der versetzten Kurve an der Cursor-Seite. */ function offsetSideMove( s: OffSide, point: Vec2, ctx: CommandContext, ): [CommandState, CommandResult] { const cc = curveOf(ctx.project, s.drawingId); if (!cc) return [s, { draft: null }]; const { curve } = cc; const sign = sideSign(curve, point); const d = sign * lastDistance; const preview = offsetDraftShapes(curve, d); const ns: OffSide = { ...s, cursor: point }; return [ ns, { draft: { preview, vertices: [], hud: { at: point, text: `${lastDistance.toFixed(2)} m` }, }, }, ]; }