From 4c7fc99eb8176f7c5e9ee9fa5c87ef2a9595b708 Mon Sep 17 00:00:00 2001 From: Karim Date: Sat, 22 Aug 2026 02:22:47 +0200 Subject: [PATCH] =?UTF-8?q?2D:=20Bemassung=20=E2=80=94=20Massketten=20(Rei?= =?UTF-8?q?henbemassung)=20in=20einer=20Geste?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Das Bemassungs-Werkzeug sammelt jetzt beliebig viele Punkte wie eine Polylinie (Doppelklick/Enter beendet, "Zurück" nimmt den letzten Punkt zurück), statt nur zwei. EIN abschliessender Klick für die Lage der Masslinie gilt für die ganze Reihe (SIA 400 Figur 14) und erzeugt je Punktpaar ein eigenes, unabhängig editierbares Bemassungs-Element. --- src/commands/cmds/dimension.test.ts | 72 +++++++++- src/commands/cmds/dimension.ts | 212 +++++++++++++++++----------- src/i18n/de.ts | 6 +- src/i18n/en.ts | 6 +- 4 files changed, 205 insertions(+), 91 deletions(-) diff --git a/src/commands/cmds/dimension.test.ts b/src/commands/cmds/dimension.test.ts index 4656671..906a3a4 100644 --- a/src/commands/cmds/dimension.test.ts +++ b/src/commands/cmds/dimension.test.ts @@ -1,6 +1,8 @@ /** - * `dimensionCommand` — zwei Punkte + Lage der Masslinie → `shape:"dimension"`. - * Die Masszahl wird NICHT gespeichert (aus dist(a,b) beim Rendern abgeleitet). + * `dimensionCommand` — Punktreihe (wie Polylinie) + EIN Offset-Klick → + * eine oder mehrere `shape:"dimension"`-Elemente (Reihenbemassung, SIA 400 + * Figur 14). Die Masszahl wird NICHT gespeichert (aus dist(a,b) beim Rendern + * abgeleitet). */ import { describe, it, expect } from "vitest"; @@ -43,7 +45,7 @@ function makeCtx(p: Project): CommandContext { } describe("dimensionCommand", () => { - it("zwei Punkte + Offset-Klick (links positiv) → shape:dimension mit korrektem Vorzeichen", () => { + it("zwei Punkte + Enter + Offset-Klick (links positiv) → ein shape:dimension mit korrektem Vorzeichen", () => { const p = project(); const ctx = makeCtx(p); let [state] = dimensionCommand.onInput( @@ -52,6 +54,7 @@ describe("dimensionCommand", () => { ctx, ); [state] = dimensionCommand.onInput(state, { kind: "point", point: { x: 10, y: 0 } }, ctx); + [state] = dimensionCommand.onConfirm(state, ctx); // Cursor bei y=2 (oberhalb von a→b, entlang +x) → Links-Normale zeigt nach +y. const [, result] = dimensionCommand.onInput(state, { kind: "point", point: { x: 5, y: 2 } }, ctx); expect(result.commit).toBeDefined(); @@ -66,7 +69,7 @@ describe("dimensionCommand", () => { } }); - it("getippte Offset-Zahl committet direkt (kein Punkt-Klick nötig)", () => { + it("Fertig-Option (statt Enter) beendet die Punktreihe identisch", () => { const p = project(); const ctx = makeCtx(p); let [state] = dimensionCommand.onInput( @@ -74,14 +77,54 @@ describe("dimensionCommand", () => { { kind: "point", point: { x: 0, y: 0 } }, ctx, ); - [state] = dimensionCommand.onInput(state, { kind: "point", point: { x: 0, y: 5 } }, ctx); + [state] = dimensionCommand.onInput(state, { kind: "point", point: { x: 4, y: 0 } }, ctx); + [state] = dimensionCommand.onInput(state, { kind: "option", id: "finish" }, ctx); const [, result] = dimensionCommand.onInput(state, { kind: "number", value: 1.5 }, ctx); const next = result.commit!(p); const g = next.drawings2d[0].geom; if (g.shape === "dimension") expect(g.offset).toBe(1.5); }); - it("Null-Strecke (a===b) committet nichts", () => { + it("Kette aus drei Punkten (Reihenbemassung) → zwei Segmente mit demselben Offset", () => { + const p = project(); + const ctx = makeCtx(p); + let [state] = dimensionCommand.onInput( + dimensionCommand.init(), + { kind: "point", point: { x: 0, y: 0 } }, + ctx, + ); + [state] = dimensionCommand.onInput(state, { kind: "point", point: { x: 4, y: 0 } }, ctx); + [state] = dimensionCommand.onInput(state, { kind: "point", point: { x: 10, y: 0 } }, ctx); + [state] = dimensionCommand.onConfirm(state, ctx); + const [, result] = dimensionCommand.onInput(state, { kind: "number", value: 2 }, ctx); + const next = result.commit!(p); + expect(next.drawings2d.length).toBe(2); + const segs = next.drawings2d.map((d) => d.geom).filter((g) => g.shape === "dimension"); + expect(segs[0]).toMatchObject({ a: { x: 0, y: 0 }, b: { x: 4, y: 0 }, offset: 2 }); + expect(segs[1]).toMatchObject({ a: { x: 4, y: 0 }, b: { x: 10, y: 0 }, offset: 2 }); + }); + + it("Zurück-Option entfernt den letzten Punkt", () => { + const p = project(); + const ctx = makeCtx(p); + let [state] = dimensionCommand.onInput( + dimensionCommand.init(), + { kind: "point", point: { x: 0, y: 0 } }, + ctx, + ); + [state] = dimensionCommand.onInput(state, { kind: "point", point: { x: 4, y: 0 } }, ctx); + [state] = dimensionCommand.onInput(state, { kind: "point", point: { x: 9, y: 0 } }, ctx); + [state] = dimensionCommand.onInput(state, { kind: "option", id: "undo" }, ctx); + [state] = dimensionCommand.onConfirm(state, ctx); + const [, result] = dimensionCommand.onInput(state, { kind: "number", value: 1 }, ctx); + const next = result.commit!(p); + // Nach Zurück nur noch [0,0]→[4,0] übrig → ein Segment. + expect(next.drawings2d.length).toBe(1); + const g = next.drawings2d[0].geom; + if (g.shape === "dimension") expect(g.b).toEqual({ x: 4, y: 0 }); + }); + + it("entartetes Segment (identische Punkte) innerhalb der Kette wird übersprungen", () => { const p = project(); const ctx = makeCtx(p); let [state] = dimensionCommand.onInput( @@ -89,7 +132,22 @@ describe("dimensionCommand", () => { { kind: "point", point: { x: 3, y: 3 } }, ctx, ); - const [, result] = dimensionCommand.onInput(state, { kind: "point", point: { x: 3, y: 3 } }, ctx); + [state] = dimensionCommand.onInput(state, { kind: "point", point: { x: 3, y: 3 } }, ctx); + [state] = dimensionCommand.onConfirm(state, ctx); + const [, result] = dimensionCommand.onInput(state, { kind: "number", value: 1 }, ctx); + const next = result.commit!(p); + expect(next.drawings2d.length).toBe(0); + }); + + it("Enter mit nur einem Punkt bricht ohne commit ab", () => { + const p = project(); + const ctx = makeCtx(p); + const [state] = dimensionCommand.onInput( + dimensionCommand.init(), + { kind: "point", point: { x: 0, y: 0 } }, + ctx, + ); + const [, result] = dimensionCommand.onConfirm(state, ctx); expect(result.commit).toBeUndefined(); expect(result.done).toBe(true); }); diff --git a/src/commands/cmds/dimension.ts b/src/commands/cmds/dimension.ts index e5e8f92..f4eab46 100644 --- a/src/commands/cmds/dimension.ts +++ b/src/commands/cmds/dimension.ts @@ -1,21 +1,27 @@ -// Bemassung (lineare Masslinie, SIA 400 B.5.3) — misst den Abstand zweier -// Punkte. Schritte: -// 1) „Erster Punkt:" → Punkt (a) -// 2) „Zweiter Punkt:" → Punkt (b) — zeigt die Länge live -// 3) „Lage der Masslinie:" → Punkt/Zahl (Normalabstand zu a-b, -// links von a→b positiv) → commit (shape:"dimension") +// Bemassung (lineare Masslinie(n), SIA 400 B.5.3, Figur 14 „Reihenbemassung"). +// Wie eine Polylinie: beliebig viele Punkte, dann EIN weiterer Klick für die +// Lage der Masslinie(n) — erzeugt eine ganze Massketten in einer Geste. +// Schritte: +// 1) „Erster Punkt:" → Punkt +// 2) „Nächster Punkt ( Fertig Zurück ):" → Punkt … (Doppelklick/Enter +// beendet die Punktreihe, sobald ≥2 Punkte gesetzt sind) +// 3) „Lage der Masslinie:" → Punkt/Zahl (EIN Normalabstand +// für ALLE Segmente) → commit: je aufeinanderfolgendem Punktpaar EIN +// `shape:"dimension"`-Element (unabhängig editierbar, teilt nur den +// beim Zeichnen gewählten Offset). // // Die Masszahl wird beim Rendern aus dist(a,b) abgeleitet (nicht gespeichert) // — bleibt beim späteren Griff-Ziehen automatisch korrekt. import type { Drawing2D } from "../../model/types"; -import { uniqueId } from "../../tools/types"; +import { segmentHud, uniqueId } from "../../tools/types"; import type { Command, CommandContext, CommandField, CommandResult, CommandState, + CmdOption, DraftShape, Project, ToolDraft, @@ -34,41 +40,7 @@ function signedOffset(a: Vec2, b: Vec2, pt: Vec2): number { return (pt.x - a.x) * nx + (pt.y - a.y) * ny; } -interface DimStart extends CommandState { - phase: "start"; -} -interface DimEnd extends CommandState { - phase: "end"; - a: Vec2; - cursor: Vec2 | null; -} -interface DimOffset extends CommandState { - phase: "offset"; - a: Vec2; - b: Vec2; - cursor: Vec2 | null; -} -type DimState = DimStart | DimEnd | DimOffset; - -const OFFSET_FIELDS: CommandField[] = [{ id: "offset", labelKey: "cmd.field.offset" }]; - -/** Vorschau: Masshilfslinien + Masslinie (grob, ohne Massstriche/Text — Feinheiten rendert generatePlan). */ -function dimDraft(a: Vec2, b: Vec2, offset: number, at: Vec2 | null): ToolDraft { - const preview: DraftShape[] = []; - if (Math.abs(offset) > EPS) { - const off = offsetPoints(a, b, offset); - preview.push({ kind: "line", a, b: off.a }); - preview.push({ kind: "line", a: b, b: off.b }); - preview.push({ kind: "line", a: off.a, b: off.b }); - } else { - preview.push({ kind: "line", a, b }); - } - const draft: ToolDraft = { preview, vertices: [a, b] }; - if (at) draft.hud = { at, text: `${dist(a, b).toFixed(3)}m · Δ${offset.toFixed(3)}m` }; - return draft; -} - -/** Endpunkte der um `offset` parallel verschobenen Masslinie. */ +/** Endpunkte der um `offset` parallel verschobenen Masslinie (Strecke a-b). */ function offsetPoints(a: Vec2, b: Vec2, offset: number): { a: Vec2; b: Vec2 } { const dx = b.x - a.x, dy = b.y - a.y; const len = Math.hypot(dx, dy) || 1; @@ -76,16 +48,71 @@ function offsetPoints(a: Vec2, b: Vec2, offset: number): { a: Vec2; b: Vec2 } { return { a: { x: a.x + nx, y: a.y + ny }, b: { x: b.x + nx, y: b.y + ny } }; } -function appendDimension(p: Project, a: Vec2, b: Vec2, offset: number, ctx: CommandContext): Project { - if (dist(a, b) < EPS) return p; - const d: Drawing2D = { - id: uniqueId("dr2d"), - type: "drawing2d", - levelId: ctx.level.id, - categoryCode: ctx.defaultCategoryCode, - geom: { shape: "dimension", a, b, offset }, - }; - return { ...p, drawings2d: [...p.drawings2d, d] }; +interface DimStart extends CommandState { + phase: "start"; +} +interface DimNext extends CommandState { + phase: "next"; + pts: Vec2[]; + cursor: Vec2 | null; +} +interface DimOffset extends CommandState { + phase: "offset"; + pts: Vec2[]; + cursor: Vec2 | null; +} +type DimState = DimStart | DimNext | DimOffset; + +const UNDO: CmdOption = { id: "undo", labelKey: "cmd.dimension.undo" }; +const FINISH: CmdOption = { id: "finish", labelKey: "cmd.dimension.finish" }; +const OFFSET_FIELDS: CommandField[] = [{ id: "offset", labelKey: "cmd.field.offset" }]; + +/** Vorschau: Punktreihe (Gummiband) während des Sammelns. */ +function chainDraft(pts: Vec2[], cursor: Vec2 | null): ToolDraft { + const shown = cursor ? [...pts, cursor] : pts; + const draft: ToolDraft = { preview: [{ kind: "poly", pts: shown, closed: false }], vertices: pts }; + const last = pts[pts.length - 1]; + if (cursor && last && dist(last, cursor) >= EPS) draft.hud = segmentHud(last, cursor); + return draft; +} + +/** Vorschau: alle Masslinien-Segmente bei gegebenem Offset (grob — Massstriche/Text rendert generatePlan). */ +function offsetDraft(pts: Vec2[], offset: number, at: Vec2 | null): ToolDraft { + const preview: DraftShape[] = []; + for (let i = 0; i < pts.length - 1; i++) { + const a = pts[i], b = pts[i + 1]; + if (Math.abs(offset) > EPS) { + const off = offsetPoints(a, b, offset); + preview.push({ kind: "line", a, b: off.a }); + preview.push({ kind: "line", a: b, b: off.b }); + preview.push({ kind: "line", a: off.a, b: off.b }); + } else { + preview.push({ kind: "line", a, b }); + } + } + const draft: ToolDraft = { preview, vertices: pts }; + if (at) { + const total = pts.reduce((sum, p, i) => (i === 0 ? 0 : sum + dist(pts[i - 1], p)), 0); + draft.hud = { at, text: `Σ${total.toFixed(3)}m · Δ${offset.toFixed(3)}m` }; + } + return draft; +} + +function appendDimensionChain(p: Project, pts: Vec2[], offset: number, ctx: CommandContext): Project { + const added: Drawing2D[] = []; + for (let i = 0; i < pts.length - 1; i++) { + const a = pts[i], b = pts[i + 1]; + if (dist(a, b) < EPS) continue; + added.push({ + id: uniqueId("dr2d"), + type: "drawing2d", + levelId: ctx.level.id, + categoryCode: ctx.defaultCategoryCode, + geom: { shape: "dimension", a, b, offset }, + }); + } + if (added.length === 0) return p; + return { ...p, drawings2d: [...p.drawings2d, ...added] }; } const idle = (): [CommandState, CommandResult] => [ @@ -98,70 +125,95 @@ export const dimensionCommand: Command = { labelKey: "cmd.dimension.label", prompt: (s) => { const phase = (s as DimState).phase; - return phase === "end" ? "cmd.dimension.end" : phase === "offset" ? "cmd.dimension.offset" : "cmd.dimension.start"; + return phase === "next" ? "cmd.dimension.next" : phase === "offset" ? "cmd.dimension.offset" : "cmd.dimension.start"; + }, + accepts: (s) => ((s as DimState).phase === "offset" ? ["point", "number"] : ["point", "option"]), + options: (s) => { + const ds = s as DimState; + if (ds.phase !== "next") return []; + return ds.pts.length >= 2 ? [FINISH, UNDO] : [UNDO]; }, - accepts: (s) => ((s as DimState).phase === "offset" ? ["point", "number"] : ["point"]), - options: () => [], init: (): DimStart => ({ phase: "start", lastPoint: null }), onInput: (state, input, ctx): [CommandState, CommandResult] => { const s = state as DimState; + + if (input.kind === "option" && s.phase === "next") { + if (input.id === "undo") { + const pts = s.pts.slice(0, -1); + if (pts.length === 0) return [{ phase: "start", lastPoint: null }, { draft: null }]; + const ns: DimNext = { phase: "next", pts, cursor: s.cursor, lastPoint: pts[pts.length - 1] }; + return [ns, { draft: chainDraft(pts, s.cursor) }]; + } + if (input.id === "finish" && s.pts.length >= 2) { + const ns: DimOffset = { phase: "offset", pts: s.pts, cursor: s.cursor, lastPoint: s.lastPoint }; + return [ns, { draft: offsetDraft(s.pts, 0, null) }]; + } + return [s, { draft: chainDraft(s.pts, s.cursor) }]; + } + if (s.phase !== "offset") { if (input.kind !== "point") return [s, { draft: null }]; const pt = input.point; - if (s.phase !== "end") { - const ns: DimEnd = { phase: "end", a: pt, cursor: pt, lastPoint: pt }; - return [ns, { draft: { preview: [], vertices: [pt] } }]; + if (s.phase !== "next") { + const ns: DimNext = { phase: "next", pts: [pt], cursor: pt, lastPoint: pt }; + return [ns, { draft: chainDraft([pt], pt) }]; } - if (dist(s.a, pt) < EPS) return idle(); - const ns: DimOffset = { phase: "offset", a: s.a, b: pt, cursor: pt, lastPoint: pt }; - return [ns, { draft: dimDraft(s.a, pt, 0, null) }]; + const pts = [...s.pts, pt]; + const ns: DimNext = { phase: "next", pts, cursor: pt, lastPoint: pt }; + return [ns, { draft: chainDraft(pts, pt) }]; } + // Offset-Schritt: Zahl = direkter Abstand, Punkt = Abstand zum Cursor. let offset: number; if (input.kind === "number") offset = input.value; - else if (input.kind === "point") offset = signedOffset(s.a, s.b, input.point); - else return [s, { draft: dimDraft(s.a, s.b, 0, null) }]; - const { a, b } = s; + else if (input.kind === "point") offset = signedOffset(s.pts[0], s.pts[1], input.point); + else return [s, { draft: offsetDraft(s.pts, 0, null) }]; + const pts = s.pts; return [ { phase: "start", lastPoint: null }, - { draft: null, done: true, commit: (p) => appendDimension(p, a, b, offset, ctx) }, + { draft: null, done: true, commit: (p) => appendDimensionChain(p, pts, offset, ctx) }, ]; }, onMove: (state, point): [CommandState, CommandResult] => { const s = state as DimState; - if (s.phase === "end") { - const ns: DimEnd = { ...s, cursor: point }; - if (dist(s.a, point) < EPS) return [ns, { draft: { preview: [], vertices: [s.a] } }]; - const draft: ToolDraft = { - preview: [{ kind: "line", a: s.a, b: point }], - vertices: [s.a], - hud: { at: point, text: `${dist(s.a, point).toFixed(3)}m` }, - }; - return [ns, { draft }]; + if (s.phase === "next") { + const ns: DimNext = { ...s, cursor: point }; + return [ns, { draft: chainDraft(s.pts, point) }]; } if (s.phase === "offset") { - const off = signedOffset(s.a, s.b, point); + // Offset relativ zum ERSTEN Segment (repräsentativ für die ganze Kette). + const off = signedOffset(s.pts[0], s.pts[1], point); const ns: DimOffset = { ...s, cursor: point }; - return [ns, { draft: dimDraft(s.a, s.b, off, point) }]; + return [ns, { draft: offsetDraft(s.pts, off, point) }]; } return [s, { draft: null }]; }, - onConfirm: (): [CommandState, CommandResult] => idle(), + // Enter/Space/Rechtsklick: im "next"-Schritt (≥2 Punkte) wie Doppelklick → + // Punktreihe abschliessen; im "offset"-Schritt bricht es ab (kein sinnvoller + // Default-Abstand ohne Zeigegeste). + onConfirm: (state): [CommandState, CommandResult] => { + const s = state as DimState; + if (s.phase === "next" && s.pts.length >= 2) { + const ns: DimOffset = { phase: "offset", pts: s.pts, cursor: s.cursor, lastPoint: s.lastPoint }; + return [ns, { draft: offsetDraft(s.pts, 0, null) }]; + } + return idle(); + }, onCancel: (): [CommandState, CommandResult] => idle(), fields: (state) => ((state as DimState).phase === "offset" ? OFFSET_FIELDS : []), fieldValues: (state, _locks, cursor): Record => { const s = state as DimState; if (s.phase !== "offset" || !cursor) return {}; - return { offset: signedOffset(s.a, s.b, cursor) }; + return { offset: signedOffset(s.pts[0], s.pts[1], cursor) }; }, pointFromFields: (state, locks, cursor) => { const s = state as DimState; if (s.phase !== "offset") return null; - const offset = "offset" in locks ? locks.offset : cursor ? signedOffset(s.a, s.b, cursor) : 0; - return offsetPoints(s.a, s.b, offset).a; + const offset = "offset" in locks ? locks.offset : cursor ? signedOffset(s.pts[0], s.pts[1], cursor) : 0; + return offsetPoints(s.pts[0], s.pts[1], offset).a; }, }; diff --git a/src/i18n/de.ts b/src/i18n/de.ts index 8dbc4fe..5d6f33a 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -185,7 +185,7 @@ export const de = { "tool.bezier": "Bezier", "tool.bezier.hint": "Bezier: Anker, Griff 1, Griff 2, Endpunkt — je Segment; Enter beendet", "tool.dimension": "Bemassung", - "tool.dimension.hint": "Bemassung: Erster Punkt, zweiter Punkt, dann Lage der Masslinie", + "tool.dimension.hint": "Bemassung: Punkte wie Polylinie klicken, Doppelklick/Enter beendet, dann Lage der Masslinie (Massketten in einer Geste)", "tool.text": "Text", "tool.text.hint": "Text: Ankerpunkt setzen, dann Text eintippen", "tool.textbox": "Textspalte", @@ -1164,8 +1164,10 @@ export const de = { "cmd.bezier.finish": "Fertig", "cmd.dimension.label": "Bemassung", "cmd.dimension.start": "Erster Punkt:", - "cmd.dimension.end": "Zweiter Punkt:", + "cmd.dimension.next": "Nächster Punkt ( Fertig Zurück ):", "cmd.dimension.offset": "Lage der Masslinie:", + "cmd.dimension.undo": "Zurück", + "cmd.dimension.finish": "Fertig", "cmd.field.offset": "Abstand", "cmd.text.label": "Text", "cmd.text.point": "Ankerpunkt:", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 2a475b5..f518ed8 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -183,7 +183,7 @@ export const en: Record = { "tool.bezier": "Bezier", "tool.bezier.hint": "Bezier: anchor, handle 1, handle 2, end point — per segment; Enter finishes", "tool.dimension": "Dimension", - "tool.dimension.hint": "Dimension: first point, second point, then dimension line position", + "tool.dimension.hint": "Dimension: click points like a polyline, double-click/Enter finishes, then set the dimension line position (chains in one gesture)", "tool.text": "Text", "tool.text.hint": "Text: set anchor point, then type the text", "tool.textbox": "Text column", @@ -1153,8 +1153,10 @@ export const en: Record = { "cmd.bezier.finish": "Finish", "cmd.dimension.label": "Dimension", "cmd.dimension.start": "First point:", - "cmd.dimension.end": "Second point:", + "cmd.dimension.next": "Next point ( Finish Undo ):", "cmd.dimension.offset": "Dimension line position:", + "cmd.dimension.undo": "Undo", + "cmd.dimension.finish": "Finish", "cmd.field.offset": "Offset", "cmd.text.label": "Text", "cmd.text.point": "Anchor point:",