diff --git a/src/commands/cmds/dimension.test.ts b/src/commands/cmds/dimension.test.ts index 7a3cb3f..e9b0617 100644 --- a/src/commands/cmds/dimension.test.ts +++ b/src/commands/cmds/dimension.test.ts @@ -1,15 +1,18 @@ /** - * `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 + * `dimensionCommand` — Punktreihe (wie Polylinie) + EIN Offset-Klick → EIN + * `shape:"dimension"`-Element für die ganze Kette (Reihenbemassung, SIA 400 + * Figur 14). Ist beim Start bereits eine Kette selektiert, ergänzt der erste + * Klick sie (verlängert ODER fügt dazwischen ein) statt eine neue zu + * beginnen. Die Masszahl wird NICHT gespeichert (aus dist(a,b) beim Rendern * abgeleitet). */ import { describe, it, expect } from "vitest"; import { dimensionCommand } from "./dimension"; import type { CommandContext, Project } from "../types"; +import type { Drawing2D } from "../../model/types"; -function project(): Project { +function project(extra: Drawing2D[] = []): Project { return { id: "t", name: "T", @@ -27,12 +30,12 @@ function project(): Project { ceilings: [], stairs: [], rooms: [], - drawings2d: [], + drawings2d: extra, context: [], }; } -function makeCtx(p: Project): CommandContext { +function makeCtx(p: Project, selectedDrawingId: string | null = null): CommandContext { return { project: p, level: p.drawingLevels[0], @@ -40,11 +43,11 @@ function makeCtx(p: Project): CommandContext { activeLineStyleId: "solid", activeWallTypeId: "aw", lastPoint: null, - selection: { wallIds: [], drawingId: null }, + selection: { wallIds: [], drawingId: selectedDrawingId }, }; } -describe("dimensionCommand", () => { +describe("dimensionCommand — neu zeichnen", () => { it("zwei Punkte + Enter + Offset-Klick (links positiv) → ein shape:dimension mit korrektem Vorzeichen", () => { const p = project(); const ctx = makeCtx(p); @@ -63,8 +66,7 @@ describe("dimensionCommand", () => { const g = next.drawings2d[0].geom; expect(g.shape).toBe("dimension"); if (g.shape === "dimension") { - expect(g.a).toEqual({ x: 0, y: 0 }); - expect(g.b).toEqual({ x: 10, y: 0 }); + expect(g.pts).toEqual([{ x: 0, y: 0 }, { x: 10, y: 0 }]); expect(g.offset).toBeCloseTo(2, 6); } }); @@ -85,7 +87,7 @@ describe("dimensionCommand", () => { if (g.shape === "dimension") expect(g.offset).toBe(1.5); }); - it("Kette aus drei Punkten (Reihenbemassung) → zwei Segmente mit demselben Offset", () => { + it("Kette aus drei Punkten (Reihenbemassung) → EIN Element mit drei Punkten", () => { const p = project(); const ctx = makeCtx(p); let [state] = dimensionCommand.onInput( @@ -98,10 +100,12 @@ describe("dimensionCommand", () => { [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 }); + expect(next.drawings2d.length).toBe(1); + const g = next.drawings2d[0].geom; + if (g.shape === "dimension") { + expect(g.pts).toEqual([{ x: 0, y: 0 }, { x: 4, y: 0 }, { x: 10, y: 0 }]); + expect(g.offset).toBe(2); + } }); it("dritter Punkt ausserhalb der Geraden wird auf sie projiziert (keine Polylinie, immer gerade)", () => { @@ -119,11 +123,10 @@ describe("dimensionCommand", () => { [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(2); - const second = next.drawings2d[1].geom; - if (second.shape === "dimension") { - expect(second.b.y).toBeCloseTo(0, 6); // auf die Gerade y=0 projiziert - expect(second.b.x).toBeCloseTo(10, 6); // x-Komponente bleibt (Projektion entlang x) + const g = next.drawings2d[0].geom; + if (g.shape === "dimension") { + expect(g.pts[2].y).toBeCloseTo(0, 6); // auf die Gerade y=0 projiziert + expect(g.pts[2].x).toBeCloseTo(10, 6); // x-Komponente bleibt (Projektion entlang x) } }); @@ -141,13 +144,11 @@ describe("dimensionCommand", () => { [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 }); + if (g.shape === "dimension") expect(g.pts).toEqual([{ x: 0, y: 0 }, { x: 4, y: 0 }]); }); - it("entartetes Segment (identische Punkte) innerhalb der Kette wird übersprungen", () => { + it("entartete Kette (identische Punkte) committet nichts", () => { const p = project(); const ctx = makeCtx(p); let [state] = dimensionCommand.onInput( @@ -175,3 +176,88 @@ describe("dimensionCommand", () => { expect(result.done).toBe(true); }); }); + +describe("dimensionCommand — bestehende Kette ergänzen (erst selektieren, dann Befehl)", () => { + function chain(pts: { x: number; y: number }[], offset = 1): Drawing2D { + return { + id: "dim-existing", + type: "drawing2d", + levelId: "eg", + categoryCode: "20", + geom: { shape: "dimension", pts, offset }, + }; + } + + it("Klick jenseits des letzten Punkts verlängert die Kette (dasselbe Element)", () => { + const existing = chain([{ x: 0, y: 0 }, { x: 4, y: 0 }]); + const p = project([existing]); + const ctx = makeCtx(p, "dim-existing"); + let [state] = dimensionCommand.onInput( + dimensionCommand.init(), + { kind: "point", point: { x: 10, y: 0 } }, + 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(1); // kein neues Element + expect(next.drawings2d[0].id).toBe("dim-existing"); + const g = next.drawings2d[0].geom; + if (g.shape === "dimension") { + expect(g.pts).toEqual([{ x: 0, y: 0 }, { x: 4, y: 0 }, { x: 10, y: 0 }]); + } + }); + + it("Klick vor dem ersten Punkt verlängert rückwärts (wird vorangestellt)", () => { + const existing = chain([{ x: 4, y: 0 }, { x: 10, y: 0 }]); + const p = project([existing]); + const ctx = makeCtx(p, "dim-existing"); + let [state] = dimensionCommand.onInput( + dimensionCommand.init(), + { kind: "point", point: { x: 0, y: 0 } }, + ctx, + ); + [state] = dimensionCommand.onConfirm(state, ctx); + const [, result] = dimensionCommand.onInput(state, { kind: "number", value: 1 }, ctx); + const next = result.commit!(p); + const g = next.drawings2d[0].geom; + if (g.shape === "dimension") { + expect(g.pts).toEqual([{ x: 0, y: 0 }, { x: 4, y: 0 }, { x: 10, y: 0 }]); + } + }); + + it("Klick zwischen zwei bestehenden Punkten fügt DAZWISCHEN ein", () => { + const existing = chain([{ x: 0, y: 0 }, { x: 10, y: 0 }]); + const p = project([existing]); + const ctx = makeCtx(p, "dim-existing"); + let [state] = dimensionCommand.onInput( + dimensionCommand.init(), + { kind: "point", point: { x: 4, y: 0 } }, + 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(1); + const g = next.drawings2d[0].geom; + if (g.shape === "dimension") { + expect(g.pts).toEqual([{ x: 0, y: 0 }, { x: 4, y: 0 }, { x: 10, y: 0 }]); + } + }); + + it("keine Selektion (drawingId null) startet stattdessen eine neue Kette", () => { + const existing = chain([{ x: 0, y: 0 }, { x: 4, y: 0 }]); + const p = project([existing]); + const ctx = makeCtx(p, null); + let [state] = dimensionCommand.onInput( + dimensionCommand.init(), + { kind: "point", point: { x: 20, y: 20 } }, + ctx, + ); + [state] = dimensionCommand.onInput(state, { kind: "point", point: { x: 25, y: 20 } }, 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(2); // bestehendes Element bleibt, neues kommt dazu + }); +}); diff --git a/src/commands/cmds/dimension.ts b/src/commands/cmds/dimension.ts index 6a05b67..a31f119 100644 --- a/src/commands/cmds/dimension.ts +++ b/src/commands/cmds/dimension.ts @@ -4,14 +4,20 @@ // Klick setzt den Startpunkt, der zweite legt die Richtung fest — jeder // weitere Klick wird auf diese Gerade projiziert (Reihenbemassung misst // mehrere Punkte entlang EINER Referenzlinie, z. B. einer Wand, nie einen -// Zickzack-Pfad). Schritte: +// Zickzack-Pfad). Die ganze Kette ist EIN Element (`pts.length` ≥ 2). +// +// Neu zeichnen: // 1) „Erster Punkt:" → Punkt // 2) „Nächster Punkt ( Fertig Zurück ):" → Punkt … (ab dem 3. Punkt auf die // durch Punkt 1/2 definierte Gerade projiziert) -// 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). +// 3) „Lage der Masslinie:" → Punkt/Zahl → commit neues Element +// +// Ergänzen einer bestehenden Kette (Rhino-Muster: erst selektieren, dann +// Befehl — s. `ctx.selection`): ist beim Start eine `dimension`-Kette +// gewählt, lädt der Befehl deren Punkte/Offset vor und der erste Klick wird +// per Geraden-Projektion in die Kette EINSORTIERT (`insertIntoChain`) — vor +// dem ersten, nach dem letzten (verlängert) ODER zwischen zwei bestehenden +// Punkten (fügt ein). Der Commit ersetzt dasselbe Element (keine Kopie). // // Die Masszahl wird beim Rendern aus dist(a,b) abgeleitet (nicht gespeichert) // — bleibt beim späteren Griff-Ziehen automatisch korrekt. @@ -52,6 +58,37 @@ function projectOnLine(p0: Vec2, p1: Vec2, pt: Vec2): Vec2 { return { x: p0.x + dx * t, y: p0.y + dy * t }; } +/** + * Sortiert `raw` (auf die Kettengerade projiziert) an die richtige Stelle in + * `pts` ein — VOR dem ersten (verlängert rückwärts), NACH dem letzten + * (verlängert vorwärts) oder ZWISCHEN zwei bestehenden Punkten (fügt ein). + * `pts` muss bereits kollinear sein (≥ 2 Punkte); die Skalarposition entlang + * der durch `pts[0]`/`pts[1]` festgelegten Geraden entscheidet die Reihenfolge. + */ +function insertIntoChain(pts: Vec2[], raw: Vec2): Vec2[] { + if (pts.length < 2) return [...pts, raw]; + const p0 = pts[0], p1 = pts[1]; + const dx = p1.x - p0.x, dy = p1.y - p0.y; + const len2 = dx * dx + dy * dy || 1; + const tOf = (p: Vec2): number => ((p.x - p0.x) * dx + (p.y - p0.y) * dy) / len2; + const placed = projectOnLine(p0, p1, raw); + const newT = tOf(placed); + let idx = pts.findIndex((p) => newT < tOf(p)); + if (idx === -1) idx = pts.length; + const out = [...pts]; + out.splice(idx, 0, placed); + return out; +} + +/** Bestehende, selektierte Masskette (Rhino: erst selektieren, dann Befehl) oder null. */ +function selectedChain(ctx: CommandContext): { id: string; pts: Vec2[]; offset: number } | null { + const id = ctx.selection.drawingId; + if (!id) return null; + const d = ctx.project.drawings2d.find((x) => x.id === id); + if (!d || d.geom.shape !== "dimension") return null; + return { id: d.id, pts: d.geom.pts, offset: d.geom.offset }; +} + /** 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; @@ -67,11 +104,14 @@ interface DimNext extends CommandState { phase: "next"; pts: Vec2[]; cursor: Vec2 | null; + /** ID der bearbeiteten Kette (Ergänzen-Modus) — null = neue Kette. */ + editId: string | null; } interface DimOffset extends CommandState { phase: "offset"; pts: Vec2[]; cursor: Vec2 | null; + editId: string | null; } type DimState = DimStart | DimNext | DimOffset; @@ -110,21 +150,26 @@ function offsetDraft(pts: Vec2[], offset: number, at: Vec2 | null): ToolDraft { 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 }, - }); +/** Committet eine Kette: neu anhängen (editId null) ODER dasselbe Element ersetzen. */ +function commitChain(p: Project, pts: Vec2[], offset: number, editId: string | null, ctx: CommandContext): Project { + const cleaned = pts.filter((pt, i) => i === 0 || dist(pts[i - 1], pt) >= EPS); + if (cleaned.length < 2) return p; // zu wenig gültige Punkte → nichts committen/ändern + if (editId) { + return { + ...p, + drawings2d: p.drawings2d.map((d) => + d.id === editId ? { ...d, geom: { shape: "dimension", pts: cleaned, offset } } : d, + ), + }; } - if (added.length === 0) return p; - return { ...p, drawings2d: [...p.drawings2d, ...added] }; + const d: Drawing2D = { + id: uniqueId("dr2d"), + type: "drawing2d", + levelId: ctx.level.id, + categoryCode: ctx.defaultCategoryCode, + geom: { shape: "dimension", pts: cleaned, offset }, + }; + return { ...p, drawings2d: [...p.drawings2d, d] }; } const idle = (): [CommandState, CommandResult] => [ @@ -154,11 +199,11 @@ export const dimensionCommand: Command = { 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] }; + const ns: DimNext = { phase: "next", pts, cursor: s.cursor, editId: s.editId, 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 }; + const ns: DimOffset = { phase: "offset", pts: s.pts, cursor: s.cursor, editId: s.editId, lastPoint: s.lastPoint }; return [ns, { draft: offsetDraft(s.pts, 0, null) }]; } return [s, { draft: chainDraft(s.pts, s.cursor) }]; @@ -168,14 +213,19 @@ export const dimensionCommand: Command = { if (input.kind !== "point") return [s, { draft: null }]; const pt = input.point; if (s.phase !== "next") { - const ns: DimNext = { phase: "next", pts: [pt], cursor: pt, lastPoint: pt }; - return [ns, { draft: chainDraft([pt], pt) }]; + // Erster Klick: bei selektierter Kette wird er EINSORTIERT (Ergänzen); + // sonst startet er eine neue Kette. + const existing = selectedChain(ctx); + const pts = existing ? insertIntoChain(existing.pts, pt) : [pt]; + const editId = existing ? existing.id : null; + const ns: DimNext = { phase: "next", pts, cursor: pt, editId, lastPoint: pt }; + return [ns, { draft: chainDraft(pts, pt) }]; } - // Ab dem dritten Punkt: auf die durch Punkt 1/2 festgelegte Gerade projizieren. - const placed = s.pts.length >= 2 ? projectOnLine(s.pts[0], s.pts[1], pt) : pt; - const pts = [...s.pts, placed]; - const ns: DimNext = { phase: "next", pts, cursor: placed, lastPoint: placed }; - return [ns, { draft: chainDraft(pts, placed) }]; + // Ab dem dritten Punkt (bzw. jedem weiteren Klick im Ergänzen-Modus): + // auf die durch Punkt 1/2 festgelegte Gerade projizieren/einsortieren. + const pts = s.pts.length >= 2 ? insertIntoChain(s.pts, pt) : [...s.pts, pt]; + const ns: DimNext = { phase: "next", pts, cursor: pt, editId: s.editId, lastPoint: pt }; + return [ns, { draft: chainDraft(pts, pt) }]; } // Offset-Schritt: Zahl = direkter Abstand, Punkt = Abstand zum Cursor. @@ -184,9 +234,10 @@ export const dimensionCommand: Command = { 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; + const editId = s.editId; return [ { phase: "start", lastPoint: null }, - { draft: null, done: true, commit: (p) => appendDimensionChain(p, pts, offset, ctx) }, + { draft: null, done: true, commit: (p) => commitChain(p, pts, offset, editId, ctx) }, ]; }, @@ -212,7 +263,7 @@ export const dimensionCommand: Command = { 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 }; + const ns: DimOffset = { phase: "offset", pts: s.pts, cursor: s.cursor, editId: s.editId, lastPoint: s.lastPoint }; return [ns, { draft: offsetDraft(s.pts, 0, null) }]; } return idle(); diff --git a/src/model/geoRebase.ts b/src/model/geoRebase.ts index 70c015e..e25d596 100644 --- a/src/model/geoRebase.ts +++ b/src/model/geoRebase.ts @@ -113,7 +113,7 @@ export function translateProject(project: Project, dx: number, dy: number): Proj case "spline": return { ...d, geom: { ...g, pts: shiftAll(g.pts, dx, dy) } }; case "dimension": - return { ...d, geom: { ...g, a: shift(g.a, dx, dy), b: shift(g.b, dx, dy) } }; + return { ...d, geom: { ...g, pts: shiftAll(g.pts, dx, dy) } }; case "kote": return { ...d, geom: { ...g, at: shift(g.at, dx, dy) } }; case "text": diff --git a/src/model/types.ts b/src/model/types.ts index 1cf5072..14ef288 100644 --- a/src/model/types.ts +++ b/src/model/types.ts @@ -1533,7 +1533,15 @@ export type Drawing2DGeom = * beim Verschieben der Griffe automatisch korrekt. Rein 2D (kein Bezug auf * andere Elemente — Assoziativität nur über die eigenen Endpunkte). */ - | { shape: "dimension"; a: Vec2; b: Vec2; offset: number } + /** + * `pts.length` ≥ 2, IMMER kollinear (Werkzeug erzwingt das beim Zeichnen/ + * Ergänzen — eine Masskette ist eine Gerade, kein Zickzack-Pfad). Jedes + * aufeinanderfolgende Punktpaar ist EIN gemessenes Segment (Reihenbemassung, + * SIA 400 Figur 14), alle teilen denselben `offset`. EIN Element für die + * ganze Kette (nicht mehrere) — spätere Bearbeitung kann Punkte ergänzen + * (verlängern ODER dazwischen einfügen, s. `commands/cmds/dimension.ts`). + */ + | { shape: "dimension"; pts: Vec2[]; offset: number } /** * Höhenkote (SIA 400 B.5.4, Figur 18): Dreieck-Symbol + Höhenwert (Meter, * ±0.00-Referenz frei wählbar, s. Norm) an einem Punkt. `kind` bestimmt diff --git a/src/plan/generatePlan.ts b/src/plan/generatePlan.ts index b9593de..8c4ff51 100644 --- a/src/plan/generatePlan.ts +++ b/src/plan/generatePlan.ts @@ -730,8 +730,7 @@ function computeBounds( } else if (g.shape === "bezier" || g.shape === "spline") { g.pts.forEach(acc); } else if (g.shape === "dimension") { - acc(g.a); - acc(g.b); + g.pts.forEach(acc); } else if (g.shape === "kote") acc(g.at); else if (g.shape === "text") acc(g.at); else if (g.shape === "image") { acc(g.min); acc(g.max); } diff --git a/src/plan/generatePlan/drawing2d.test.ts b/src/plan/generatePlan/drawing2d.test.ts index 2b45013..6dc53a5 100644 --- a/src/plan/generatePlan/drawing2d.test.ts +++ b/src/plan/generatePlan/drawing2d.test.ts @@ -129,7 +129,7 @@ describe("addDrawing2D — dimension (SIA 400 B.5.3)", () => { type: "drawing2d", levelId: "eg", categoryCode: "20", - geom: { shape: "dimension", a: { x: 0, y: 0 }, b: { x: 4, y: 0 }, offset: 1 }, + geom: { shape: "dimension", pts: [{ x: 0, y: 0 }, { x: 4, y: 0 }], offset: 1 }, }; const out: Primitive[] = []; addDrawing2D(out, project(), d, false, lwByCode, colorByCode, catByCode); @@ -152,7 +152,7 @@ describe("addDrawing2D — dimension (SIA 400 B.5.3)", () => { type: "drawing2d", levelId: "eg", categoryCode: "20", - geom: { shape: "dimension", a: { x: 0, y: 0 }, b: { x: 3, y: 4 }, offset: 0.5 }, // 3-4-5-Dreieck → 5m + geom: { shape: "dimension", pts: [{ x: 0, y: 0 }, { x: 3, y: 4 }], offset: 0.5 }, // 3-4-5-Dreieck → 5m }; const out: Primitive[] = []; addDrawing2D(out, project(), d, false, lwByCode, colorByCode, catByCode); @@ -167,7 +167,7 @@ describe("addDrawing2D — dimension (SIA 400 B.5.3)", () => { type: "drawing2d", levelId: "eg", categoryCode: "20", - geom: { shape: "dimension", a: { x: 2, y: 2 }, b: { x: 2, y: 2 }, offset: 1 }, + geom: { shape: "dimension", pts: [{ x: 2, y: 2 }, { x: 2, y: 2 }], offset: 1 }, }; const out: Primitive[] = []; addDrawing2D(out, project(), d, false, lwByCode, colorByCode, catByCode); diff --git a/src/plan/generatePlan/drawing2d.ts b/src/plan/generatePlan/drawing2d.ts index 48de75c..0b6722b 100644 --- a/src/plan/generatePlan/drawing2d.ts +++ b/src/plan/generatePlan/drawing2d.ts @@ -30,6 +30,77 @@ function formatDimLabel(m: number): string { return m.toFixed(2); } +/** + * Ein einzelnes Mass-Segment (Masshilfslinien + Masslinie + Massstriche + + * Masszahl) zwischen `a` und `b` bei gegebenem `offset` — s. `formatDimLabel`. + * Extrahiert, damit eine Masskette (`shape:"dimension"`, `pts.length` ≥ 2) + * je aufeinanderfolgendem Punktpaar dasselbe Rendering wiederverwenden kann. + */ +function addDimensionSegment( + out: Primitive[], + a: Vec2, + b: Vec2, + offset: number, + weightMm: number, + dash: number[] | null, + color: string, + greyed: boolean, + drawingId: string, +): void { + const dx = b.x - a.x, dy = b.y - a.y; + const lineLen = Math.hypot(dx, dy); + if (lineLen < 1e-6) return; + const ux = dx / lineLen, uy = dy / lineLen; // Einheitsrichtung a→b + const nx = -uy, ny = ux; // Links-Normale (Einheit) — Richtung des Offsets + const sign = offset >= 0 ? 1 : -1; + const dimA: Vec2 = { x: a.x + nx * offset, y: a.y + ny * offset }; + const dimB: Vec2 = { x: b.x + nx * offset, y: b.y + ny * offset }; + const extLine = (from: Vec2, to: Vec2) => + out.push({ kind: "line", a: from, b: to, cls: "draw2d", weightMm, dash, color, greyed, drawingId }); + extLine( + { x: a.x + nx * DIM_GAP_M * sign, y: a.y + ny * DIM_GAP_M * sign }, + { x: dimA.x + nx * DIM_OVERSHOOT_M * sign, y: dimA.y + ny * DIM_OVERSHOOT_M * sign }, + ); + extLine( + { x: b.x + nx * DIM_GAP_M * sign, y: b.y + ny * DIM_GAP_M * sign }, + { x: dimB.x + nx * DIM_OVERSHOOT_M * sign, y: dimB.y + ny * DIM_OVERSHOOT_M * sign }, + ); + extLine(dimA, dimB); + // Massstriche: 45°-Diagonale (Winkelhalbierende von Richtung/Normale), an + // beiden Enden PARALLEL (nicht gespiegelt) — wie in SIA 400 Figur 13. + const tx = (ux + nx) / Math.SQRT2, ty = (uy + ny) / Math.SQRT2; + const half = DIM_TICK_LEN_M / 2; + const tick = (pt: Vec2) => + out.push({ + kind: "line", + a: { x: pt.x - tx * half, y: pt.y - ty * half }, + b: { x: pt.x + tx * half, y: pt.y + ty * half }, + cls: "draw2d", + weightMm: weightMm * 2, + dash: null, + color, + greyed, + drawingId, + }); + tick(dimA); + tick(dimB); + // Masszahl: Mitte der Masslinie, leicht abgesetzt, nie kopfüber lesbar. + let angle = Math.atan2(dy, dx); + if (Math.cos(angle) < 0) angle += Math.PI; + const upx = -Math.sin(angle), upy = Math.cos(angle); // Links-Normale der ENDGÜLTIGEN Richtung + const mid: Vec2 = { x: (dimA.x + dimB.x) / 2, y: (dimA.y + dimB.y) / 2 }; + out.push({ + kind: "drawingText", + at: { x: mid.x + upx * DIM_TEXT_HEIGHT_M * 0.6, y: mid.y + upy * DIM_TEXT_HEIGHT_M * 0.6 }, + text: formatDimLabel(lineLen), + heightM: DIM_TEXT_HEIGHT_M, + angle, + color, + greyed, + drawingId, + }); +} + // ── Höhenkote (SIA 400 B.5.4) ──────────────────────────────────────────────── const KOTE_TRI_SIZE_M = 0.15; // Dreieck-Grösse (Spitze bis Basis) const KOTE_TRI_HALF_W_M = KOTE_TRI_SIZE_M * 0.6; @@ -185,62 +256,11 @@ export function addDrawing2D( break; } case "dimension": { - // SIA 400 B.5.3: Masshilfslinien + Masslinie in der dünnsten Linie; - // Massbegrenzungslinien (Massstriche) doppelt so dick; Masszahl aus - // dist(a,b) — nicht gespeichert, folgt Griff-Änderungen automatisch. - const dx = g.b.x - g.a.x, dy = g.b.y - g.a.y; - const lineLen = Math.hypot(dx, dy); - if (lineLen < 1e-6) break; - const ux = dx / lineLen, uy = dy / lineLen; // Einheitsrichtung a→b - const nx = -uy, ny = ux; // Links-Normale (Einheit) — Richtung des Offsets - const off = g.offset; - const sign = off >= 0 ? 1 : -1; - const dimA: Vec2 = { x: g.a.x + nx * off, y: g.a.y + ny * off }; - const dimB: Vec2 = { x: g.b.x + nx * off, y: g.b.y + ny * off }; - const extLine = (from: Vec2, to: Vec2) => - out.push({ kind: "line", a: from, b: to, cls: "draw2d", weightMm, dash, color, greyed, drawingId: d.id }); - extLine( - { x: g.a.x + nx * DIM_GAP_M * sign, y: g.a.y + ny * DIM_GAP_M * sign }, - { x: dimA.x + nx * DIM_OVERSHOOT_M * sign, y: dimA.y + ny * DIM_OVERSHOOT_M * sign }, - ); - extLine( - { x: g.b.x + nx * DIM_GAP_M * sign, y: g.b.y + ny * DIM_GAP_M * sign }, - { x: dimB.x + nx * DIM_OVERSHOOT_M * sign, y: dimB.y + ny * DIM_OVERSHOOT_M * sign }, - ); - extLine(dimA, dimB); - // Massstriche: 45°-Diagonale (Winkelhalbierende von Richtung/Normale), - // an beiden Enden PARALLEL (nicht gespiegelt) — wie in SIA 400 Figur 13. - const tx = (ux + nx) / Math.SQRT2, ty = (uy + ny) / Math.SQRT2; - const half = DIM_TICK_LEN_M / 2; - const tick = (pt: Vec2) => - out.push({ - kind: "line", - a: { x: pt.x - tx * half, y: pt.y - ty * half }, - b: { x: pt.x + tx * half, y: pt.y + ty * half }, - cls: "draw2d", - weightMm: weightMm * 2, - dash: null, - color, - greyed, - drawingId: d.id, - }); - tick(dimA); - tick(dimB); - // Masszahl: Mitte der Masslinie, leicht abgesetzt, nie kopfüber lesbar. - let angle = Math.atan2(dy, dx); - if (Math.cos(angle) < 0) angle += Math.PI; - const upx = -Math.sin(angle), upy = Math.cos(angle); // Links-Normale der ENDGÜLTIGEN Richtung - const mid: Vec2 = { x: (dimA.x + dimB.x) / 2, y: (dimA.y + dimB.y) / 2 }; - out.push({ - kind: "drawingText", - at: { x: mid.x + upx * DIM_TEXT_HEIGHT_M * 0.6, y: mid.y + upy * DIM_TEXT_HEIGHT_M * 0.6 }, - text: formatDimLabel(lineLen), - heightM: DIM_TEXT_HEIGHT_M, - angle, - color, - greyed, - drawingId: d.id, - }); + // SIA 400 B.5.3, Figur 14 (Reihenbemassung): jedes aufeinanderfolgende + // Punktpaar der Kette ist ein eigenes Mass-Segment, alle teilen `offset`. + for (let i = 0; i < g.pts.length - 1; i++) { + addDimensionSegment(out, g.pts[i], g.pts[i + 1], g.offset, weightMm, dash, color, greyed, d.id); + } break; } case "kote": { diff --git a/src/state/projectSlice.ts b/src/state/projectSlice.ts index e2a1983..4bde38c 100644 --- a/src/state/projectSlice.ts +++ b/src/state/projectSlice.ts @@ -1520,14 +1520,16 @@ export function drawingVertices(d: import("../model/types").Drawing2D): Vec2[] { } // Bezier/Spline: alle Kontroll-/Wegpunkte als Griffe (wie polyline). if (g.shape === "bezier" || g.shape === "spline") return [...g.pts]; - // Bemassung: Griff 0/1 = die beiden gemessenen Punkte (a/b); Griff 2 sitzt - // AUF der Masslinie (Mitte) — ihn zu ziehen ändert nur `offset` (s. moveGrip). + // Bemassung: Griff 0..N-1 = jeder Punkt der Kette (wie polyline); EIN + // zusätzlicher Griff (Index N) sitzt AUF der Masslinie (Mitte der Kette) — + // ihn zu ziehen ändert nur `offset`, nicht die Punkte (s. moveGrip). if (g.shape === "dimension") { - const dx = g.b.x - g.a.x, dy = g.b.y - g.a.y; + const a = g.pts[0], b = g.pts[g.pts.length - 1]; + const dx = b.x - a.x, dy = b.y - a.y; const len = Math.hypot(dx, dy) || 1; const nx = (-dy / len) * g.offset, ny = (dx / len) * g.offset; - const mid: Vec2 = { x: (g.a.x + g.b.x) / 2 + nx, y: (g.a.y + g.b.y) / 2 + ny }; - return [g.a, g.b, mid]; + const mid: Vec2 = { x: (a.x + b.x) / 2 + nx, y: (a.y + b.y) / 2 + ny }; + return [...g.pts, mid]; } // Kote: EIN Griff an der Dreieck-Spitze (zum Verschieben; siehe moveGrip). if (g.shape === "kote") return [g.at]; @@ -1592,16 +1594,20 @@ function moveGrip( const pts = g.pts.map((p, i) => (i === index ? pt : p)); return { ...d, geom: { ...g, pts } }; } - // Bemassung: Griff 0/1 verschiebt den gemessenen Punkt a/b (Masszahl - // folgt automatisch, s. Rendering); Griff 2 (auf der Masslinie) ändert - // nur `offset` — Normalabstand von `pt` zur (unveränderten) Strecke a-b. + // Bemassung: Griff 0..N-1 verschiebt den jeweiligen Kettenpunkt + // (Masszahlen folgen automatisch, s. Rendering); der LETZTE Griff + // (auf der Masslinie) ändert nur `offset` — Normalabstand von `pt` + // zur (unveränderten) Punktreihe. if (g.shape === "dimension") { - if (index === 0) return { ...d, geom: { ...g, a: pt } }; - if (index === 1) return { ...d, geom: { ...g, b: pt } }; - const dx = g.b.x - g.a.x, dy = g.b.y - g.a.y; + if (index < g.pts.length) { + const pts = g.pts.map((p, i) => (i === index ? pt : p)); + return { ...d, geom: { ...g, pts } }; + } + const a = g.pts[0], b = g.pts[g.pts.length - 1]; + const dx = b.x - a.x, dy = b.y - a.y; const len = Math.hypot(dx, dy) || 1; const nx = -dy / len, ny = dx / len; - const offset = (pt.x - g.a.x) * nx + (pt.y - g.a.y) * ny; + const offset = (pt.x - a.x) * nx + (pt.y - a.y) * ny; return { ...d, geom: { ...g, offset } }; } if (g.shape === "rect" || g.shape === "image") { @@ -1698,9 +1704,8 @@ function moveElementBy( return { ...d, geom: { ...g, center: mv(g.center) } }; case "bezier": case "spline": - return { ...d, geom: { ...g, pts: g.pts.map(mv) } }; case "dimension": - return { ...d, geom: { ...g, a: mv(g.a), b: mv(g.b) } }; + return { ...d, geom: { ...g, pts: g.pts.map(mv) } }; case "kote": return { ...d, geom: { ...g, at: mv(g.at) } }; case "text": diff --git a/src/state/selectionInfo.ts b/src/state/selectionInfo.ts index 00ccf94..fc000e2 100644 --- a/src/state/selectionInfo.ts +++ b/src/state/selectionInfo.ts @@ -545,13 +545,14 @@ function geomBBox(g: Drawing2DGeom): SelectionBBox { for (const p of g.pts) acc(p.x, p.y); break; case "dimension": { - acc(g.a.x, g.a.y); - acc(g.b.x, g.b.y); - const dx = g.b.x - g.a.x, dy = g.b.y - g.a.y; - const len = Math.hypot(dx, dy) || 1; - const nx = (-dy / len) * g.offset, ny = (dx / len) * g.offset; - acc(g.a.x + nx, g.a.y + ny); - acc(g.b.x + nx, g.b.y + ny); + for (const p of g.pts) acc(p.x, p.y); + if (g.pts.length >= 2) { + const a = g.pts[0], b = g.pts[g.pts.length - 1]; + const dx = b.x - a.x, dy = b.y - a.y; + const len = Math.hypot(dx, dy) || 1; + const nx = (-dy / len) * g.offset, ny = (dx / len) * g.offset; + for (const p of g.pts) acc(p.x + nx, p.y + ny); + } break; } case "kote": diff --git a/src/state/useProjectFileActions.ts b/src/state/useProjectFileActions.ts index 65f0fb2..0bbc608 100644 --- a/src/state/useProjectFileActions.ts +++ b/src/state/useProjectFileActions.ts @@ -55,8 +55,7 @@ function drawingsBBox( } else if (g.shape === "bezier" || g.shape === "spline") { for (const p of g.pts) acc(p.x, p.y); } else if (g.shape === "dimension") { - acc(g.a.x, g.a.y); - acc(g.b.x, g.b.y); + for (const p of g.pts) acc(p.x, p.y); } else if (g.shape === "kote") acc(g.at.x, g.at.y); else if (g.shape === "text") acc(g.at.x, g.at.y); } @@ -76,7 +75,7 @@ function translateDrawing(d: Drawing2D, dx: number, dy: number): Drawing2D { case "ellipse": return { ...d, geom: { ...g, center: mv(g.center) } }; case "bezier": return { ...d, geom: { ...g, pts: g.pts.map(mv) } }; case "spline": return { ...d, geom: { ...g, pts: g.pts.map(mv) } }; - case "dimension": return { ...d, geom: { ...g, a: mv(g.a), b: mv(g.b) } }; + case "dimension": return { ...d, geom: { ...g, pts: g.pts.map(mv) } }; case "kote": return { ...d, geom: { ...g, at: mv(g.at) } }; case "text": return { ...d, geom: { ...g, at: mv(g.at) } }; default: return d; diff --git a/src/tools/transform.ts b/src/tools/transform.ts index e209457..76cd51b 100644 --- a/src/tools/transform.ts +++ b/src/tools/transform.ts @@ -186,7 +186,7 @@ const mvGeom = (geom: Drawing2DGeom, fn: (p: Vec2) => Vec2): Drawing2DGeom => { case "spline": return { ...geom, pts: geom.pts.map(fn) }; case "dimension": - return { ...geom, a: fn(geom.a), b: fn(geom.b) }; + return { ...geom, pts: geom.pts.map(fn) }; case "kote": return { ...geom, at: fn(geom.at) }; case "text": diff --git a/src/viewport/drawingGrips.ts b/src/viewport/drawingGrips.ts index 71b435f..6252b96 100644 --- a/src/viewport/drawingGrips.ts +++ b/src/viewport/drawingGrips.ts @@ -24,8 +24,7 @@ export function drawingGripVertices(d: Drawing2D): Vec2[] { { x: g.min.x, y: g.max.y }, ]; } - if (g.shape === "bezier" || g.shape === "spline") return [...g.pts]; - if (g.shape === "dimension") return [g.a, g.b]; + if (g.shape === "bezier" || g.shape === "spline" || g.shape === "dimension") return [...g.pts]; return []; }