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:
@@ -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(),
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
@@ -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(),
|
||||
};
|
||||
@@ -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).
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
@@ -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(),
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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). */
|
||||
|
||||
Reference in New Issue
Block a user