From 2dc57d01a5f5274f312a7dae3e462516fa5f565b Mon Sep 17 00:00:00 2001 From: Karim Date: Sat, 22 Aug 2026 02:32:11 +0200 Subject: [PATCH] =?UTF-8?q?2D:=20H=C3=B6henkoten-Werkzeug=20+=20Masskette?= =?UTF-8?q?=20bleibt=20gerade=20+=20Punkt-Notation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neues Werkzeug "Höhenkote" (SIA 400 B.5.4, Figur 18): Dreieck-Symbol + Höhenwert an einem Punkt. Vier Varianten (OK/UK × fertig/roh) bestimmen Dreieck-Richtung und Füllung, per Option umschaltbar. Die Norm ordnet Zahlen UNTERHALB einer Masslinie explizit Höhenmassen zu — das war der eigentliche Grund, warum ein separater Typ statt einer Bemassungs-Variante nötig war. Bemassungs-Korrektur: eine Masskette bleibt beim Zeichnen immer eine GERADE Linie (Klicks ab dem dritten Punkt werden auf die durch Punkt 1/2 festgelegte Gerade projiziert), auch wenn die Bedienung wie eine Polylinie funktioniert — vorher konnte man versehentlich einen Zickzack-Pfad erzeugen. Ausserdem: Masszahlen nutzen jetzt Punkt- statt Komma-Notation ("3.96" statt "3,96") — die tatsächlich gezeichneten Beispiele in der Norm (Figur 13/14/18) verwenden durchgängig den Punkt, nicht das Komma aus der abstrakten B.5.2-Beispielliste. --- src/App.tsx | 1 + src/commands/cmds/dimension.test.ts | 23 +++++ src/commands/cmds/dimension.ts | 35 +++++--- src/commands/cmds/kote.test.ts | 86 ++++++++++++++++++ src/commands/cmds/kote.ts | 111 ++++++++++++++++++++++++ src/commands/registry.ts | 4 + src/i18n/de.ts | 11 ++- src/i18n/en.ts | 11 ++- src/model/geoRebase.ts | 2 + src/model/types.ts | 16 ++++ src/panels/ToolsPanel.tsx | 10 +++ src/plan/generatePlan.ts | 3 +- src/plan/generatePlan/drawing2d.test.ts | 61 ++++++++++++- src/plan/generatePlan/drawing2d.ts | 48 +++++++++- src/state/projectSlice.ts | 6 ++ src/state/selectionInfo.ts | 1 + src/state/useProjectFileActions.ts | 4 +- src/tools/tools.ts | 14 +++ src/tools/transform.ts | 2 + src/tools/types.ts | 1 + src/viewport/drawingGrips.ts | 2 +- 21 files changed, 433 insertions(+), 19 deletions(-) create mode 100644 src/commands/cmds/kote.test.ts create mode 100644 src/commands/cmds/kote.ts diff --git a/src/App.tsx b/src/App.tsx index 3f39c55..eeea446 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -223,6 +223,7 @@ const TOOL_COMMAND: Partial> = { spline: "spline", bezier: "bezier", dimension: "dimension", + kote: "kote", text: "text", textbox: "textbox", }; diff --git a/src/commands/cmds/dimension.test.ts b/src/commands/cmds/dimension.test.ts index 906a3a4..7a3cb3f 100644 --- a/src/commands/cmds/dimension.test.ts +++ b/src/commands/cmds/dimension.test.ts @@ -104,6 +104,29 @@ describe("dimensionCommand", () => { expect(segs[1]).toMatchObject({ a: { x: 4, y: 0 }, b: { x: 10, y: 0 }, offset: 2 }); }); + it("dritter Punkt ausserhalb der Geraden wird auf sie projiziert (keine Polylinie, immer gerade)", () => { + const p = project(); + const ctx = makeCtx(p); + let [state] = dimensionCommand.onInput( + dimensionCommand.init(), + { kind: "point", point: { x: 0, y: 0 } }, + ctx, + ); + // Richtung: entlang +x (y=0). + [state] = dimensionCommand.onInput(state, { kind: "point", point: { x: 4, y: 0 } }, ctx); + // Dritter Klick liegt bewusst NICHT auf der Geraden (y=3) → wird projiziert. + [state] = dimensionCommand.onInput(state, { kind: "point", point: { x: 10, 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(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) + } + }); + it("Zurück-Option entfernt den letzten Punkt", () => { const p = project(); const ctx = makeCtx(p); diff --git a/src/commands/cmds/dimension.ts b/src/commands/cmds/dimension.ts index f4eab46..6a05b67 100644 --- a/src/commands/cmds/dimension.ts +++ b/src/commands/cmds/dimension.ts @@ -1,10 +1,13 @@ // 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: +// Bedienung wie eine Polylinie (beliebig viele Klicks, Doppelklick/Enter +// beendet), das ERGEBNIS ist aber IMMER eine GERADE Punktreihe: der erste +// 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: // 1) „Erster Punkt:" → Punkt -// 2) „Nächster Punkt ( Fertig Zurück ):" → Punkt … (Doppelklick/Enter -// beendet die Punktreihe, sobald ≥2 Punkte gesetzt sind) +// 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 @@ -40,6 +43,15 @@ function signedOffset(a: Vec2, b: Vec2, pt: Vec2): number { return (pt.x - a.x) * nx + (pt.y - a.y) * ny; } +/** Projiziert `pt` auf die Gerade durch `p0` in Richtung `p1` (Kette bleibt gerade ab Punkt 3). */ +function projectOnLine(p0: Vec2, p1: Vec2, pt: Vec2): Vec2 { + const dx = p1.x - p0.x, dy = p1.y - p0.y; + const len2 = dx * dx + dy * dy; + if (len2 < EPS) return pt; + const t = ((pt.x - p0.x) * dx + (pt.y - p0.y) * dy) / len2; + return { x: p0.x + dx * t, y: p0.y + dy * t }; +} + /** 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; @@ -159,9 +171,11 @@ export const dimensionCommand: Command = { const ns: DimNext = { phase: "next", pts: [pt], cursor: pt, lastPoint: pt }; return [ns, { draft: chainDraft([pt], pt) }]; } - const pts = [...s.pts, pt]; - const ns: DimNext = { phase: "next", pts, cursor: pt, 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) }]; } // Offset-Schritt: Zahl = direkter Abstand, Punkt = Abstand zum Cursor. @@ -179,8 +193,9 @@ export const dimensionCommand: Command = { onMove: (state, point): [CommandState, CommandResult] => { const s = state as DimState; if (s.phase === "next") { - const ns: DimNext = { ...s, cursor: point }; - return [ns, { draft: chainDraft(s.pts, point) }]; + const cursor = s.pts.length >= 2 ? projectOnLine(s.pts[0], s.pts[1], point) : point; + const ns: DimNext = { ...s, cursor }; + return [ns, { draft: chainDraft(s.pts, cursor) }]; } if (s.phase === "offset") { // Offset relativ zum ERSTEN Segment (repräsentativ für die ganze Kette). diff --git a/src/commands/cmds/kote.test.ts b/src/commands/cmds/kote.test.ts new file mode 100644 index 0000000..7bc5c51 --- /dev/null +++ b/src/commands/cmds/kote.test.ts @@ -0,0 +1,86 @@ +/** + * `koteCommand` — Punkt + getippter Höhenwert → `shape:"kote"` (SIA 400 + * B.5.4, Figur 18). Default-Art „OK fertig"; per Option zyklisch umschaltbar. + */ + +import { describe, it, expect } from "vitest"; +import { koteCommand } from "./kote"; +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("koteCommand", () => { + it("Punkt + Zahl → shape:kote mit Default-Art OK fertig, rotation 0", () => { + const p = project(); + const ctx = makeCtx(p); + let [state] = koteCommand.onInput(koteCommand.init(), { kind: "point", point: { x: 2, y: 3 } }, ctx); + const [, result] = koteCommand.onInput(state, { kind: "number", value: 3.25 }, 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("kote"); + if (g.shape === "kote") { + expect(g.at).toEqual({ x: 2, y: 3 }); + expect(g.value).toBe(3.25); + expect(g.kind).toBe("okFertig"); + expect(g.rotation).toBe(0); + } + }); + + it("Art-Option zyklt OK fertig → UK fertig → OK roh → UK roh → OK fertig", () => { + const p = project(); + const ctx = makeCtx(p); + let [state] = koteCommand.onInput(koteCommand.init(), { kind: "point", point: { x: 0, y: 0 } }, ctx); + const seen: string[] = []; + for (let i = 0; i < 4; i++) { + [state] = koteCommand.onInput(state, { kind: "option", id: "kind" }, ctx); + const opts = koteCommand.options(state); + seen.push(opts[0].value!); + } + expect(seen).toEqual(["ukFertig", "okRoh", "ukRoh", "okFertig"]); + }); + + it("negativer Wert wird unverändert übernommen (Formatierung passiert erst beim Rendern)", () => { + const p = project(); + const ctx = makeCtx(p); + let [state] = koteCommand.onInput(koteCommand.init(), { kind: "point", point: { x: 0, y: 0 } }, ctx); + const [, result] = koteCommand.onInput(state, { kind: "number", value: -0.1 }, ctx); + const next = result.commit!(p); + const g = next.drawings2d[0].geom; + if (g.shape === "kote") expect(g.value).toBe(-0.1); + }); +}); diff --git a/src/commands/cmds/kote.ts b/src/commands/cmds/kote.ts new file mode 100644 index 0000000..80b14f1 --- /dev/null +++ b/src/commands/cmds/kote.ts @@ -0,0 +1,111 @@ +// Höhenkote (SIA 400 B.5.4, Figur 18) — Dreieck-Symbol + Höhenwert an einem +// Punkt. Schritte: +// 1) „Punkt:" → Punkt (Dreieck-Spitze) +// 2) „Höhenwert ( Art ):" → Zahl (Meter, z. B. 3.25 oder -0.1) → +// commit. „Art" (Option, zyklisch: OK fertig/UK fertig/OK roh/UK roh) +// wechselt VOR dem Tippen die Dreieck-Variante (Default OK fertig). + +import type { Drawing2D } from "../../model/types"; +import { uniqueId } from "../../tools/types"; +import type { + Command, + CommandContext, + CommandResult, + CommandState, + CmdOption, + DraftShape, + Project, + ToolDraft, + Vec2, +} from "../types"; +import type { TranslationKey } from "../../i18n"; + +type KoteKind = "okFertig" | "ukFertig" | "okRoh" | "ukRoh"; +const KIND_ORDER: KoteKind[] = ["okFertig", "ukFertig", "okRoh", "ukRoh"]; +const nextKind = (k: KoteKind): KoteKind => KIND_ORDER[(KIND_ORDER.indexOf(k) + 1) % KIND_ORDER.length]; +const KIND_LABEL: Record = { + okFertig: "cmd.kote.kind.okFertig", + ukFertig: "cmd.kote.kind.ukFertig", + okRoh: "cmd.kote.kind.okRoh", + ukRoh: "cmd.kote.kind.ukRoh", +}; + +const TRI_SIZE = 0.15; // Modell-Meter, Vorschau-Grösse (Rendering: DIM_… in drawing2d.ts) +const HALF_W = TRI_SIZE * 0.6; + +/** Dreieck-Punkte für die Vorschau: `at` = Spitze; OK zeigt nach unten, UK nach oben. */ +function triPts(at: Vec2, kind: KoteKind): Vec2[] { + const up = kind === "okFertig" || kind === "okRoh" ? 1 : -1; // Basis liegt in +up-Richtung von `at` + return [ + at, + { x: at.x - HALF_W, y: at.y + up * TRI_SIZE }, + { x: at.x + HALF_W, y: at.y + up * TRI_SIZE }, + ]; +} + +interface KotePoint extends CommandState { + phase: "point"; +} +interface KoteValue extends CommandState { + phase: "value"; + at: Vec2; + kind: KoteKind; +} +type KoteState = KotePoint | KoteValue; + +function kindOption(kind: KoteKind): CmdOption { + return { id: "kind", labelKey: KIND_LABEL[kind], value: kind }; +} + +function valueDraft(at: Vec2, kind: KoteKind): ToolDraft { + const preview: DraftShape[] = [{ kind: "poly", pts: triPts(at, kind), closed: true }]; + return { preview, vertices: [at] }; +} + +function appendKote(p: Project, at: Vec2, value: number, kind: KoteKind, ctx: CommandContext): Project { + const d: Drawing2D = { + id: uniqueId("dr2d"), + type: "drawing2d", + levelId: ctx.level.id, + categoryCode: ctx.defaultCategoryCode, + geom: { shape: "kote", at, value, kind, rotation: 0 }, + }; + return { ...p, drawings2d: [...p.drawings2d, d] }; +} + +const idle = (): [CommandState, CommandResult] => [ + { phase: "point", lastPoint: null }, + { draft: null, done: true }, +]; + +export const koteCommand: Command = { + name: "kote", + labelKey: "cmd.kote.label", + prompt: (s) => ((s as KoteState).phase === "value" ? "cmd.kote.value" : "cmd.kote.point"), + accepts: (s) => ((s as KoteState).phase === "value" ? ["number", "option"] : ["point"]), + options: (s) => ((s as KoteState).phase === "value" ? [kindOption((s as KoteValue).kind)] : []), + init: (): KotePoint => ({ phase: "point", lastPoint: null }), + + onInput: (state, input, ctx): [CommandState, CommandResult] => { + const s = state as KoteState; + if (s.phase === "point") { + if (input.kind !== "point") return [s, { draft: null }]; + const ns: KoteValue = { phase: "value", at: input.point, kind: "okFertig", lastPoint: input.point }; + return [ns, { draft: valueDraft(input.point, "okFertig") }]; + } + if (input.kind === "option" && input.id === "kind") { + const ns: KoteValue = { ...s, kind: nextKind(s.kind) }; + return [ns, { draft: valueDraft(s.at, ns.kind) }]; + } + if (input.kind !== "number") return [s, { draft: valueDraft(s.at, s.kind) }]; + const { at, kind } = s; + return [ + { phase: "point", lastPoint: null }, + { draft: null, done: true, commit: (p) => appendKote(p, at, input.value, kind, ctx) }, + ]; + }, + + onMove: (state): [CommandState, CommandResult] => [state, { draft: null }], + onConfirm: (): [CommandState, CommandResult] => idle(), + onCancel: (): [CommandState, CommandResult] => idle(), +}; diff --git a/src/commands/registry.ts b/src/commands/registry.ts index 2b82a77..011b8aa 100644 --- a/src/commands/registry.ts +++ b/src/commands/registry.ts @@ -21,6 +21,7 @@ import { ellipseCommand } from "./cmds/ellipse"; import { splineCommand } from "./cmds/spline"; import { bezierCommand } from "./cmds/bezier"; import { dimensionCommand } from "./cmds/dimension"; +import { koteCommand } from "./cmds/kote"; import { textCommand } from "./cmds/text"; import { textboxCommand } from "./cmds/textbox"; import { moveCommand } from "./cmds/move"; @@ -61,6 +62,7 @@ export const COMMANDS: Record = { spline: splineCommand, bezier: bezierCommand, dimension: dimensionCommand, + kote: koteCommand, text: textCommand, textbox: textboxCommand, move: moveCommand, @@ -116,6 +118,8 @@ export const ALIASES: Record = { sp: "spline", dim: "dimension", bemassung: "dimension", + kt: "kote", + hoehenkote: "kote", bz: "bezier", tx: "text", txt: "text", diff --git a/src/i18n/de.ts b/src/i18n/de.ts index 5d6f33a..40a3427 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -185,7 +185,9 @@ 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: Punkte wie Polylinie klicken, Doppelklick/Enter beendet, dann Lage der Masslinie (Massketten in einer Geste)", + "tool.dimension.hint": "Bemassung: Punkte wie Polylinie klicken (ab Punkt 3 auf einer Geraden), Doppelklick/Enter beendet, dann Lage der Masslinie (Massketten in einer Geste)", + "tool.kote": "Höhenkote", + "tool.kote.hint": "Höhenkote: Punkt setzen, dann Höhenwert eintippen (Art umschaltbar: OK/UK, fertig/roh)", "tool.text": "Text", "tool.text.hint": "Text: Ankerpunkt setzen, dann Text eintippen", "tool.textbox": "Textspalte", @@ -1168,6 +1170,13 @@ export const de = { "cmd.dimension.offset": "Lage der Masslinie:", "cmd.dimension.undo": "Zurück", "cmd.dimension.finish": "Fertig", + "cmd.kote.label": "Höhenkote", + "cmd.kote.point": "Punkt:", + "cmd.kote.value": "Höhenwert ( Art ):", + "cmd.kote.kind.okFertig": "OK fertig", + "cmd.kote.kind.ukFertig": "UK fertig", + "cmd.kote.kind.okRoh": "OK roh", + "cmd.kote.kind.ukRoh": "UK roh", "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 f518ed8..e6bc56e 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -183,7 +183,9 @@ 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: click points like a polyline, double-click/Enter finishes, then set the dimension line position (chains in one gesture)", + "tool.dimension.hint": "Dimension: click points like a polyline (point 3+ snaps to a straight line), double-click/Enter finishes, then set the dimension line position (chains in one gesture)", + "tool.kote": "Elevation mark", + "tool.kote.hint": "Elevation mark: set a point, then type the height value (type toggle: top/bottom, finished/raw)", "tool.text": "Text", "tool.text.hint": "Text: set anchor point, then type the text", "tool.textbox": "Text column", @@ -1157,6 +1159,13 @@ export const en: Record = { "cmd.dimension.offset": "Dimension line position:", "cmd.dimension.undo": "Undo", "cmd.dimension.finish": "Finish", + "cmd.kote.label": "Elevation mark", + "cmd.kote.point": "Point:", + "cmd.kote.value": "Height value ( Type ):", + "cmd.kote.kind.okFertig": "Top, finished", + "cmd.kote.kind.ukFertig": "Bottom, finished", + "cmd.kote.kind.okRoh": "Top, raw", + "cmd.kote.kind.ukRoh": "Bottom, raw", "cmd.field.offset": "Offset", "cmd.text.label": "Text", "cmd.text.point": "Anchor point:", diff --git a/src/model/geoRebase.ts b/src/model/geoRebase.ts index 42e5d7e..70c015e 100644 --- a/src/model/geoRebase.ts +++ b/src/model/geoRebase.ts @@ -114,6 +114,8 @@ export function translateProject(project: Project, dx: number, dy: number): Proj 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 "kote": + return { ...d, geom: { ...g, at: shift(g.at, 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 335e5f1..1cf5072 100644 --- a/src/model/types.ts +++ b/src/model/types.ts @@ -1534,6 +1534,22 @@ export type Drawing2DGeom = * andere Elemente — Assoziativität nur über die eigenen Endpunkte). */ | { shape: "dimension"; a: Vec2; b: 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 + * Dreieck-Richtung (OK = Spitze nach unten, UK = Spitze nach oben) UND + * Füllung (fertig = Kontur/hohl, roh = voll gefüllt) — vier norm-genaue + * Varianten, die fünfte („OK fertig UND roh kombiniert") ist nicht gebaut. + * `at` ist die SPITZE des Dreiecks (der exakte referenzierte Punkt). + */ + | { + shape: "kote"; + at: Vec2; + value: number; + kind: "okFertig" | "ukFertig" | "okRoh" | "ukRoh"; + /** Drehung (Radiant); Werkzeug erzeugt bewusst nur 0, s. Ellipse-Muster. */ + rotation: number; + } | { shape: "text"; at: Vec2; diff --git a/src/panels/ToolsPanel.tsx b/src/panels/ToolsPanel.tsx index c970d54..f2704b9 100644 --- a/src/panels/ToolsPanel.tsx +++ b/src/panels/ToolsPanel.tsx @@ -179,6 +179,15 @@ export function ToolIcon({ id }: { id: ToolId }) { ); + case "kote": + // Höhenkote: Dreieck (Spitze unten, OK) + Zahl. + return ( + + + + + + ); case "text": // Serifen-„A" als Text-Symbol. return ( @@ -225,6 +234,7 @@ const TOOLS_2D: ToolId[] = [ "spline", "bezier", "dimension", + "kote", "text", "textbox", "image", diff --git a/src/plan/generatePlan.ts b/src/plan/generatePlan.ts index 4cdeb6e..b9593de 100644 --- a/src/plan/generatePlan.ts +++ b/src/plan/generatePlan.ts @@ -732,7 +732,8 @@ function computeBounds( } else if (g.shape === "dimension") { acc(g.a); acc(g.b); - } else if (g.shape === "text") acc(g.at); + } 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); } } // GESAMTE Kontext-Schicht (Konturen, importierte Gebäude/Terrain, Luftbild) diff --git a/src/plan/generatePlan/drawing2d.test.ts b/src/plan/generatePlan/drawing2d.test.ts index ba554f9..2b45013 100644 --- a/src/plan/generatePlan/drawing2d.test.ts +++ b/src/plan/generatePlan/drawing2d.test.ts @@ -146,7 +146,7 @@ describe("addDrawing2D — dimension (SIA 400 B.5.3)", () => { expect(thick).toBeCloseTo(thin * 2, 6); }); - it("Masszahl entspricht dist(a,b) mit Komma-Dezimaltrennzeichen (SIA 400 B.5.2)", () => { + it("Masszahl entspricht dist(a,b), 2 Dezimalstellen (SIA 400 Figur 13/14: Punkt-Notation)", () => { const d: Drawing2D = { id: "dim2", type: "drawing2d", @@ -158,7 +158,7 @@ describe("addDrawing2D — dimension (SIA 400 B.5.3)", () => { 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"); + if (text?.kind === "drawingText") expect(text.text).toBe("5.00"); }); it("entartete Strecke (a===b) erzeugt keine Primitive", () => { @@ -174,3 +174,60 @@ describe("addDrawing2D — dimension (SIA 400 B.5.3)", () => { expect(out.length).toBe(0); }); }); + +describe("addDrawing2D — kote (SIA 400 B.5.4)", () => { + it("OK fertig: hohles Dreieck (Spitze nach unten) + formatierte Zahl mit +", () => { + const d: Drawing2D = { + id: "k1", + type: "drawing2d", + levelId: "eg", + categoryCode: "20", + geom: { shape: "kote", at: { x: 0, y: 0 }, value: 3.25, kind: "okFertig", rotation: 0 }, + }; + const out: Primitive[] = []; + addDrawing2D(out, project(), d, false, lwByCode, colorByCode, catByCode); + const poly = out.find((p) => p.kind === "polygon"); + const text = out.find((p) => p.kind === "drawingText"); + expect(poly).toBeDefined(); + expect(text).toBeDefined(); + if (poly?.kind === "polygon") { + expect(poly.fill).toBe("none"); // fertig = hohl + expect(poly.pts[0]).toEqual({ x: 0, y: 0 }); // Spitze = at + expect(poly.pts[1].y).toBeGreaterThan(0); // OK: Basis oberhalb der Spitze + } + if (text?.kind === "drawingText") expect(text.text).toBe("+3.25"); + }); + + it("UK roh: gefülltes Dreieck (Spitze nach oben), Basis unterhalb", () => { + const d: Drawing2D = { + id: "k2", + type: "drawing2d", + levelId: "eg", + categoryCode: "20", + geom: { shape: "kote", at: { x: 0, y: 0 }, value: -0.1, kind: "ukRoh", rotation: 0 }, + }; + const out: Primitive[] = []; + addDrawing2D(out, project(), d, false, lwByCode, colorByCode, catByCode); + const poly = out.find((p) => p.kind === "polygon"); + const text = out.find((p) => p.kind === "drawingText"); + if (poly?.kind === "polygon") { + expect(poly.fill).not.toBe("none"); // roh = gefüllt + expect(poly.pts[1].y).toBeLessThan(0); // UK: Basis unterhalb der Spitze + } + if (text?.kind === "drawingText") expect(text.text).toBe("-0.10"); + }); + + it("Wert 0 wird als ±0.00 formatiert", () => { + const d: Drawing2D = { + id: "k3", + type: "drawing2d", + levelId: "eg", + categoryCode: "20", + geom: { shape: "kote", at: { x: 0, y: 0 }, value: 0, kind: "okFertig", rotation: 0 }, + }; + const out: Primitive[] = []; + addDrawing2D(out, project(), d, false, lwByCode, colorByCode, catByCode); + const text = out.find((p) => p.kind === "drawingText"); + if (text?.kind === "drawingText") expect(text.text).toBe("±0.00"); + }); +}); diff --git a/src/plan/generatePlan/drawing2d.ts b/src/plan/generatePlan/drawing2d.ts index 783b97b..48de75c 100644 --- a/src/plan/generatePlan/drawing2d.ts +++ b/src/plan/generatePlan/drawing2d.ts @@ -25,9 +25,21 @@ const DIM_OVERSHOOT_M = 0.05; // Überstand der Masshilfslinie über die Masslin 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"). */ +/** Formatiert eine Länge (Meter) als Masszahl: 2 Dezimalstellen (SIA 400 B.5.2/B.5.4, „3.25"/„518.60"). */ function formatDimLabel(m: number): string { - return m.toFixed(2).replace(".", ","); + return m.toFixed(2); +} + +// ── 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; +const KOTE_TEXT_HEIGHT_M = 0.2; +const KOTE_TEXT_GAP_M = 0.08; // Abstand Dreieck-Basis → Zahl + +/** Formatiert einen Höhenwert (SIA 400 B.5.4, Figur 18): „+3.25" · „±0.00" · „-0.10". */ +function formatKoteLabel(value: number): string { + if (Math.abs(value) < 1e-6) return "±0.00"; + return (value > 0 ? "+" : "") + value.toFixed(2); } export function addDrawing2D( @@ -231,6 +243,38 @@ export function addDrawing2D( }); break; } + case "kote": { + // SIA 400 B.5.4/Figur 18: OK = Spitze nach unten, UK = Spitze nach oben; + // fertig = Kontur (hohl), roh = voll gefüllt. `at` ist die Spitze. + const up = g.kind === "okFertig" || g.kind === "okRoh" ? 1 : -1; + const filled = g.kind === "okRoh" || g.kind === "ukRoh"; + const pts: Vec2[] = [ + g.at, + { x: g.at.x - KOTE_TRI_HALF_W_M, y: g.at.y + up * KOTE_TRI_SIZE_M }, + { x: g.at.x + KOTE_TRI_HALF_W_M, y: g.at.y + up * KOTE_TRI_SIZE_M }, + ]; + out.push({ + kind: "polygon", + pts, + fill: filled ? color : "none", + stroke: color, + strokeWidthMm: weightMm, + hatch: NO_HATCH, + greyed, + drawingId: d.id, + }); + out.push({ + kind: "drawingText", + at: { x: g.at.x + KOTE_TRI_HALF_W_M + KOTE_TEXT_GAP_M, y: g.at.y + up * KOTE_TRI_SIZE_M * 0.5 }, + text: formatKoteLabel(g.value), + heightM: KOTE_TEXT_HEIGHT_M, + angle: 0, + 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 4925853..e2a1983 100644 --- a/src/state/projectSlice.ts +++ b/src/state/projectSlice.ts @@ -1529,6 +1529,8 @@ export function drawingVertices(d: import("../model/types").Drawing2D): Vec2[] { 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]; } + // Kote: EIN Griff an der Dreieck-Spitze (zum Verschieben; siehe moveGrip). + if (g.shape === "kote") return [g.at]; // Text: EIN Griff am Ankerpunkt (zum Verschieben; siehe moveGrip). if (g.shape === "text") return [g.at]; return []; @@ -1616,6 +1618,8 @@ function moveGrip( } // Text: der einzige Griff (Index 0) sitzt am Ankerpunkt → verschiebt ihn. if (g.shape === "text") return { ...d, geom: { ...g, at: pt } }; + // Kote: der einzige Griff (Index 0) sitzt an der Dreieck-Spitze. + if (g.shape === "kote") return { ...d, geom: { ...g, at: pt } }; // Kreis: Griff 0 (Ost) bzw. 1 (Nord) — zieht man die beiden Achsen // unabhängig auseinander, wird der Kreis zur Ellipse (rotation 0); // bleiben rx/ry (fast) gleich, bleibt es ein Kreis (s. `ellipseOrCircle`). @@ -1697,6 +1701,8 @@ function moveElementBy( return { ...d, geom: { ...g, pts: g.pts.map(mv) } }; case "dimension": return { ...d, geom: { ...g, a: mv(g.a), b: mv(g.b) } }; + case "kote": + return { ...d, geom: { ...g, at: mv(g.at) } }; case "text": return { ...d, geom: { ...g, at: mv(g.at) } }; default: diff --git a/src/state/selectionInfo.ts b/src/state/selectionInfo.ts index f40a1ab..00ccf94 100644 --- a/src/state/selectionInfo.ts +++ b/src/state/selectionInfo.ts @@ -554,6 +554,7 @@ function geomBBox(g: Drawing2DGeom): SelectionBBox { acc(g.b.x + nx, g.b.y + ny); break; } + case "kote": case "text": acc(g.at.x, g.at.y); break; diff --git a/src/state/useProjectFileActions.ts b/src/state/useProjectFileActions.ts index 3ca0c3b..65f0fb2 100644 --- a/src/state/useProjectFileActions.ts +++ b/src/state/useProjectFileActions.ts @@ -57,7 +57,8 @@ function drawingsBBox( } 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); + } else if (g.shape === "kote") acc(g.at.x, g.at.y); + else if (g.shape === "text") acc(g.at.x, g.at.y); } return Number.isFinite(minX) ? { minX, minY, maxX, maxY } : null; } @@ -76,6 +77,7 @@ function translateDrawing(d: Drawing2D, dx: number, dy: number): Drawing2D { 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 "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/tools.ts b/src/tools/tools.ts index c59fb51..96318e1 100644 --- a/src/tools/tools.ts +++ b/src/tools/tools.ts @@ -606,6 +606,19 @@ const dimensionTool: Tool = { onCancel: (s) => [s, { draft: null, done: true }], }; +// Höhenkote: Platzhalter-Werkzeug, gekoppelt an den `kote`-Befehl (Punkt → +// getippter Höhenwert). Nicht floorOnly. +const koteTool: Tool = { + id: "kote", + labelKey: "tool.kote", + hintKey: () => "tool.kote.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 = { @@ -667,6 +680,7 @@ const TOOLS: Record = { spline: splineTool, bezier: bezierTool, dimension: dimensionTool, + kote: koteTool, text: textTool, textbox: textboxTool, image: imageTool, diff --git a/src/tools/transform.ts b/src/tools/transform.ts index 4dc9d93..e209457 100644 --- a/src/tools/transform.ts +++ b/src/tools/transform.ts @@ -187,6 +187,8 @@ const mvGeom = (geom: Drawing2DGeom, fn: (p: Vec2) => Vec2): Drawing2DGeom => { return { ...geom, pts: geom.pts.map(fn) }; case "dimension": return { ...geom, a: fn(geom.a), b: fn(geom.b) }; + case "kote": + return { ...geom, at: fn(geom.at) }; case "text": return { ...geom, at: fn(geom.at) }; case "image": { diff --git a/src/tools/types.ts b/src/tools/types.ts index 7272fe9..fed8b65 100644 --- a/src/tools/types.ts +++ b/src/tools/types.ts @@ -25,6 +25,7 @@ export type ToolId = | "spline" | "bezier" | "dimension" + | "kote" | "text" | "textbox" | "image"; diff --git a/src/viewport/drawingGrips.ts b/src/viewport/drawingGrips.ts index 7bbb572..71b435f 100644 --- a/src/viewport/drawingGrips.ts +++ b/src/viewport/drawingGrips.ts @@ -47,6 +47,6 @@ export function drawingMoveAnchor(d: Drawing2D, verts: Vec2[]): Vec2 | null { } const g = d.geom; if (g.shape === "circle" || g.shape === "arc" || g.shape === "ellipse") return g.center; - if (g.shape === "text") return g.at; + if (g.shape === "text" || g.shape === "kote") return g.at; return null; }