Files
DOSSIER-STANDALONE/src/commands/cmds/offset.ts
T
karim ca859c4aa4 Browser-BIM (cad): semantisches Modell, abgeleitete 2D/3D-Sichten, Zeichenwerkzeuge
Standalone-Browser-Port von DOSSIER. Enthaelt das semantische Modell mit
Plan-/3D-Ableitung, Zeichen- und Editierwerkzeuge, Rhino-artiges Befehlssystem,
dockbares Panel-System, Resource-Manager, DXF/.lin/.pat-Import, i18n (de/en)
sowie Projektdokumentation und Probe-Harness.
2026-06-30 20:52:27 +02:00

340 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.
// 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` },
},
},
];
}