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.
This commit is contained in:
2026-06-30 20:52:27 +02:00
commit ca859c4aa4
157 changed files with 37921 additions and 0 deletions
+339
View File
@@ -0,0 +1,339 @@
// 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` },
},
},
];
}