Akkumulierten grünen Arbeitsstand landen (Basis für Weiterarbeit)

Bündelt den über mehrere Sessions gewachsenen, uncommitteten Stand in
einem Basis-Commit, damit Folge-Features isoliert darauf aufsetzen.
Verifikation: tsc --noEmit sauber, vitest 600/600 grün.

Enthalten (Details in PENDENZEN.md -Liste / HANDOVER.md):
- truck-Integration: Profil-Extrusion + Verjüngung + Boolean-CSG (csgrs),
  Crate src-tauri/trucksolid, Werkzeug `extrude`, ExtrudedSolid-Modell.
- kernel2d-Port nach Rust/WASM (Phasen 1–5, Diff-Harness).
- render3d 3D-Live-Schnitt = 2D-Schnitt: geschichteter Bodenaufbau,
  Prioritäts-Verschneidung (section_boolean.rs), einstellbare
  Schichttrennlinien, per-Hatch-Strichstärke, relativeToWall-Orientierung.
- Interop-Export IFC4/STL/OBJ (Loch-Ausschnitt wallMeshCut), Schnellexport.
- Projektdatei .obp + OS-Lock (lock.rs, LockConflictDialog).
- Layout-Blätter (Modell/Editor/Panel/PDF), Ausschnitte, Override-Engine,
  Tragwerk-Stützen (Column), BIM-Tree-Panel.
- Bauteil-Typsystem (Tür/Fenster/Treppe-Typen), Betontreppe mit schräger
  Laufplatte, Text-/Textbox-Werkzeug, Mess-Werkzeug, 2D/3D-Griffe für
  Öffnungen/Treppen, Snap-Symbol-Restyle.
