From 340f950f9ca86c882f9d130128b09b7d7ba495bd Mon Sep 17 00:00:00 2001 From: Karim Date: Sat, 11 Jul 2026 00:12:08 +0200 Subject: [PATCH] Schnittlinien-Werkzeug: Befehl sectionline/viewline setzt linePoints Zwei Klicks (Start -> Ende) legen die Grundriss-Schnitt-/Ansichtslinie einer DrawingLevel fest. Ziel: genau eine Platzhalter-Ebene ohne Linie wird belegt, sonst neue Ebene (Schnitt B/C ...). Live-Vorschau mit Richtungspfeil, Art (Schnitt/Ansicht) als Inline-Option umschaltbar. Registry-Aliase schnittlinie/ ansichtslinie, Ribbon-Gruppe 'Schnitte' im BIM-Tab, i18n de/en, Datenpfad-Tests. --- src/commands/cmds/sectionline.test.ts | 100 ++++++++++++ src/commands/cmds/sectionline.ts | 213 ++++++++++++++++++++++++++ src/commands/registry.ts | 5 + src/i18n/de.ts | 8 + src/i18n/en.ts | 8 + src/model/types.ts | 7 + src/ui/ribbon/ribbonItems.ts | 4 + 7 files changed, 345 insertions(+) create mode 100644 src/commands/cmds/sectionline.test.ts create mode 100644 src/commands/cmds/sectionline.ts diff --git a/src/commands/cmds/sectionline.test.ts b/src/commands/cmds/sectionline.test.ts new file mode 100644 index 0000000..23b9c3b --- /dev/null +++ b/src/commands/cmds/sectionline.test.ts @@ -0,0 +1,100 @@ +// Schnittlinien-Befehl: der reine Datenpfad (commitLine/nextLevelName) — setzt +// die Grundriss-Schnittlinie auf die richtige Ziel-Ebene bzw. legt eine neue an. + +import { describe, it, expect } from "vitest"; +import { _test } from "./sectionline"; +import type { DrawingLevel, Project } from "../../model/types"; + +const { commitLine, nextLevelName } = _test; + +function projectWith(levels: DrawingLevel[]): Project { + return { + id: "p", + name: "T", + layers: [], + drawingLevels: levels, + walls: [], + drawings2d: [], + } as unknown as Project; +} + +const a = { x: 0, y: 0 }; +const b = { x: 5, y: 0 }; + +describe("sectionline — commitLine", () => { + it("belegt die EINE Platzhalter-Schnittebene ohne Linie", () => { + const p = projectWith([ + { id: "eg", name: "EG", kind: "floor", visible: true, locked: false }, + { id: "s1", name: "Schnitt A", kind: "section", visible: true, locked: false }, + ]); + const out = commitLine(p, a, b, "section"); + const s = out.drawingLevels.find((z) => z.id === "s1")!; + expect(s.linePoints).toEqual([a, b]); + expect(s.directionSign).toBe(1); + // Keine neue Ebene angelegt. + expect(out.drawingLevels).toHaveLength(2); + }); + + it("legt eine NEUE Schnittebene an, wenn keine Platzhalter-Ebene existiert", () => { + const p = projectWith([ + { id: "eg", name: "EG", kind: "floor", visible: true, locked: false }, + { + id: "s1", + name: "Schnitt A", + kind: "section", + visible: true, + locked: false, + linePoints: [{ x: 1, y: 1 }, { x: 2, y: 2 }], + }, + ]); + const out = commitLine(p, a, b, "section"); + expect(out.drawingLevels).toHaveLength(3); + const created = out.drawingLevels[out.drawingLevels.length - 1]; + expect(created.kind).toBe("section"); + expect(created.linePoints).toEqual([a, b]); + // Zweite Ebene → Buchstabe B. + expect(created.name).toContain("B"); + }); + + it("legt bei ZWEI Platzhaltern (mehrdeutig) eine neue Ebene an", () => { + const p = projectWith([ + { id: "s1", name: "Schnitt A", kind: "section", visible: true, locked: false }, + { id: "s2", name: "Schnitt B", kind: "section", visible: true, locked: false }, + ]); + const out = commitLine(p, a, b, "section"); + expect(out.drawingLevels).toHaveLength(3); + // Die bestehenden Platzhalter bleiben unangetastet. + expect(out.drawingLevels[0].linePoints).toBeUndefined(); + expect(out.drawingLevels[1].linePoints).toBeUndefined(); + }); + + it("verwirft entartete (zu kurze) Linien", () => { + const p = projectWith([ + { id: "s1", name: "Schnitt A", kind: "section", visible: true, locked: false }, + ]); + const out = commitLine(p, a, { x: 0.02, y: 0 }, "section"); + expect(out).toBe(p); + }); + + it("trennt Schnitt- und Ansichts-Ebenen (kind)", () => { + const p = projectWith([ + { id: "s1", name: "Schnitt A", kind: "section", visible: true, locked: false }, + ]); + // Ansichtslinie darf die Schnitt-Platzhalter-Ebene NICHT belegen. + const out = commitLine(p, a, b, "elevation"); + expect(out.drawingLevels).toHaveLength(2); + expect(out.drawingLevels[0].linePoints).toBeUndefined(); + expect(out.drawingLevels[1].kind).toBe("elevation"); + }); +}); + +describe("sectionline — nextLevelName", () => { + it("zählt Buchstaben je Art hoch (A, B, C …)", () => { + const p0 = projectWith([]); + expect(nextLevelName(p0, "section")).toContain("A"); + const p1 = projectWith([ + { id: "s1", name: "Schnitt A", kind: "section", visible: true, locked: false }, + ]); + expect(nextLevelName(p1, "section")).toContain("B"); + }); +}); diff --git a/src/commands/cmds/sectionline.ts b/src/commands/cmds/sectionline.ts new file mode 100644 index 0000000..d085ea6 --- /dev/null +++ b/src/commands/cmds/sectionline.ts @@ -0,0 +1,213 @@ +// Schnittlinie — setzt die Grundriss-Schnitt-/Ansichtslinie einer Zeichnungsebene +// (DrawingLevel, kind "section"/"elevation") per zwei Klicks (Start → Ende). Die +// Linie steuert `sectionPlaneFromLevel` (plan/toSection.ts): senkrecht auf ihr +// steht die Blickrichtung (directionSign, Voreinstellung +1). +// +// Schritte: +// 1) „Schnitt-Startpunkt:" → Punkt +// 2) „Schnitt-Endpunkt:" → Live-Linie + Richtungspfeil; Klick committet. +// +// Ziel-Ebene (im Commit bestimmt): existiert GENAU EINE Ebene der gewählten Art +// OHNE gesetzte Linie (frisch angelegter Platzhalter), wird deren Linie gesetzt; +// sonst wird eine NEUE Ebene angelegt (Name „Schnitt B/C…" bzw. „Ansicht …"). +// +// Die Art (Schnitt/Ansicht) ist als Inline-Option umschaltbar — dieselbe Mechanik +// für beide, nur `kind` unterscheidet sich (Alias `viewline` startet mit +// „elevation"). Bezeichner englisch, sichtbarer Text via t(). + +import { t } from "../../i18n"; +import type { DrawingLevel, DrawingLevelKind } from "../../model/types"; +import { leftNormal, normalize, sub } from "../../model/geometry"; +import type { + Command, + CommandResult, + CommandState, + CmdOption, + DraftShape, + Project, + ToolDraft, + Vec2, +} from "../types"; + +/** Nur Schnitt/Ansicht tragen eine Linie. */ +type LineKind = "section" | "elevation"; + +/** Wählbare Art (Inline-Optionen der Befehlszeile). */ +const KIND_OPTIONS: CmdOption[] = [ + { id: "section", labelKey: "cmd.sectionline.kind.section" }, + { id: "elevation", labelKey: "cmd.sectionline.kind.elevation" }, +]; +const KIND_IDS = new Set(KIND_OPTIONS.map((o) => o.id)); + +/** + * Richtungspfeil-Vorschau: kurze Linie vom Mittelpunkt der Schnittlinie entlang + * der Blicknormalen (leftNormal der Linie, per directionSign gedreht) plus zwei + * Flügel-Segmente — zeigt, wohin der Schnitt „blickt". + */ +function directionArrow(a: Vec2, b: Vec2, sign: 1 | -1): DraftShape[] { + const dir = sub(b, a); + const len = Math.hypot(dir.x, dir.y); + if (len < 1e-6) return []; + const u = normalize(dir); + const n = leftNormal(u); // (-uy, ux) + const nx = n.x * sign; + const ny = n.y * sign; + const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }; + // Pfeillänge ~ min(1 m, Viertel der Linienlänge), damit die Vorschau lesbar bleibt. + const arrowLen = Math.min(1, len / 4); + const tip = { x: mid.x + nx * arrowLen, y: mid.y + ny * arrowLen }; + const wing = arrowLen * 0.35; + // Flügel: vom Spitzenpunkt zurück, je halb entlang −u und +u geneigt. + const back = { x: tip.x - nx * wing * 2, y: tip.y - ny * wing * 2 }; + const w1 = { x: back.x + u.x * wing, y: back.y + u.y * wing }; + const w2 = { x: back.x - u.x * wing, y: back.y - u.y * wing }; + return [ + { kind: "line", a: mid, b: tip }, + { kind: "line", a: tip, b: w1 }, + { kind: "line", a: tip, b: w2 }, + ]; +} + +/** Vorschau: Schnittlinie + Richtungspfeil + Länge im HUD. */ +function lineDraft(a: Vec2, cursor: Vec2 | null, sign: 1 | -1): ToolDraft { + if (!cursor) return { preview: [{ kind: "poly", pts: [a] }], vertices: [a] }; + const preview: DraftShape[] = [ + { kind: "line", a, b: cursor }, + ...directionArrow(a, cursor, sign), + ]; + const draft: ToolDraft = { preview, vertices: [a] }; + draft.hud = { + at: cursor, + text: `${Math.hypot(cursor.x - a.x, cursor.y - a.y).toFixed(2)} m`, + }; + return draft; +} + +/** Nächster freier Ebenen-Name der Art (A, B, C … über die Anzahl bestehender). */ +function nextLevelName(p: Project, kind: LineKind): string { + const count = p.drawingLevels.filter((z) => z.kind === kind).length; + // Buchstaben-Suffix (A, B, …, Z, dann AA…) — klassische Schnitt-Benennung. + let n = count; + let letters = ""; + do { + letters = String.fromCharCode(65 + (n % 26)) + letters; + n = Math.floor(n / 26) - 1; + } while (n >= 0); + const key = kind === "section" ? "cmd.sectionline.newSection" : "cmd.sectionline.newElevation"; + return t(key, { letter: letters }); +} + +/** + * Setzt die Schnittlinie [a, b] auf die Ziel-Ebene (immutabel). Existiert GENAU + * EINE Ebene der Art ohne Linie, wird sie belegt; sonst wird eine neue angelegt. + * Entartete (zu kurze) Linien werden verworfen. + */ +function commitLine(p: Project, a: Vec2, b: Vec2, kind: LineKind): Project { + if (Math.hypot(b.x - a.x, b.y - a.y) < 0.1) return p; + const line: [Vec2, Vec2] = [a, b]; + const orphans = p.drawingLevels.filter((z) => z.kind === kind && !z.linePoints); + if (orphans.length === 1) { + const targetId = orphans[0].id; + return { + ...p, + drawingLevels: p.drawingLevels.map((z) => + z.id === targetId ? { ...z, linePoints: line, directionSign: z.directionSign ?? 1 } : z, + ), + }; + } + const level: DrawingLevel = { + id: `${kind}-${Date.now()}`, + name: nextLevelName(p, kind), + kind: kind as DrawingLevelKind, + visible: true, + locked: false, + linePoints: line, + directionSign: 1, + }; + return { ...p, drawingLevels: [...p.drawingLevels, level] }; +} + +interface LineIdle extends CommandState { + phase: "start"; + kind: LineKind; +} +interface LineEnd extends CommandState { + phase: "end"; + a: Vec2; + cursor: Vec2 | null; + kind: LineKind; +} +type LineState = LineIdle | LineEnd; + +const DIRECTION_SIGN: 1 = 1; + +function makeCommand(name: string, startKind: LineKind): Command { + const idle = (kind: LineKind): [CommandState, CommandResult] => [ + { phase: "start", kind, lastPoint: null } as LineIdle, + { draft: null, done: true }, + ]; + return { + name, + labelKey: "cmd.sectionline.label", + // Nicht floorOnly: eine Schnittlinie zieht man IM Grundriss, wo Bauteile + // liegen — die aktive Ebene ist dann ein Geschoss. Aber der Befehl soll auch + // auf freien Zeichnungsebenen nutzbar bleiben; die Prüfung übernimmt der + // Grundriss-Kontext (keine harte Sperre nötig). + floorOnly: true, + prompt: (s) => ((s as LineState).phase === "end" ? "cmd.sectionline.end" : "cmd.sectionline.start"), + accepts: () => ["point", "option"], + options: () => KIND_OPTIONS, + init: (): LineIdle => ({ phase: "start", kind: startKind, lastPoint: null }), + + onInput: (state, input): [CommandState, CommandResult] => { + const s = state as LineState; + if (input.kind === "option" && KIND_IDS.has(input.id)) { + const ns = { ...s, kind: input.id as LineKind } as LineState; + const draft = + ns.phase === "end" ? lineDraft(ns.a, ns.cursor, DIRECTION_SIGN) : null; + return [ns, { draft }]; + } + if (input.kind !== "point") { + return [ + s, + { draft: s.phase === "end" ? lineDraft(s.a, s.cursor, DIRECTION_SIGN) : null }, + ]; + } + if (s.phase !== "end") { + const ns: LineEnd = { + phase: "end", + a: input.point, + cursor: input.point, + kind: s.kind, + lastPoint: input.point, + }; + return [ns, { draft: lineDraft(input.point, input.point, DIRECTION_SIGN) }]; + } + const a = s.a; + const b = input.point; + const kind = s.kind; + return [ + { phase: "start", kind, lastPoint: null } as LineIdle, + { draft: null, done: true, commit: (p) => commitLine(p, a, b, kind) }, + ]; + }, + + onMove: (state, point): [CommandState, CommandResult] => { + const s = state as LineState; + if (s.phase !== "end") return [s, { draft: null }]; + const ns: LineEnd = { ...s, cursor: point }; + return [ns, { draft: lineDraft(s.a, point, DIRECTION_SIGN) }]; + }, + + onConfirm: (state): [CommandState, CommandResult] => idle((state as LineState).kind), + onCancel: (state): [CommandState, CommandResult] => idle((state as LineState).kind), + }; +} + +/** Schnittlinie (startet als „section"). */ +export const sectionLineCommand: Command = makeCommand("sectionline", "section"); +/** Ansichtslinie (startet als „elevation"; identische Mechanik). */ +export const viewLineCommand: Command = makeCommand("viewline", "elevation"); + +// Für Tests: die reinen Datenpfad-Helfer. +export const _test = { commitLine, nextLevelName, directionArrow }; diff --git a/src/commands/registry.ts b/src/commands/registry.ts index 581dd03..8ffcf80 100644 --- a/src/commands/registry.ts +++ b/src/commands/registry.ts @@ -29,6 +29,7 @@ import { trimCommand } from "./cmds/trim"; import { importCommand } from "./cmds/import"; import { terrainCommand } from "./cmds/terrain"; import { measureCommand } from "./cmds/measure"; +import { sectionLineCommand, viewLineCommand } from "./cmds/sectionline"; /** Alle bekannten Befehle, Schlüssel = Befehlsname (lowercase). */ export const COMMANDS: Record = { @@ -59,6 +60,8 @@ export const COMMANDS: Record = { import: importCommand, terrain: terrainCommand, measure: measureCommand, + sectionline: sectionLineCommand, + viewline: viewLineCommand, }; /** @@ -105,6 +108,8 @@ export const ALIASES: Record = { tr: "trim", imp: "import", ter: "terrain", + schnittlinie: "sectionline", + ansichtslinie: "viewline", }; /** Liefert den Befehl zu einem exakten Namen (oder undefined). */ diff --git a/src/i18n/de.ts b/src/i18n/de.ts index 438c61b..d3f64ae 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -145,6 +145,7 @@ export const de = { "ribbon.group.draw": "Zeichnen", "ribbon.group.modify": "Ändern", "ribbon.group.components": "Bauteile", + "ribbon.group.sections": "Schnitte", "ribbon.group.custom": "Eigene Leiste", "ribbon.custom.add": "+ Hinzufügen", "ribbon.custom.empty": "Elemente über + Hinzufügen wählen", @@ -974,6 +975,13 @@ export const de = { "cmd.roof.shape.mansarde": "Mansarddach", "cmd.roof.shape.zelt": "Zeltdach", "cmd.roof.shape.flach": "Flachdach", + "cmd.sectionline.label": "Schnittlinie", + "cmd.sectionline.start": "Schnitt-Startpunkt:", + "cmd.sectionline.end": "Schnitt-Endpunkt:", + "cmd.sectionline.kind.section": "Schnitt", + "cmd.sectionline.kind.elevation": "Ansicht", + "cmd.sectionline.newSection": "Schnitt {letter}", + "cmd.sectionline.newElevation": "Ansicht {letter}", "cmd.opening.label": "Öffnung", "cmd.opening.windowLabel": "Fenster", "cmd.opening.doorLabel": "Tür", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index c0b65c3..fe572e6 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -144,6 +144,7 @@ export const en: Record = { "ribbon.group.draw": "Draw", "ribbon.group.modify": "Modify", "ribbon.group.components": "Components", + "ribbon.group.sections": "Sections", "ribbon.group.custom": "Custom bar", "ribbon.custom.add": "+ Add", "ribbon.custom.empty": "Pick elements via “+ Add”", @@ -964,6 +965,13 @@ export const en: Record = { "cmd.roof.shape.mansarde": "Mansard roof", "cmd.roof.shape.zelt": "Pyramid roof", "cmd.roof.shape.flach": "Flat roof", + "cmd.sectionline.label": "Section line", + "cmd.sectionline.start": "Section start point:", + "cmd.sectionline.end": "Section end point:", + "cmd.sectionline.kind.section": "Section", + "cmd.sectionline.kind.elevation": "Elevation", + "cmd.sectionline.newSection": "Section {letter}", + "cmd.sectionline.newElevation": "Elevation {letter}", "cmd.opening.label": "Opening", "cmd.opening.windowLabel": "Window", "cmd.opening.doorLabel": "Door", diff --git a/src/model/types.ts b/src/model/types.ts index c9c8705..35f95bc 100644 --- a/src/model/types.ts +++ b/src/model/types.ts @@ -763,6 +763,13 @@ export interface DrawingLevel { linePoints?: [Vec2, Vec2]; /** Blickrichtung relativ zur Schnittlinie (nur Schnitt/Ansicht). */ directionSign?: 1 | -1; + /** + * Schnitt-Tiefe in Metern (nur Schnitt/Ansicht): Bauteile weiter als `depth` + * HINTER der Schnittebene (in Blickrichtung) werden ausgeblendet. Fehlt der + * Wert, ist die Tiefe unbegrenzt (das gesamte Modell wird projiziert — heutiges + * Verhalten). + */ + depth?: number; } /** diff --git a/src/ui/ribbon/ribbonItems.ts b/src/ui/ribbon/ribbonItems.ts index 5fefe0a..5fe61d4 100644 --- a/src/ui/ribbon/ribbonItems.ts +++ b/src/ui/ribbon/ribbonItems.ts @@ -110,6 +110,10 @@ export const RIBBON_TABS: RibbonTab[] = [ t("room"), ], }, + { + titleKey: "ribbon.group.sections", + items: [c("sectionline"), c("viewline")], + }, ], }, {