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:",