From 492e1f811afcdc4ec3f74c46f83e0268fa3e04a7 Mon Sep 17 00:00:00 2001 From: Karim Date: Thu, 9 Jul 2026 01:13:15 +0200 Subject: [PATCH] =?UTF-8?q?Mess-Werkzeug:=20Polygonzug=20mit=20Fl=C3=A4che?= =?UTF-8?q?=20+=20Live-Werte=20im=20Objekt-Info-Panel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Das Mess-Werkzeug wird von der reinen Strecke zum Polygonzug: - Jeder Klick setzt einen weiteren Stützpunkt; angezeigt werden letztes Segment (Länge · Winkel), aufsummierte Pfadlänge und — ab 3 Ecken — die umschlossene Fläche (Gauss). - Die Messwerte erscheinen dauerhaft im Objekt-Info-Panel (neuer Abschnitt „Messung"), nicht nur flüchtig am Cursor-HUD. Kanal: ToolDraft.measure → PanelHost.measurement → ObjectInfoPanel. - Rechtsklick/Enter beendet den aktuellen Pfad und armiert sofort einen neuen (mehrere Messungen nacheinander); Rechtsklick auf leerem Pfad bzw. Esc beendet das Werkzeug. +4 Tests (Segment/Winkel, Summe/Fläche, Ketten-Verhalten). tsc sauber. --- src/App.tsx | 3 + src/commands/cmds/measure.test.ts | 46 ++++++++++++ src/commands/cmds/measure.ts | 113 +++++++++++++++++++++++------- src/i18n/de.ts | 7 ++ src/i18n/en.ts | 7 ++ src/panels/ObjectInfoPanel.tsx | 35 ++++++++- src/panels/host.ts | 8 ++- src/styles.css | 26 +++++++ src/tools/types.ts | 23 ++++++ 9 files changed, 239 insertions(+), 29 deletions(-) create mode 100644 src/commands/cmds/measure.test.ts diff --git a/src/App.tsx b/src/App.tsx index 5a90914..4f87797 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2810,6 +2810,9 @@ export default function App() { onImportLineStyles: importLineStyles, onImportHatches: importHatches, selection, + // Live-Messwerte des Mess-Werkzeugs (aus dem aktiven Draft) — das Objekt- + // Info-Panel zeigt sie, solange gemessen wird. + measurement: draft?.measure ?? null, onSetSelectionColor: (color) => { if (!selection) return; if (selection.kind === "wall" || selection.kind === "drawing2d") diff --git a/src/commands/cmds/measure.test.ts b/src/commands/cmds/measure.test.ts new file mode 100644 index 0000000..a420a6b --- /dev/null +++ b/src/commands/cmds/measure.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from "vitest"; +import { measureCommand } from "./measure"; +import type { CommandContext, Vec2 } from "../types"; + +// Der Kontext wird vom Mess-Werkzeug nicht gelesen (rein visuell) — Dummy genügt. +const ctx = {} as CommandContext; +const P = (x: number, y: number): Vec2 => ({ x, y }); + +describe("measureCommand", () => { + it("misst Segment + Winkel ab dem ersten Punkt", () => { + const s0 = measureCommand.init(); + const [s1] = measureCommand.onInput(s0, { kind: "point", point: P(0, 0) }, ctx); + const [, r] = measureCommand.onMove(s1, P(3, 0), null, ctx); + expect(r.draft?.measure?.segment).toBeCloseTo(3, 6); + expect(r.draft?.measure?.angle).toBeCloseTo(0, 6); + expect(r.draft?.measure?.area).toBeNull(); + expect(r.draft?.measure?.points).toBe(1); + }); + + it("summiert die Pfadlänge und zeigt die Fläche ab 3 Ecken", () => { + let s = measureCommand.init(); + [s] = measureCommand.onInput(s, { kind: "point", point: P(0, 0) }, ctx); + [s] = measureCommand.onInput(s, { kind: "point", point: P(4, 0) }, ctx); + [s] = measureCommand.onInput(s, { kind: "point", point: P(4, 3) }, ctx); + // Cursor zurück Richtung Ursprung → geschlossenes Dreieck 4×3/2 = 6 m². + const [, r] = measureCommand.onMove(s, P(0, 0), null, ctx); + expect(r.draft?.measure?.total).toBeCloseTo(4 + 3 + 5, 6); // 0→4→3→Hypotenuse 5 + expect(r.draft?.measure?.area).toBeCloseTo(6, 6); + expect(r.draft?.measure?.points).toBe(3); + }); + + it("Rechtsklick beendet den Pfad und armiert einen neuen (kein done)", () => { + let s = measureCommand.init(); + [s] = measureCommand.onInput(s, { kind: "point", point: P(1, 1) }, ctx); + const [ns, r] = measureCommand.onConfirm(s, ctx); + expect(r.done).not.toBe(true); + expect((ns as { phase: string }).phase).toBe("start"); + expect(r.draft).toBeNull(); + }); + + it("Rechtsklick auf leerem Pfad beendet das Werkzeug", () => { + const s = measureCommand.init(); + const [, r] = measureCommand.onConfirm(s, ctx); + expect(r.done).toBe(true); + }); +}); diff --git a/src/commands/cmds/measure.ts b/src/commands/cmds/measure.ts index 8a2240b..f871a6e 100644 --- a/src/commands/cmds/measure.ts +++ b/src/commands/cmds/measure.ts @@ -1,10 +1,11 @@ -// Messen — schnelles, unverbindliches Distanz-/Winkel-Messwerkzeug (kein Element -// wird erzeugt). Schritte: -// 1) „Messen von:" → Punkt (Startpunkt) -// 2) „bis:" → Live-Vorschau (Messlinie + Distanz·Winkel im HUD); Klick -// setzt einen neuen Startpunkt (fortlaufend messen). -// Esc/Enter beendet. Rein visuell: `commit` bleibt immer leer, das Modell ändert -// sich nie — nur der Entwurf (Linie + HUD) zeigt das Mass. +// Messen — schnelles, unverbindliches Mess-Werkzeug (kein Element wird erzeugt). +// Ein Mess-Pfad ist ein Polygonzug: jeder Klick setzt einen weiteren Stützpunkt. +// • 1 Punkt gesetzt → Live-Strecke (Länge · Winkel) zum Cursor. +// • ≥ 2 Punkte gesetzt → zusätzlich aufsummierte Pfadlänge; sobald der Zug +// (inkl. Cursor) ≥ 3 Ecken hat, zeigt er auch die umschlossene FLÄCHE. +// Rechtsklick/Enter beendet den aktuellen Pfad und armiert einen NEUEN — so misst +// man mehrere nacheinander. Ein weiterer Rechtsklick auf leerem Pfad (bzw. Esc) +// beendet das Werkzeug. Rein visuell: `commit` bleibt immer leer. import type { Command, @@ -13,29 +14,70 @@ import type { ToolDraft, Vec2, } from "../types"; +import type { MeasurementReadout } from "../../tools/types"; const segLen = (a: Vec2, b: Vec2): number => Math.hypot(b.x - a.x, b.y - a.y); const segAngleDeg = (a: Vec2, b: Vec2): number => (Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI; +/** Aufsummierte Länge eines Polygonzugs (offene Kette). */ +function pathLength(pts: Vec2[]): number { + let sum = 0; + for (let i = 1; i < pts.length; i++) sum += segLen(pts[i - 1], pts[i]); + return sum; +} + +/** Vorzeichenlose Fläche (Gauss/Schuhband), Zug als geschlossen gedacht. */ +function polygonArea(pts: Vec2[]): number { + if (pts.length < 3) return 0; + let a = 0; + for (let i = 0; i < pts.length; i++) { + const p = pts[i]; + const q = pts[(i + 1) % pts.length]; + a += p.x * q.y - q.x * p.y; + } + return Math.abs(a) / 2; +} + interface MeasureStart extends CommandState { phase: "start"; } -interface MeasureTo extends CommandState { - phase: "to"; - from: Vec2; +interface MeasureDraw extends CommandState { + phase: "draw"; + /** Bereits gesetzte Stützpunkte (≥ 1). */ + points: Vec2[]; cursor: Vec2 | null; } -type MeasureState = MeasureStart | MeasureTo; +type MeasureState = MeasureStart | MeasureDraw; -/** Vorschau: Messlinie from→cursor + Distanz·Winkel im HUD am Cursor. */ -function measureDraft(from: Vec2, cursor: Vec2): ToolDraft { - const dist = segLen(from, cursor); - const ang = ((segAngleDeg(from, cursor) % 360) + 360) % 360; +/** Messwerte aus gesetzten Punkten + aktuellem Cursor bilden. */ +function readout(points: Vec2[], cursor: Vec2 | null): MeasurementReadout { + const chain = cursor ? [...points, cursor] : points; + const last = points[points.length - 1]; + const segment = cursor && last ? segLen(last, cursor) : 0; + const angle = cursor && last ? ((segAngleDeg(last, cursor) % 360) + 360) % 360 : 0; return { - preview: [{ kind: "line", a: from, b: cursor }], - vertices: [from, cursor], - hud: { at: cursor, text: `${dist.toFixed(3)} m · ${ang.toFixed(1)}°` }, + segment, + total: pathLength(chain), + area: chain.length >= 3 ? polygonArea(chain) : null, + angle, + points: points.length, + }; +} + +/** Vorschau: gesetzter Zug + Live-Segment zum Cursor + HUD + Messwerte. */ +function measureDraft(points: Vec2[], cursor: Vec2 | null): ToolDraft { + const chain = cursor ? [...points, cursor] : points; + const r = readout(points, cursor); + const hudText = + r.area != null + ? `${r.segment.toFixed(3)} m · Σ ${r.total.toFixed(3)} m · ${r.area.toFixed(2)} m²` + : `${r.segment.toFixed(3)} m · ${r.angle.toFixed(1)}°`; + return { + preview: [{ kind: "poly", pts: chain, closed: chain.length >= 3 }], + vertices: points, + hud: cursor ? { at: cursor, text: hudText } : undefined, + measure: r, }; } @@ -47,7 +89,7 @@ const idle = (): [CommandState, CommandResult] => [ export const measureCommand: Command = { name: "measure", labelKey: "cmd.measure.label", - prompt: (s) => ((s as MeasureState).phase === "to" ? "cmd.measure.to" : "cmd.measure.start"), + prompt: (s) => ((s as MeasureState).phase === "draw" ? "cmd.measure.next" : "cmd.measure.start"), accepts: () => ["point"], options: () => [], init: (): MeasureStart => ({ phase: "start", lastPoint: null }), @@ -55,20 +97,37 @@ export const measureCommand: Command = { onInput: (state, input): [CommandState, CommandResult] => { const s = state as MeasureState; if (input.kind !== "point") { - return [s, { draft: s.phase === "to" && s.cursor ? measureDraft(s.from, s.cursor) : null }]; + const draft = + s.phase === "draw" ? measureDraft(s.points, s.cursor) : null; + return [s, { draft }]; } - // Jeder Klick setzt den (neuen) Startpunkt — fortlaufendes Messen, bis Esc/Enter. - const ns: MeasureTo = { phase: "to", from: input.point, cursor: input.point, lastPoint: input.point }; - return [ns, { draft: measureDraft(input.point, input.point) }]; + // Klick hängt einen Stützpunkt an den Pfad (bzw. beginnt einen neuen). + const points = s.phase === "draw" ? [...s.points, input.point] : [input.point]; + const ns: MeasureDraw = { + phase: "draw", + points, + cursor: input.point, + lastPoint: input.point, + }; + return [ns, { draft: measureDraft(points, input.point) }]; }, onMove: (state, point): [CommandState, CommandResult] => { const s = state as MeasureState; - if (s.phase !== "to") return [s, { draft: null }]; - const ns: MeasureTo = { ...s, cursor: point }; - return [ns, { draft: measureDraft(s.from, point) }]; + if (s.phase !== "draw") return [s, { draft: null }]; + const ns: MeasureDraw = { ...s, cursor: point }; + return [ns, { draft: measureDraft(s.points, point) }]; }, - onConfirm: (): [CommandState, CommandResult] => idle(), + // Rechtsklick/Enter: laufenden Pfad beenden und einen NEUEN armieren (mehrere + // Messungen nacheinander). Auf leerem Pfad = Werkzeug beenden. + onConfirm: (state): [CommandState, CommandResult] => { + const s = state as MeasureState; + if (s.phase === "draw" && s.points.length > 0) { + return [{ phase: "start", lastPoint: null } as MeasureStart, { draft: null }]; + } + return idle(); + }, + // Esc: Werkzeug ganz beenden. onCancel: (): [CommandState, CommandResult] => idle(), }; diff --git a/src/i18n/de.ts b/src/i18n/de.ts index f61c650..52d9f5a 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -918,6 +918,13 @@ export const de = { "cmd.measure.label": "Messen", "cmd.measure.start": "Messen von:", "cmd.measure.to": "bis (Distanz · Winkel):", + "cmd.measure.next": "nächster Punkt (Rechtsklick = neuer Pfad):", + "objinfo.measure.title": "Messung", + "objinfo.measure.segment": "Segment", + "objinfo.measure.total": "Länge gesamt", + "objinfo.measure.area": "Fläche", + "objinfo.measure.angle": "Winkel", + "objinfo.measure.hint": "Klick = Punkt · Rechtsklick = neuer Pfad · Esc = Ende", "transform.needSelection": "Erst Objekte wählen, dann drehen.", "cmd.mirror.label": "Spiegeln", "cmd.mirror.first": "Erster Punkt der Spiegelachse:", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index e78f71c..0f8b189 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -907,6 +907,13 @@ export const en: Record = { "cmd.measure.label": "Measure", "cmd.measure.start": "Measure from:", "cmd.measure.to": "to (distance · angle):", + "cmd.measure.next": "next point (right-click = new path):", + "objinfo.measure.title": "Measurement", + "objinfo.measure.segment": "Segment", + "objinfo.measure.total": "Total length", + "objinfo.measure.area": "Area", + "objinfo.measure.angle": "Angle", + "objinfo.measure.hint": "Click = point · right-click = new path · Esc = end", "transform.needSelection": "Select objects first, then rotate.", "cmd.mirror.label": "Mirror", "cmd.mirror.first": "First point of mirror axis:", diff --git a/src/panels/ObjectInfoPanel.tsx b/src/panels/ObjectInfoPanel.tsx index ab65ad5..dff6b77 100644 --- a/src/panels/ObjectInfoPanel.tsx +++ b/src/panels/ObjectInfoPanel.tsx @@ -36,6 +36,7 @@ import type { StairInfo, WallInfo, } from "../state/selectionInfo"; +import type { MeasurementReadout } from "../tools/types"; // ── Bezugspunkt-Raster ─────────────────────────────────────────────────────── // Neun Anker als 3×3-Raster. Zeile 0 = oben (fy=0), Spalte 0 = links (fx=0). @@ -58,11 +59,16 @@ export function ObjectInfoPanel() { fy: 0.5, }); + // ── Mess-Werkzeug: Live-Messwerte (Länge/Fläche/Winkel) ───────────────────── + // Werden angezeigt, sobald gemessen wird — unabhängig von einer Selektion + // (das Mess-Werkzeug erzeugt kein Element). Hat Vorrang vor dem Leerzustand. + const m = host.measurement; + // ── Leerzustand ─────────────────────────────────────────────────────────── if (sel === null) { return (
-
{t("objinfo.empty")}
+ {m ? :
{t("objinfo.empty")}
}
); } @@ -100,6 +106,7 @@ export function ObjectInfoPanel() { return (
+ {m && } {/* Kopfzeile: Typ + Kategorie. Die element-spezifischen Attribut-Abschnitte (Wand/Decke/Öffnung/Treppe/Raum) liegen jetzt im Attribute-Panel; ObjectInfo trägt nur noch Bezugspunkt + Masse. */} @@ -1234,3 +1241,29 @@ function RotateField({ onCommit }: { onCommit: (deg: number) => void }) { ); } + +// ── Mess-Werkzeug-Abschnitt ─────────────────────────────────────────────────── +// Zeigt die Live-Messwerte (letztes Segment, aufsummierte Länge, umschlossene +// Fläche, Richtung). Fläche nur, sobald der Zug ≥ 3 Ecken hat. +function MeasurementSection({ m }: { m: MeasurementReadout }) { + return ( +
+
{t("objinfo.measure.title")}
+
+ {t("objinfo.measure.segment")} + {m.segment.toFixed(3)} m + {t("objinfo.measure.total")} + {m.total.toFixed(3)} m + {t("objinfo.measure.angle")} + {m.angle.toFixed(1)}° + {m.area != null && ( + <> + {t("objinfo.measure.area")} + {m.area.toFixed(2)} m² + + )} +
+
{t("objinfo.measure.hint")}
+
+ ); +} diff --git a/src/panels/host.ts b/src/panels/host.ts index e8403a8..b279db5 100644 --- a/src/panels/host.ts +++ b/src/panels/host.ts @@ -34,7 +34,7 @@ import type { WallReferenceLine, WallType, } from "../model/types"; -import type { SnapSettings, ToolId } from "../tools/types"; +import type { MeasurementReadout, SnapSettings, ToolId } from "../tools/types"; import type { Selection } from "../state/selectionInfo"; import type { ScheduleKind } from "../export/exportSchedule"; @@ -143,6 +143,12 @@ export interface PanelHostValue { * und grauen nicht-anwendbare Felder per `selection.kind` aus. */ selection: Selection | null; + /** + * Live-Messwerte des Mess-Werkzeugs (Länge/Fläche/Winkel), oder `null`, wenn + * gerade nicht gemessen wird. Das Objekt-Info-Panel zeigt sie dauerhaft an, + * damit der Messwert nicht nur flüchtig am Cursor (HUD) erscheint. + */ + measurement: MeasurementReadout | null; /** Setzt die (Strich-)Farbe der aktuellen Selektion (Wand oder Drawing2D). */ onSetSelectionColor: (color: string) => void; /** diff --git a/src/styles.css b/src/styles.css index 49946ee..103080b 100644 --- a/src/styles.css +++ b/src/styles.css @@ -4044,6 +4044,32 @@ body { letter-spacing: 0.04em; margin: 2px 2px 4px; } +/* Mess-Werkzeug: Live-Messwerte im Objekt-Info-Panel. */ +.objinfo-measure { + margin: 2px 2px 8px; +} +.objinfo-measure-grid { + display: grid; + grid-template-columns: auto 1fr; + gap: 2px 10px; + align-items: baseline; + margin: 2px 0 4px; +} +.objinfo-measure-key { + color: var(--label); + font-size: 11px; +} +.objinfo-measure-val { + text-align: right; + font-variant-numeric: tabular-nums; + font-size: 12px; +} +.objinfo-measure-hint { + color: var(--label); + font-size: 10px; + opacity: 0.75; +} + /* Bezugspunkt-Zeile: Würfel (fix quadratisch) links, X/Y/Z-Spalte rechts. */ .objinfo-refrow { display: flex; diff --git a/src/tools/types.ts b/src/tools/types.ts index 7d1b45e..df18935 100644 --- a/src/tools/types.ts +++ b/src/tools/types.ts @@ -119,6 +119,27 @@ export type DraftShape = | { kind: "line"; a: Vec2; b: Vec2 } | { kind: "poly"; pts: Vec2[]; closed?: boolean }; +/** + * Live-Messwerte eines laufenden Mess-Vorgangs (Werkzeug „Messen"). Wird über + * den {@link ToolDraft} bis ins Objekt-Info-Panel durchgereicht, damit der + * Messwert nicht nur am Cursor (HUD), sondern dauerhaft ablesbar ist. + */ +export interface MeasurementReadout { + /** Länge des letzten Segments (Meter). */ + segment: number; + /** Aufsummierte Länge des ganzen Mess-Pfades (Meter). */ + total: number; + /** + * Umschlossene Fläche des Pfades (m²), sobald ≥ 3 Punkte den Polygonzug + * schliessen; sonst null (reine Streckenmessung). + */ + area: number | null; + /** Richtung des letzten Segments (Grad, 0…360, 0 = +X). */ + angle: number; + /** Anzahl bereits gesetzter Stützpunkte. */ + points: number; +} + /** Darstellbare Vorschau (Rubber-Band). */ export interface ToolDraft { /** Vorschau-Formen (gestrichelt/halbtransparent gezeichnet). */ @@ -129,6 +150,8 @@ export interface ToolDraft { snap?: SnapResult | null; /** Optionaler Maß-/Winkel-Text am Cursor (z. B. „3.20 m · 90°"). */ hud?: { at: Vec2; text: string }; + /** Live-Messwerte (nur beim Mess-Werkzeug gesetzt) fürs Objekt-Info-Panel. */ + measure?: MeasurementReadout; } /** Was ein Werkzeug-Schritt nach außen meldet. */