2D: Masskette ist jetzt EIN Element (verlängerbar + Punkte einfügbar)

Bemassungs-Modell umgebaut: eine Reihenbemassung war bisher N unabhängige
Einzel-Elemente, jetzt EIN Element mit einer Punktreihe (pts statt a/b).
Alle Segmente teilen weiterhin einen Offset, aber Verschieben/Selektieren/
Löschen betrifft die ganze Kette auf einmal.

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