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:
@@ -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` },
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user