Mess-Werkzeug: Polygonzug mit Fläche + Live-Werte im Objekt-Info-Panel
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.
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -907,6 +907,13 @@ export const en: Record<TranslationKey, string> = {
|
||||
"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:",
|
||||
|
||||
@@ -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 (
|
||||
<div className="objinfo-panel">
|
||||
<div className="objinfo-empty">{t("objinfo.empty")}</div>
|
||||
{m ? <MeasurementSection m={m} /> : <div className="objinfo-empty">{t("objinfo.empty")}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -100,6 +106,7 @@ export function ObjectInfoPanel() {
|
||||
|
||||
return (
|
||||
<div className="objinfo-panel">
|
||||
{m && <MeasurementSection m={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 }) {
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div className="objinfo-measure">
|
||||
<div className="objinfo-section-label">{t("objinfo.measure.title")}</div>
|
||||
<div className="objinfo-measure-grid">
|
||||
<span className="objinfo-measure-key">{t("objinfo.measure.segment")}</span>
|
||||
<span className="objinfo-measure-val">{m.segment.toFixed(3)} m</span>
|
||||
<span className="objinfo-measure-key">{t("objinfo.measure.total")}</span>
|
||||
<span className="objinfo-measure-val">{m.total.toFixed(3)} m</span>
|
||||
<span className="objinfo-measure-key">{t("objinfo.measure.angle")}</span>
|
||||
<span className="objinfo-measure-val">{m.angle.toFixed(1)}°</span>
|
||||
{m.area != null && (
|
||||
<>
|
||||
<span className="objinfo-measure-key">{t("objinfo.measure.area")}</span>
|
||||
<span className="objinfo-measure-val">{m.area.toFixed(2)} m²</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="objinfo-measure-hint">{t("objinfo.measure.hint")}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+7
-1
@@ -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;
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user