This commit is contained in:
2026-07-09 00:57:29 +02:00
parent 889cbb2c12
commit 35299307d6
131 changed files with 26501 additions and 852 deletions
+152
View File
@@ -0,0 +1,152 @@
// Stütze (Column) als Engine-Befehl — DOSSIER-Audit A4 Tragwerk. Ablauf:
// 1) „Stütze setzen:" → Klick setzt den Einfügepunkt (Profil-Mitte) → commit.
//
// Optionen (togglebar, IMMER sichtbar): Profil Rechteck/Rund. Eine getippte Zahl
// setzt das Leitmass (Rechteck: Seitenlänge → quadratisch; Rund: Durchmesser)
// PERSISTENT über Aufrufe — analog `offset`/`extrude`, wo der pick-Schritt auch
// eine Zahl annimmt. Höhe = Geschosshöhe des aktiven Geschosses (Default).
//
// Herkunft der Stützen-Felder (CommandContext):
// • floorId ← ctx.level.id (aktives Geschoss)
// • categoryCode← "50" (Tragwerk; Fallback ctx.defaultCategoryCode)
// • height ← Geschosshöhe (ctx.level.floorHeight), Fallback DEF_HEIGHT
//
// Bezeichner englisch, sichtbarer Text via t() (CONVENTIONS.md).
import type { Column, ColumnProfile } from "../../model/types";
import { uniqueId } from "../../tools/types";
import { columnFootprint } from "../../geometry/column";
import type {
Command,
CommandContext,
CommandResult,
CommandState,
CmdOption,
Project,
Vec2,
} from "../types";
/** Default-Ebene (Kategorie) für Stützen: „50" Tragwerk (Fallback: aktive). */
const COLUMN_CATEGORY = "50";
/** Default-Kantenlänge eines Rechteckprofils (Meter). */
const DEF_SIZE = 0.3;
/** Fallback-Höhe, falls das Geschoss keine floorHeight trägt (Meter). */
const DEF_HEIGHT = 2.6;
// ── Persistente Defaults (modulweit, leben über Befehls-Instanzen hinweg) ────
const columnDefaults: { kind: "rect" | "round"; size: number } = {
kind: "rect",
size: DEF_SIZE,
};
/** Baut das aktuelle Default-Profil aus `columnDefaults`. */
function currentProfile(): ColumnProfile {
if (columnDefaults.kind === "round") {
return { kind: "round", radius: columnDefaults.size / 2 };
}
return { kind: "rect", width: columnDefaults.size, depth: columnDefaults.size };
}
/** Fügt die Stütze immutabel an `project.columns` an. */
function appendColumn(
p: Project,
position: Vec2,
ctx: CommandContext,
): Project {
const height =
ctx.level.floorHeight != null && ctx.level.floorHeight > 0
? ctx.level.floorHeight
: DEF_HEIGHT;
const column: Column = {
id: uniqueId("column"),
type: "column",
floorId: ctx.level.id,
categoryCode: COLUMN_CATEGORY,
position,
profile: currentProfile(),
rotation: 0,
height,
};
return { ...p, columns: [...(p.columns ?? []), column] };
}
interface ColState extends CommandState {
phase: "place" | "done";
}
const finish = (): [CommandState, CommandResult] => [
{ phase: "done", lastPoint: null } as ColState,
{ draft: null, done: true },
];
/** Footprint-Vorschau des aktuellen Default-Profils am Cursor. */
function previewAt(pt: Vec2): CommandResult {
const ghost: Column = {
id: "_preview",
type: "column",
floorId: "",
categoryCode: COLUMN_CATEGORY,
position: pt,
profile: currentProfile(),
rotation: 0,
height: 0,
};
const size =
columnDefaults.kind === "round"
? `Ø${(columnDefaults.size * 100).toFixed(0)}`
: `${(columnDefaults.size * 100).toFixed(0)}×${(columnDefaults.size * 100).toFixed(0)}`;
return {
draft: {
preview: [{ kind: "poly", pts: columnFootprint(ghost), closed: true }],
vertices: [],
hud: { at: pt, text: size },
},
};
}
export const columnCommand: Command = {
name: "column",
labelKey: "cmd.column.label",
prompt: () => "cmd.column.place",
accepts: () => ["point", "number", "option"],
options: (): CmdOption[] => [
{
id: "profile",
labelKey: "cmd.column.profile",
value: columnDefaults.kind === "round" ? "round" : "rect",
},
],
floorOnly: true,
init: (): ColState => ({ phase: "place", lastPoint: null }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as ColState;
if (s.phase === "done") return finish();
if (input.kind === "number") {
const v = Math.abs(input.value);
if (v > 1e-4) columnDefaults.size = v;
return [s, { draft: null }];
}
if (input.kind === "option" && input.id === "profile") {
columnDefaults.kind = columnDefaults.kind === "round" ? "rect" : "round";
return [s, { draft: null }];
}
if (input.kind === "point") {
return [
{ phase: "done", lastPoint: null } as ColState,
{ draft: null, done: true, commit: (p) => appendColumn(p, input.point, ctx) },
];
}
return [s, { draft: null }];
},
onMove: (state, point): [CommandState, CommandResult] => {
const s = state as ColState;
if (s.phase !== "place") return [s, { draft: null }];
return [s, previewAt(point)];
},
onConfirm: (): [CommandState, CommandResult] => finish(),
onCancel: (): [CommandState, CommandResult] => finish(),
};
+12 -2
View File
@@ -42,10 +42,20 @@ function targetFromFields(base: Vec2, locks: Record<string, number>, cursor: Vec
}
function hasSelection(sel: CommandSelection): boolean {
return sel.wallIds.length > 0 || sel.drawingId !== null;
return (
sel.wallIds.length > 0 ||
sel.drawingId !== null ||
!!sel.extrudedSolidId ||
!!sel.columnId
);
}
function toTransformSel(sel: CommandSelection): TransformSelection {
return { wallIds: sel.wallIds, drawingId: sel.drawingId };
return {
wallIds: sel.wallIds,
drawingId: sel.drawingId,
extrudedSolidId: sel.extrudedSolidId,
columnId: sel.columnId,
};
}
interface CopyBase extends CommandState {
+287
View File
@@ -0,0 +1,287 @@
// Extrudieren — truck-Integration Phase 3 (docs/design/truck-plan.md, PENDENZEN
// "truck-Integration"). Erzeugt aus einer geschlossenen 2D-Kurve (Polylinie-Ring,
// Rechteck oder Kreis) einen 3D-Körper: Schritte:
// 1) Profil bestimmen: ist genau EIN passendes geschlossenes Drawing2D
// selektiert, wird es genommen; sonst „Profil wählen:" → Klick auf einen Ring.
// 2) „Höhe:" → Zahl tippen (PERSISTENT als Default über Aufrufe) ODER Enter
// bestätigt den zuletzt genutzten Default.
// 3) „Verjüngung:" → Zahl 0..1 tippen (0=Prisma/Default, 1=Spitze — Kegel bei
// Kreis-, Pyramide bei Polygon-Profil) ODER Enter für den zuletzt
// genutzten Default (persistent, wie Höhe). → commit neues ExtrudedSolid.
//
// Die eigentliche Extrusion (truck-WASM) läuft asynchron erst in toWalls3d.ts
// (emitExtrudedSolids); dieser Befehl legt nur die Rohdaten (Punkte + Höhe +
// Verjüngung) im Projekt ab.
import type { Drawing2D, ExtrudedSolid } from "../../model/types";
import { uniqueId } from "../../tools/types";
import { polylineEdges, pointSegmentDistance } from "../../geometry/kernel2d";
import type {
Command,
CommandContext,
CommandResult,
CommandSelection,
CommandState,
Project,
Vec2,
} from "../types";
const HIT_TOL = 0.25; // Modell-Meter: Pick-Toleranz für die Profil-Wahl
/** Persistente Extrusionshöhe über Aufrufe hinweg (Default: Geschosshöhe). */
let lastHeight = 2.6;
/** Persistente Verjüngung über Aufrufe hinweg (Default: Prisma, kein Kegel/Pyramide). */
let lastTaper = 0;
// ── Profil-Abstraktion: geschlossene polyline/rect/circle → Punktliste ─────
/** Kreis-Tessellierung für Footprint/Auswahl/bbox (48-Eck, wie circle.ts). */
function circlePts(center: Vec2, r: number): Vec2[] {
const N = 48;
const pts: Vec2[] = [];
for (let i = 0; i < N; i++) {
const t = (i / N) * Math.PI * 2;
pts.push({ x: center.x + r * Math.cos(t), y: center.y + r * Math.sin(t) });
}
return pts;
}
/** Ein extrudierfähiges Profil: Tessellierung + optional die echte Kreis-Geometrie. */
interface Profile {
pts: Vec2[];
circle?: { center: Vec2; r: number };
}
/** Wandelt ein extrudierfähiges (geschlossenes) Drawing2D in ein Profil. */
function geomToProfile(d: Drawing2D): Profile | null {
const g = d.geom;
if (g.shape === "polyline" && g.closed && g.pts.length >= 3) return { pts: g.pts };
if (g.shape === "rect") {
return { pts: [g.min, { x: g.max.x, y: g.min.y }, g.max, { x: g.min.x, y: g.max.y }] };
}
if (g.shape === "circle") {
return { pts: circlePts(g.center, g.r), circle: { center: g.center, r: g.r } };
}
return null;
}
/** Genau ein passendes Drawing2D vorselektiert? Dann liefere sein Profil. */
function singleSelectedProfile(
project: Project,
sel: CommandSelection,
): ({ d: Drawing2D } & Profile) | null {
if (!sel.drawingId || sel.wallIds.length > 0) return null;
const d = project.drawings2d.find((x) => x.id === sel.drawingId);
if (!d) return null;
const profile = geomToProfile(d);
return profile ? { d, ...profile } : null;
}
/** Nächstes extrudierfähige Drawing2D zum Klickpunkt (innerhalb Toleranz). */
function pickProfileAt(
project: Project,
levelId: string,
pt: Vec2,
): ({ d: Drawing2D } & Profile) | null {
let best: ({ d: Drawing2D } & Profile) | null = null;
let bestD = HIT_TOL;
for (const d of project.drawings2d) {
if (d.levelId !== levelId) continue;
const profile = geomToProfile(d);
if (!profile) continue;
for (const [a, b] of polylineEdges(profile.pts, true)) {
const dd = pointSegmentDistance(pt, a, b);
if (dd < bestD) {
bestD = dd;
best = { d, ...profile };
}
}
}
return best;
}
/**
* Hängt den neuen Körper an UND entfernt das Quell-Profil (`sourceId`) aus
* `drawings2d` — die Polylinie/das Rechteck WIRD zur Extrusion, sie bleibt
* nicht als unabhängiges Duplikat liegen (sonst zwei Objekte am selben Ort,
* eines davon im Grundriss unsichtbar verwaist).
*/
function appendExtrudedSolid(
p: Project,
profile: Profile,
height: number,
taper: number,
sourceId: string,
ctx: CommandContext,
): Project {
const solid: ExtrudedSolid = {
id: uniqueId("extrude"),
type: "extrudedSolid",
levelId: ctx.level.id,
points: profile.pts,
height,
circle: profile.circle,
...(taper > 0 ? { taper } : {}),
};
return {
...p,
drawings2d: p.drawings2d.filter((d) => d.id !== sourceId),
extrudedSolids: [...(p.extrudedSolids ?? []), solid],
};
}
// ── Zustand ───────────────────────────────────────────────────────────────────
interface ExPick extends CommandState {
phase: "pick";
}
interface ExHeight extends CommandState {
phase: "height";
profile: Profile;
/** Id des Quell-Drawing2D, das beim Commit ersetzt (entfernt) wird. */
sourceId: string;
cursor: Vec2 | null;
}
interface ExTaper extends CommandState {
phase: "taper";
profile: Profile;
sourceId: string;
height: number;
cursor: Vec2 | null;
}
interface ExDone extends CommandState {
phase: "done";
}
type ExState = ExPick | ExHeight | ExTaper | ExDone;
const finish = (): [CommandState, CommandResult] => [
{ phase: "done", lastPoint: null } as ExDone,
{ draft: null, done: true },
];
/** Höhe entgegengenommen → Verjüngungs-Schritt betreten (HUD zeigt Default). */
const enterTaperPhase = (
profile: Profile,
height: number,
sourceId: string,
): [CommandState, CommandResult] => [
{ phase: "taper", profile, sourceId, height, cursor: null } as ExTaper,
{ draft: null },
];
const commitTaper = (
profile: Profile,
height: number,
taper: number,
sourceId: string,
ctx: CommandContext,
): [CommandState, CommandResult] => [
{ phase: "done", lastPoint: null } as ExDone,
{ draft: null, done: true, commit: (p) => appendExtrudedSolid(p, profile, height, taper, sourceId, ctx) },
];
export const extrudeCommand: Command = {
name: "extrude",
labelKey: "cmd.extrude.label",
prompt: (s) => {
const phase = (s as ExState).phase;
if (phase === "height") return "cmd.extrude.height";
if (phase === "taper") return "cmd.extrude.taper";
return "cmd.extrude.pick";
},
// Auch der „pick"-Schritt nimmt eine Zahl an, damit eine getippte Höhe sofort
// greift, wenn ein Profil vorselektiert ist (dann wird „pick" live wie
// „height" behandelt — analog offset.ts).
accepts: () => ["point", "number"],
options: () => [],
floorOnly: true,
init: (): ExPick => ({ phase: "pick", lastPoint: null }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as ExState;
if (s.phase === "done") return finish();
if (s.phase === "pick") {
const pre = singleSelectedProfile(ctx.project, ctx.selection);
if (pre) {
if (input.kind === "number") {
const h = Math.abs(input.value);
if (h < 1e-6) return [s, { draft: null }];
lastHeight = h;
return enterTaperPhase(pre, h, pre.d.id);
}
// Punkt im vorselektierten Fall: Höhen-Schritt betreten (HUD zeigt Default).
const ns: ExHeight = { phase: "height", profile: pre, sourceId: pre.d.id, cursor: null, lastPoint: null };
return [ns, { draft: null }];
}
if (input.kind === "point") {
const hit = pickProfileAt(ctx.project, ctx.level.id, input.point);
if (!hit) return [s, { draft: null }];
const ns: ExHeight = { phase: "height", profile: hit, sourceId: hit.d.id, cursor: null, lastPoint: null };
return [ns, { draft: null }];
}
return [s, { draft: null }];
}
if (s.phase === "height") {
if (input.kind === "number") {
const h = Math.abs(input.value);
if (h < 1e-6) return [s, { draft: null }];
lastHeight = h;
return enterTaperPhase(s.profile, h, s.sourceId);
}
return [s, { draft: null }];
}
// phase === "taper"
const ts = s as ExTaper;
if (input.kind === "number") {
const t = Math.min(1, Math.max(0, input.value));
lastTaper = t;
return commitTaper(ts.profile, ts.height, t, ts.sourceId, ctx);
}
return [ts, { draft: null }];
},
onMove: (state, point): [CommandState, CommandResult] => {
const s = state as ExState;
if (s.phase === "height") {
const ns: ExHeight = { ...s, cursor: point };
return [
ns,
{
draft: {
preview: [{ kind: "poly", pts: s.profile.pts, closed: true }],
vertices: [],
hud: { at: point, text: `${lastHeight.toFixed(2)} m` },
},
},
];
}
if (s.phase === "taper") {
const ns: ExTaper = { ...s, cursor: point };
return [
ns,
{
draft: {
preview: [{ kind: "poly", pts: s.profile.pts, closed: true }],
vertices: [],
hud: { at: point, text: lastTaper.toFixed(2) },
},
},
];
}
return [s, { draft: null }];
},
// Enter/Rechtsklick: Höhen-Schritt → Default-Höhe übernehmen + Verjüngungs-
// Schritt betreten; Verjüngungs-Schritt → Default-Verjüngung übernehmen + committen.
onConfirm: (state, ctx): [CommandState, CommandResult] => {
const s = state as ExState;
if (s.phase === "height") return enterTaperPhase(s.profile, lastHeight, s.sourceId);
if (s.phase === "taper") return commitTaper(s.profile, s.height, lastTaper, s.sourceId, ctx);
return finish();
},
onCancel: (): [CommandState, CommandResult] => finish(),
};
+74
View File
@@ -0,0 +1,74 @@
// Messen — schnelles, unverbindliches Distanz-/Winkel-Messwerkzeug (kein Element
// wird erzeugt). Schritte:
// 1) „Messen von:" → Punkt (Startpunkt)
// 2) „bis:" → Live-Vorschau (Messlinie + Distanz·Winkel im HUD); Klick
// setzt einen neuen Startpunkt (fortlaufend messen).
// Esc/Enter beendet. Rein visuell: `commit` bleibt immer leer, das Modell ändert
// sich nie — nur der Entwurf (Linie + HUD) zeigt das Mass.
import type {
Command,
CommandResult,
CommandState,
ToolDraft,
Vec2,
} from "../types";
const segLen = (a: Vec2, b: Vec2): number => Math.hypot(b.x - a.x, b.y - a.y);
const segAngleDeg = (a: Vec2, b: Vec2): number =>
(Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI;
interface MeasureStart extends CommandState {
phase: "start";
}
interface MeasureTo extends CommandState {
phase: "to";
from: Vec2;
cursor: Vec2 | null;
}
type MeasureState = MeasureStart | MeasureTo;
/** Vorschau: Messlinie from→cursor + Distanz·Winkel im HUD am Cursor. */
function measureDraft(from: Vec2, cursor: Vec2): ToolDraft {
const dist = segLen(from, cursor);
const ang = ((segAngleDeg(from, cursor) % 360) + 360) % 360;
return {
preview: [{ kind: "line", a: from, b: cursor }],
vertices: [from, cursor],
hud: { at: cursor, text: `${dist.toFixed(3)} m · ${ang.toFixed(1)}°` },
};
}
const idle = (): [CommandState, CommandResult] => [
{ phase: "start", lastPoint: null } as MeasureStart,
{ draft: null, done: true },
];
export const measureCommand: Command = {
name: "measure",
labelKey: "cmd.measure.label",
prompt: (s) => ((s as MeasureState).phase === "to" ? "cmd.measure.to" : "cmd.measure.start"),
accepts: () => ["point"],
options: () => [],
init: (): MeasureStart => ({ phase: "start", lastPoint: null }),
onInput: (state, input): [CommandState, CommandResult] => {
const s = state as MeasureState;
if (input.kind !== "point") {
return [s, { draft: s.phase === "to" && s.cursor ? measureDraft(s.from, s.cursor) : null }];
}
// Jeder Klick setzt den (neuen) Startpunkt — fortlaufendes Messen, bis Esc/Enter.
const ns: MeasureTo = { phase: "to", from: input.point, cursor: input.point, lastPoint: input.point };
return [ns, { draft: measureDraft(input.point, input.point) }];
},
onMove: (state, point): [CommandState, CommandResult] => {
const s = state as MeasureState;
if (s.phase !== "to") return [s, { draft: null }];
const ns: MeasureTo = { ...s, cursor: point };
return [ns, { draft: measureDraft(s.from, point) }];
},
onConfirm: (): [CommandState, CommandResult] => idle(),
onCancel: (): [CommandState, CommandResult] => idle(),
};
+12 -2
View File
@@ -32,12 +32,22 @@ const EPS = 1e-6;
/** Hat die Auswahl überhaupt etwas Spiegelbares? */
function hasSelection(sel: CommandSelection): boolean {
return sel.wallIds.length > 0 || sel.drawingId !== null;
return (
sel.wallIds.length > 0 ||
sel.drawingId !== null ||
!!sel.extrudedSolidId ||
!!sel.columnId
);
}
/** Auswahl → TransformSelection (gleiche Form). */
function toTransformSel(sel: CommandSelection): TransformSelection {
return { wallIds: sel.wallIds, drawingId: sel.drawingId };
return {
wallIds: sel.wallIds,
drawingId: sel.drawingId,
extrudedSolidId: sel.extrudedSolidId,
columnId: sel.columnId,
};
}
// Zustand: erst ersten Achsenpunkt setzen, dann den zweiten (mit Auswahl-Snapshot).
+12 -2
View File
@@ -49,12 +49,22 @@ function targetFromFields(base: Vec2, locks: Record<string, number>, cursor: Vec
/** Hat die Auswahl überhaupt etwas Verschiebbares? */
function hasSelection(sel: CommandSelection): boolean {
return sel.wallIds.length > 0 || sel.drawingId !== null;
return (
sel.wallIds.length > 0 ||
sel.drawingId !== null ||
!!sel.extrudedSolidId ||
!!sel.columnId
);
}
/** Auswahl → TransformSelection (gleiche Form). */
function toTransformSel(sel: CommandSelection): TransformSelection {
return { wallIds: sel.wallIds, drawingId: sel.drawingId };
return {
wallIds: sel.wallIds,
drawingId: sel.drawingId,
extrudedSolidId: sel.extrudedSolidId,
columnId: sel.columnId,
};
}
// Zustand: erst Basispunkt setzen, dann Ziel (mit Auswahl-Snapshot vom Start).
+76
View File
@@ -0,0 +1,76 @@
// Text — modellverankerter Einzeltext als 2D-Element. Schritte:
// 1) „Ankerpunkt:" → Punkt (Position im Modell)
// 2) „Text:" → getippte Zeile (das Label) → commit Drawing2D {shape:"text"}
//
// Der Text-Schritt nimmt Freitext an (accepts:["text"]) — die Engine reicht die
// komplette getippte Zeile 1:1 durch (auch Zahlen/Kommas). Gerendert wird der
// Text in generatePlan als `drawingText` (SVG), analog zum DXF-Import.
import type { Drawing2D } from "../../model/types";
import { uniqueId } from "../../tools/types";
import type {
Command,
CommandContext,
CommandResult,
CommandState,
Project,
Vec2,
} from "../types";
/** Default-Schrifthöhe eines frei platzierten Texts in Modell-Metern. */
const DEFAULT_TEXT_HEIGHT_M = 0.25;
interface TextIdle extends CommandState {
phase: "point";
}
interface TextLabel extends CommandState {
phase: "label";
at: Vec2;
}
type TextState = TextIdle | TextLabel;
function appendText(p: Project, at: Vec2, text: string, ctx: CommandContext): Project {
const label = text.trim();
if (label === "") return p;
const d: Drawing2D = {
id: uniqueId("dr2d"),
type: "drawing2d",
levelId: ctx.level.id,
categoryCode: ctx.defaultCategoryCode,
geom: { shape: "text", at, text: label, height: DEFAULT_TEXT_HEIGHT_M, angle: 0 },
};
return { ...p, drawings2d: [...p.drawings2d, d] };
}
const idle = (): [CommandState, CommandResult] => [
{ phase: "point", lastPoint: null },
{ draft: null, done: true },
];
export const textCommand: Command = {
name: "text",
labelKey: "cmd.text.label",
prompt: (s) => ((s as TextState).phase === "label" ? "cmd.text.enter" : "cmd.text.point"),
accepts: (s) => ((s as TextState).phase === "label" ? ["text"] : ["point"]),
options: () => [],
init: (): TextIdle => ({ phase: "point", lastPoint: null }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as TextState;
if (s.phase !== "label") {
if (input.kind !== "point") return [s, { draft: null }];
const ns: TextLabel = { phase: "label", at: input.point, lastPoint: input.point };
return [ns, { draft: null }];
}
if (input.kind !== "text") return [s, { draft: null }];
const at = s.at;
return [
{ phase: "point", lastPoint: null },
{ draft: null, done: true, commit: (p) => appendText(p, at, input.text, ctx) },
];
},
onMove: (state): [CommandState, CommandResult] => [state, { draft: null }],
onConfirm: (): [CommandState, CommandResult] => idle(),
onCancel: (): [CommandState, CommandResult] => idle(),
};
+127
View File
@@ -0,0 +1,127 @@
// Textspalte (Absatztext) — wie das Text-Werkzeug, aber mit einer Spaltenbreite:
// der Text bricht beim Rendern wortweise auf diese Breite um. Schritte:
// 1) „Ankerpunkt:" → Punkt (oben-links der Spalte)
// 2) „Spaltenbreite:" → Punkt (die horizontale Distanz zum Anker = Breite;
// Live-Vorschau der Breitenlinie)
// 3) „Text:" → getippte Zeile → commit Drawing2D {shape:"text", width}
//
// Reuse des bestehenden {shape:"text"}-Elements (mit `width`) — so erben Selektion,
// Verschieben und Griff automatisch vom Einzeltext; nur die Breite kommt hinzu.
import type { Drawing2D } from "../../model/types";
import { uniqueId } from "../../tools/types";
import type {
Command,
CommandContext,
CommandResult,
CommandState,
Project,
ToolDraft,
Vec2,
} from "../types";
/** Default-Schrifthöhe einer Textspalte in Modell-Metern. */
const DEFAULT_TEXT_HEIGHT_M = 0.25;
/** Kleinste sinnvolle Spaltenbreite (Meter). */
const MIN_WIDTH_M = 0.2;
interface TbPoint extends CommandState {
phase: "point";
}
interface TbWidth extends CommandState {
phase: "width";
at: Vec2;
cursor: Vec2 | null;
}
interface TbLabel extends CommandState {
phase: "label";
at: Vec2;
width: number;
}
type TbState = TbPoint | TbWidth | TbLabel;
/** Horizontale Spaltenbreite aus Anker + Cursor (Betrag der x-Differenz, geklemmt). */
function widthOf(at: Vec2, cursor: Vec2): number {
return Math.max(MIN_WIDTH_M, Math.abs(cursor.x - at.x));
}
/** Vorschau: waagrechte Breitenlinie am Anker (zeigt die Spaltenbreite). */
function widthDraft(at: Vec2, cursor: Vec2): ToolDraft {
const w = widthOf(at, cursor);
const b: Vec2 = { x: at.x + w, y: at.y };
return {
preview: [{ kind: "line", a: at, b }],
vertices: [at, b],
hud: { at: b, text: `${w.toFixed(2)} m` },
};
}
function appendTextbox(
p: Project,
at: Vec2,
width: number,
text: string,
ctx: CommandContext,
): Project {
const label = text.trim();
if (label === "") return p;
const d: Drawing2D = {
id: uniqueId("dr2d"),
type: "drawing2d",
levelId: ctx.level.id,
categoryCode: ctx.defaultCategoryCode,
geom: { shape: "text", at, text: label, height: DEFAULT_TEXT_HEIGHT_M, angle: 0, width },
};
return { ...p, drawings2d: [...p.drawings2d, d] };
}
const idle = (): [CommandState, CommandResult] => [
{ phase: "point", lastPoint: null } as TbPoint,
{ draft: null, done: true },
];
export const textboxCommand: Command = {
name: "textbox",
labelKey: "cmd.textbox.label",
prompt: (s) => {
const ts = s as TbState;
if (ts.phase === "label") return "cmd.textbox.enter";
if (ts.phase === "width") return "cmd.textbox.width";
return "cmd.textbox.point";
},
accepts: (s) => ((s as TbState).phase === "label" ? ["text"] : ["point"]),
options: () => [],
init: (): TbPoint => ({ phase: "point", lastPoint: null }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as TbState;
if (s.phase === "point") {
if (input.kind !== "point") return [s, { draft: null }];
const ns: TbWidth = { phase: "width", at: input.point, cursor: input.point, lastPoint: input.point };
return [ns, { draft: widthDraft(input.point, input.point) }];
}
if (s.phase === "width") {
if (input.kind !== "point") return [s, { draft: s.cursor ? widthDraft(s.at, s.cursor) : null }];
const width = widthOf(s.at, input.point);
const ns: TbLabel = { phase: "label", at: s.at, width, lastPoint: input.point };
return [ns, { draft: null }];
}
// label
if (input.kind !== "text") return [s, { draft: null }];
const { at, width } = s;
return [
{ phase: "point", lastPoint: null } as TbPoint,
{ draft: null, done: true, commit: (p) => appendTextbox(p, at, width, input.text, ctx) },
];
},
onMove: (state, point): [CommandState, CommandResult] => {
const s = state as TbState;
if (s.phase !== "width") return [s, { draft: null }];
const ns: TbWidth = { ...s, cursor: point };
return [ns, { draft: widthDraft(s.at, point) }];
},
onConfirm: (): [CommandState, CommandResult] => idle(),
onCancel: (): [CommandState, CommandResult] => idle(),
};
+8
View File
@@ -328,6 +328,14 @@ export class CommandEngine {
}
}
// Freitext-Schritt (z. B. Text-Platzierung): der Schritt nimmt beliebigen
// Text als Label — die komplette getippte Zeile geht 1:1 durch (auch Zahlen/
// Kommas), bevor die numerische/Koordinaten-Auflösung greift.
if (accepts.includes("text")) {
this.feed({ kind: "text", text });
return;
}
const parsed = parseInput(text);
// Tab-Feld-Modus (§2.7): eine nackte Zahl LOCKT das aktive Feld (statt als
+16
View File
@@ -10,18 +10,23 @@ import { wallCommand } from "./cmds/wall";
import { ceilingCommand } from "./cmds/ceiling";
import { openingCommand, fensterCommand, tuerCommand } from "./cmds/opening";
import { stairCommand } from "./cmds/stair";
import { columnCommand } from "./cmds/column";
import { roomCommand } from "./cmds/room";
import { rectCommand } from "./cmds/rect";
import { circleCommand } from "./cmds/circle";
import { arcCommand } from "./cmds/arc";
import { textCommand } from "./cmds/text";
import { textboxCommand } from "./cmds/textbox";
import { moveCommand } from "./cmds/move";
import { mirrorCommand } from "./cmds/mirror";
import { joinCommand } from "./cmds/join";
import { copyCommand } from "./cmds/copy";
import { offsetCommand } from "./cmds/offset";
import { extrudeCommand } from "./cmds/extrude";
import { trimCommand } from "./cmds/trim";
import { importCommand } from "./cmds/import";
import { terrainCommand } from "./cmds/terrain";
import { measureCommand } from "./cmds/measure";
/** Alle bekannten Befehle, Schlüssel = Befehlsname (lowercase). */
export const COMMANDS: Record<string, Command> = {
@@ -31,20 +36,25 @@ export const COMMANDS: Record<string, Command> = {
fenster: fensterCommand,
tuer: tuerCommand,
stair: stairCommand,
column: columnCommand,
room: roomCommand,
line: lineCommand,
polyline: polylineCommand,
rect: rectCommand,
circle: circleCommand,
arc: arcCommand,
text: textCommand,
textbox: textboxCommand,
move: moveCommand,
mirror: mirrorCommand,
join: joinCommand,
copy: copyCommand,
offset: offsetCommand,
extrude: extrudeCommand,
trim: trimCommand,
import: importCommand,
terrain: terrainCommand,
measure: measureCommand,
};
/**
@@ -63,6 +73,8 @@ export const ALIASES: Record<string, string> = {
oe: "opening",
treppe: "stair",
tp: "stair",
stuetze: "column",
stütze: "column",
raum: "room",
rm: "room",
l: "line",
@@ -71,6 +83,9 @@ export const ALIASES: Record<string, string> = {
c: "circle",
a: "arc",
bogen: "arc",
tx: "text",
txt: "text",
beschriftung: "text",
m: "move",
s: "mirror",
spiegeln: "mirror",
@@ -78,6 +93,7 @@ export const ALIASES: Record<string, string> = {
verbinden: "join",
cp: "copy",
o: "offset",
ex: "extrude",
tr: "trim",
imp: "import",
ter: "terrain",
+5 -1
View File
@@ -19,7 +19,7 @@ export type { DraftShape, SnapResult, ToolDraft } from "../tools/types";
// ── Eingabe-Arten ────────────────────────────────────────────────────────────
/** Arten von Eingaben, die ein Schritt annehmen kann. */
export type AcceptKind = "point" | "number" | "option" | "selection";
export type AcceptKind = "point" | "number" | "option" | "text" | "selection";
/**
* Geparste, noch nicht aufgelöste Roh-Eingabe aus der Command-Line bzw. der
@@ -67,6 +67,10 @@ export interface CommandSelection {
* `drawingId` (falls gesetzt) als einziges Element.
*/
drawingIds?: string[];
/** Gewählter extrudierter Körper (truck-Integration), falls einer selektiert ist. */
extrudedSolidId?: string | null;
/** Gewählte Stütze (Tragwerk), falls eine selektiert ist. */
columnId?: string | null;
}
/** Live-Kontext, den ein Befehl bei jedem Schritt erhält (analog ToolContext). */