From 6fd5a50d771fa477e761e53bee4819d8e842dddf Mon Sep 17 00:00:00 2001 From: Karim Date: Sat, 22 Aug 2026 02:10:38 +0200 Subject: [PATCH] 2D: Bemassungs-Werkzeug nach SIA 400 B.5.3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neue Masslinie (shape:"dimension"): zwei Punkte + Lage der Masslinie. Folgt der Norm statt DIN-Gewohnheiten: Massbegrenzungslinien als Massstriche (doppelte Strichstärke, keine Pfeilspitzen), Masszahl mit Komma- Dezimaltrennzeichen, nie kopfüber lesbar (dreht sich automatisch). Die Masszahl wird nicht gespeichert, sondern immer aus dem aktuellen Abstand der beiden Punkte abgeleitet — bleibt beim Griff-Ziehen automatisch korrekt. Drei Griffe (beide Endpunkte + ein Griff auf der Masslinie zum Verschieben des Abstands), Verschieben/Rotieren, Georef-Rebase und Plan-Einpassen entsprechend erweitert. --- src/App.tsx | 1 + src/commands/cmds/dimension.test.ts | 96 ++++++++++++++ src/commands/cmds/dimension.ts | 167 ++++++++++++++++++++++++ src/commands/registry.ts | 4 + src/i18n/de.ts | 7 + src/i18n/en.ts | 7 + src/model/geoRebase.ts | 2 + src/model/types.ts | 10 ++ src/panels/ToolsPanel.tsx | 12 ++ src/plan/generatePlan.ts | 3 + src/plan/generatePlan/drawing2d.test.ts | 53 ++++++++ src/plan/generatePlan/drawing2d.ts | 74 +++++++++++ src/state/projectSlice.ts | 23 ++++ src/state/selectionInfo.ts | 10 ++ src/state/useProjectFileActions.ts | 4 + src/tools/tools.ts | 14 ++ src/tools/transform.ts | 2 + src/tools/types.ts | 1 + src/viewport/drawingGrips.ts | 1 + 19 files changed, 491 insertions(+) create mode 100644 src/commands/cmds/dimension.test.ts create mode 100644 src/commands/cmds/dimension.ts diff --git a/src/App.tsx b/src/App.tsx index 098491f..3f39c55 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -222,6 +222,7 @@ const TOOL_COMMAND: Partial> = { ellipse: "ellipse", spline: "spline", bezier: "bezier", + dimension: "dimension", text: "text", textbox: "textbox", }; diff --git a/src/commands/cmds/dimension.test.ts b/src/commands/cmds/dimension.test.ts new file mode 100644 index 0000000..4656671 --- /dev/null +++ b/src/commands/cmds/dimension.test.ts @@ -0,0 +1,96 @@ +/** + * `dimensionCommand` — zwei Punkte + Lage der Masslinie → `shape:"dimension"`. + * 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"; + +function project(): Project { + return { + id: "t", + name: "T", + lineStyles: [], + hatches: [], + components: [], + wallTypes: [], + drawingLevels: [ + { id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0, baseElevation: 0 }, + ], + layers: [{ code: "20", name: "Zeichnung", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }], + walls: [], + doors: [], + openings: [], + ceilings: [], + stairs: [], + rooms: [], + drawings2d: [], + context: [], + }; +} + +function makeCtx(p: Project): CommandContext { + return { + project: p, + level: p.drawingLevels[0], + defaultCategoryCode: "20", + activeLineStyleId: "solid", + activeWallTypeId: "aw", + lastPoint: null, + selection: { wallIds: [], drawingId: null }, + }; +} + +describe("dimensionCommand", () => { + it("zwei Punkte + Offset-Klick (links positiv) → shape:dimension mit korrektem Vorzeichen", () => { + 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: 10, y: 0 } }, 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(); + const next = result.commit!(p); + expect(next.drawings2d.length).toBe(1); + 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.offset).toBeCloseTo(2, 6); + } + }); + + it("getippte Offset-Zahl committet direkt (kein Punkt-Klick nötig)", () => { + 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: 0, y: 5 } }, 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", () => { + const p = project(); + const ctx = makeCtx(p); + let [state] = dimensionCommand.onInput( + dimensionCommand.init(), + { kind: "point", point: { x: 3, y: 3 } }, + ctx, + ); + const [, result] = dimensionCommand.onInput(state, { kind: "point", point: { x: 3, y: 3 } }, ctx); + expect(result.commit).toBeUndefined(); + expect(result.done).toBe(true); + }); +}); diff --git a/src/commands/cmds/dimension.ts b/src/commands/cmds/dimension.ts new file mode 100644 index 0000000..e5e8f92 --- /dev/null +++ b/src/commands/cmds/dimension.ts @@ -0,0 +1,167 @@ +// 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") +// +// 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 type { + Command, + CommandContext, + CommandField, + CommandResult, + CommandState, + DraftShape, + Project, + ToolDraft, + Vec2, +} from "../types"; + +const EPS = 1e-6; +const dist = (a: Vec2, b: Vec2): number => Math.hypot(b.x - a.x, b.y - a.y); + +/** Vorzeichenbehafteter Normalabstand von `pt` zur Geraden a→b (links positiv). */ +function signedOffset(a: Vec2, b: Vec2, pt: Vec2): number { + const dx = b.x - a.x, dy = b.y - a.y; + const len = Math.hypot(dx, dy); + if (len < EPS) return 0; + const nx = -dy / len, ny = dx / len; + 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. */ +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; + const nx = (-dy / len) * offset, ny = (dx / len) * offset; + 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] }; +} + +const idle = (): [CommandState, CommandResult] => [ + { phase: "start", lastPoint: null }, + { draft: null, done: true }, +]; + +export const dimensionCommand: Command = { + name: "dimension", + 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"; + }, + 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 (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 (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) }]; + } + // 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; + return [ + { phase: "start", lastPoint: null }, + { draft: null, done: true, commit: (p) => appendDimension(p, a, b, 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 === "offset") { + const off = signedOffset(s.a, s.b, point); + const ns: DimOffset = { ...s, cursor: point }; + return [ns, { draft: dimDraft(s.a, s.b, off, point) }]; + } + return [s, { draft: null }]; + }, + + onConfirm: (): [CommandState, CommandResult] => 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) }; + }, + 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; + }, +}; diff --git a/src/commands/registry.ts b/src/commands/registry.ts index cdb1769..2b82a77 100644 --- a/src/commands/registry.ts +++ b/src/commands/registry.ts @@ -20,6 +20,7 @@ import { arcCommand } from "./cmds/arc"; import { ellipseCommand } from "./cmds/ellipse"; import { splineCommand } from "./cmds/spline"; import { bezierCommand } from "./cmds/bezier"; +import { dimensionCommand } from "./cmds/dimension"; import { textCommand } from "./cmds/text"; import { textboxCommand } from "./cmds/textbox"; import { moveCommand } from "./cmds/move"; @@ -59,6 +60,7 @@ export const COMMANDS: Record = { ellipse: ellipseCommand, spline: splineCommand, bezier: bezierCommand, + dimension: dimensionCommand, text: textCommand, textbox: textboxCommand, move: moveCommand, @@ -112,6 +114,8 @@ export const ALIASES: Record = { bogen: "arc", el: "ellipse", sp: "spline", + dim: "dimension", + bemassung: "dimension", bz: "bezier", tx: "text", txt: "text", diff --git a/src/i18n/de.ts b/src/i18n/de.ts index 4380f5a..8dbc4fe 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -184,6 +184,8 @@ export const de = { "tool.spline.hint": "Spline: Wegpunkte klicken, Enter beendet (glatte Kurve durch die Punkte)", "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.text": "Text", "tool.text.hint": "Text: Ankerpunkt setzen, dann Text eintippen", "tool.textbox": "Textspalte", @@ -1160,6 +1162,11 @@ export const de = { "cmd.bezier.c2": "Griff 2:", "cmd.bezier.end": "Endpunkt ( Fertig ):", "cmd.bezier.finish": "Fertig", + "cmd.dimension.label": "Bemassung", + "cmd.dimension.start": "Erster Punkt:", + "cmd.dimension.end": "Zweiter Punkt:", + "cmd.dimension.offset": "Lage der Masslinie:", + "cmd.field.offset": "Abstand", "cmd.text.label": "Text", "cmd.text.point": "Ankerpunkt:", "cmd.textbox.label": "Textspalte", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 2d01f0d..2a475b5 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -182,6 +182,8 @@ export const en: Record = { "tool.spline.hint": "Spline: click waypoints, Enter finishes (smooth curve through the points)", "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.text": "Text", "tool.text.hint": "Text: set anchor point, then type the text", "tool.textbox": "Text column", @@ -1149,6 +1151,11 @@ export const en: Record = { "cmd.bezier.c2": "Handle 2:", "cmd.bezier.end": "End point ( Finish ):", "cmd.bezier.finish": "Finish", + "cmd.dimension.label": "Dimension", + "cmd.dimension.start": "First point:", + "cmd.dimension.end": "Second point:", + "cmd.dimension.offset": "Dimension line position:", + "cmd.field.offset": "Offset", "cmd.text.label": "Text", "cmd.text.point": "Anchor point:", "cmd.textbox.label": "Text column", diff --git a/src/model/geoRebase.ts b/src/model/geoRebase.ts index b37f251..42e5d7e 100644 --- a/src/model/geoRebase.ts +++ b/src/model/geoRebase.ts @@ -112,6 +112,8 @@ export function translateProject(project: Project, dx: number, dy: number): Proj return { ...d, geom: { ...g, pts: shiftAll(g.pts, dx, dy) } }; 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) } }; case "text": return { ...d, geom: { ...g, at: shift(g.at, dx, dy) } }; case "image": diff --git a/src/model/types.ts b/src/model/types.ts index 9a87cec..335e5f1 100644 --- a/src/model/types.ts +++ b/src/model/types.ts @@ -1524,6 +1524,16 @@ export type Drawing2DGeom = * schliesst den Ring (letzter Wegpunkt → erster). */ | { shape: "spline"; pts: Vec2[]; closed: boolean } + /** + * Masslinie (lineare Bemassung, SIA 400 B.5.3): misst den Abstand `a`↔`b`. + * `offset` ist der VORZEICHENBEHAFTETE Normalabstand (Modell-Meter, links + * von a→b positiv), in dem die eigentliche Masslinie parallel zu a-b liegt + * — Masshilfslinien laufen von a/b bis zu dieser Linie. Die Masszahl wird + * beim Rendern aus `dist(a,b)` abgeleitet (nicht gespeichert), bleibt also + * 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 } | { shape: "text"; at: Vec2; diff --git a/src/panels/ToolsPanel.tsx b/src/panels/ToolsPanel.tsx index 96b3da8..c970d54 100644 --- a/src/panels/ToolsPanel.tsx +++ b/src/panels/ToolsPanel.tsx @@ -168,6 +168,17 @@ export function ToolIcon({ id }: { id: ToolId }) { ); + case "dimension": + // Masslinie mit Massstrichen (Diagonalstriche) an beiden Enden. + return ( + + + + + + + + ); case "text": // Serifen-„A" als Text-Symbol. return ( @@ -213,6 +224,7 @@ const TOOLS_2D: ToolId[] = [ "ellipse", "spline", "bezier", + "dimension", "text", "textbox", "image", diff --git a/src/plan/generatePlan.ts b/src/plan/generatePlan.ts index f3b56f5..4cdeb6e 100644 --- a/src/plan/generatePlan.ts +++ b/src/plan/generatePlan.ts @@ -729,6 +729,9 @@ function computeBounds( acc({ x: g.center.x + g.rx, y: g.center.y + g.ry }); } else if (g.shape === "bezier" || g.shape === "spline") { g.pts.forEach(acc); + } else if (g.shape === "dimension") { + acc(g.a); + acc(g.b); } 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 ac71542..ba554f9 100644 --- a/src/plan/generatePlan/drawing2d.test.ts +++ b/src/plan/generatePlan/drawing2d.test.ts @@ -121,3 +121,56 @@ describe("addDrawing2D — spline", () => { expect(last.b).toEqual(lines[0].a); }); }); + +describe("addDrawing2D — dimension (SIA 400 B.5.3)", () => { + it("gibt 2 Masshilfslinien + 1 Masslinie (dünn) + 2 Massstriche (doppelte Stärke) + 1 Masszahl-Text aus", () => { + const d: Drawing2D = { + id: "dim1", + type: "drawing2d", + levelId: "eg", + categoryCode: "20", + geom: { shape: "dimension", a: { x: 0, y: 0 }, b: { x: 4, y: 0 }, offset: 1 }, + }; + const out: Primitive[] = []; + addDrawing2D(out, project(), d, false, lwByCode, colorByCode, catByCode); + const lines = out.filter((p): p is Extract => p.kind === "line"); + const texts = out.filter((p) => p.kind === "drawingText"); + expect(lines.length).toBe(5); // 2 Masshilfslinien + 1 Masslinie + 2 Massstriche + expect(texts.length).toBe(1); + const weights = lines.map((l) => l.weightMm).sort((a, b) => a - b); + const thin = weights[0]; + const thick = weights[weights.length - 1]; + // 3× dünn (Masshilfslinien + Masslinie), 2× doppelt so dick (Massstriche). + expect(weights.filter((w) => w === thin).length).toBe(3); + expect(weights.filter((w) => w === thick).length).toBe(2); + expect(thick).toBeCloseTo(thin * 2, 6); + }); + + it("Masszahl entspricht dist(a,b) mit Komma-Dezimaltrennzeichen (SIA 400 B.5.2)", () => { + const d: Drawing2D = { + id: "dim2", + 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 + }; + const out: Primitive[] = []; + addDrawing2D(out, project(), d, false, lwByCode, colorByCode, catByCode); + const text = out.find((p) => p.kind === "drawingText"); + expect(text).toBeDefined(); + if (text?.kind === "drawingText") expect(text.text).toBe("5,00"); + }); + + it("entartete Strecke (a===b) erzeugt keine Primitive", () => { + const d: Drawing2D = { + id: "dim3", + type: "drawing2d", + levelId: "eg", + categoryCode: "20", + geom: { shape: "dimension", a: { x: 2, y: 2 }, b: { x: 2, y: 2 }, offset: 1 }, + }; + const out: Primitive[] = []; + addDrawing2D(out, project(), d, false, lwByCode, colorByCode, catByCode); + expect(out.length).toBe(0); + }); +}); diff --git a/src/plan/generatePlan/drawing2d.ts b/src/plan/generatePlan/drawing2d.ts index d379031..783b97b 100644 --- a/src/plan/generatePlan/drawing2d.ts +++ b/src/plan/generatePlan/drawing2d.ts @@ -15,6 +15,21 @@ import { } from "./shared"; import { ellipsePts, sampleBezierChain, sampleCatmullRom } from "../../tools/curves"; +// ── Bemassung (SIA 400 B.5.3) ──────────────────────────────────────────────── +// Modell-Meter-Konstanten für rein grafische (nicht reale) Bemassungs-Details +// — analog zu DEFAULT_TEXT_HEIGHT_M (text.ts): fest gewählt, damit sie bei der +// üblichen Plan-Massstäben (1:50…1:100) lesbar wirken, nicht dynamisch an den +// Massstab gekoppelt (wie auch die übrige Textgrösse im Plan). +const DIM_GAP_M = 0.05; // Abstand Masshilfslinie → bemessener Punkt +const DIM_OVERSHOOT_M = 0.05; // Überstand der Masshilfslinie über die Masslinie hinaus +const DIM_TICK_LEN_M = 0.15; // Länge des Massstrichs (Diagonale an den Masslinien-Enden) +const DIM_TEXT_HEIGHT_M = 0.2; // Schrifthöhe der Masszahl + +/** Formatiert eine Länge (Meter) als Masszahl: 2 Dezimalstellen, Komma (SIA 400 B.5.2, „2,75"). */ +function formatDimLabel(m: number): string { + return m.toFixed(2).replace(".", ","); +} + export function addDrawing2D( out: Primitive[], project: Project, @@ -157,6 +172,65 @@ export function addDrawing2D( if (g.closed && g.pts.length > 2) seg(pts[pts.length - 1], pts[0]); 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, + }); + break; + } case "text": out.push({ kind: "drawingText", diff --git a/src/state/projectSlice.ts b/src/state/projectSlice.ts index fbc2c96..4925853 100644 --- a/src/state/projectSlice.ts +++ b/src/state/projectSlice.ts @@ -1520,6 +1520,15 @@ 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). + if (g.shape === "dimension") { + 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; + 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]; + } // Text: EIN Griff am Ankerpunkt (zum Verschieben; siehe moveGrip). if (g.shape === "text") return [g.at]; return []; @@ -1581,6 +1590,18 @@ 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. + 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; + 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; + return { ...d, geom: { ...g, offset } }; + } if (g.shape === "rect" || g.shape === "image") { const corners = [ g.min, @@ -1674,6 +1695,8 @@ function moveElementBy( 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) } }; case "text": return { ...d, geom: { ...g, at: mv(g.at) } }; default: diff --git a/src/state/selectionInfo.ts b/src/state/selectionInfo.ts index 472c1c7..f40a1ab 100644 --- a/src/state/selectionInfo.ts +++ b/src/state/selectionInfo.ts @@ -544,6 +544,16 @@ function geomBBox(g: Drawing2DGeom): SelectionBBox { case "spline": 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); + break; + } case "text": acc(g.at.x, g.at.y); break; diff --git a/src/state/useProjectFileActions.ts b/src/state/useProjectFileActions.ts index 3b5a3c6..3ca0c3b 100644 --- a/src/state/useProjectFileActions.ts +++ b/src/state/useProjectFileActions.ts @@ -54,6 +54,9 @@ function drawingsBBox( acc(g.center.x + g.rx, g.center.y + g.ry); } 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); } else if (g.shape === "text") acc(g.at.x, g.at.y); } return Number.isFinite(minX) ? { minX, minY, maxX, maxY } : null; @@ -72,6 +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 "text": return { ...d, geom: { ...g, at: mv(g.at) } }; default: return d; } diff --git a/src/tools/tools.ts b/src/tools/tools.ts index a4141a0..c59fb51 100644 --- a/src/tools/tools.ts +++ b/src/tools/tools.ts @@ -593,6 +593,19 @@ const bezierTool: Tool = { onCancel: (s) => [s, { draft: null, done: true }], }; +// Bemassung: Platzhalter-Werkzeug, gekoppelt an den `dimension`-Befehl (zwei +// Punkte + Lage der Masslinie). Nicht floorOnly. +const dimensionTool: Tool = { + id: "dimension", + labelKey: "tool.dimension", + hintKey: () => "tool.dimension.hint", + init: () => ({ phase: "idle" }), + onClick: (s) => [s, { draft: null }], + onMove: (s) => [s, { draft: null }], + onCommitGesture: (s) => [s, { draft: null, done: true }], + onCancel: (s) => [s, { draft: null, done: true }], +}; + // Text: Platzhalter-Werkzeug, gekoppelt an den `text`-Befehl (Ankerpunkt → // getippte Zeile). Wie Kreis/Bogen nicht floorOnly (freies 2D-Element). const textTool: Tool = { @@ -653,6 +666,7 @@ const TOOLS: Record = { ellipse: ellipseTool, spline: splineTool, bezier: bezierTool, + dimension: dimensionTool, text: textTool, textbox: textboxTool, image: imageTool, diff --git a/src/tools/transform.ts b/src/tools/transform.ts index 17874ca..4dc9d93 100644 --- a/src/tools/transform.ts +++ b/src/tools/transform.ts @@ -185,6 +185,8 @@ const mvGeom = (geom: Drawing2DGeom, fn: (p: Vec2) => Vec2): Drawing2DGeom => { return { ...geom, pts: geom.pts.map(fn) }; case "spline": return { ...geom, pts: geom.pts.map(fn) }; + case "dimension": + return { ...geom, a: fn(geom.a), b: fn(geom.b) }; case "text": return { ...geom, at: fn(geom.at) }; case "image": { diff --git a/src/tools/types.ts b/src/tools/types.ts index 3a69f9b..7272fe9 100644 --- a/src/tools/types.ts +++ b/src/tools/types.ts @@ -24,6 +24,7 @@ export type ToolId = | "ellipse" | "spline" | "bezier" + | "dimension" | "text" | "textbox" | "image"; diff --git a/src/viewport/drawingGrips.ts b/src/viewport/drawingGrips.ts index 449dcec..7bbb572 100644 --- a/src/viewport/drawingGrips.ts +++ b/src/viewport/drawingGrips.ts @@ -25,6 +25,7 @@ export function drawingGripVertices(d: Drawing2D): Vec2[] { ]; } if (g.shape === "bezier" || g.shape === "spline") return [...g.pts]; + if (g.shape === "dimension") return [g.a, g.b]; return []; }