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 →
* eine oder mehrere `shape:"dimension"`-Elemente (Reihenbemassung, SIA 400
* Figur 14). Die Masszahl wird NICHT gespeichert (aus dist(a,b) beim Rendern
* `dimensionCommand` — Punktreihe (wie Polylinie) + EIN Offset-Klick → EIN
* `shape:"dimension"`-Element für die ganze Kette (Reihenbemassung, SIA 400
* 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).
*/
import { describe, it, expect } from "vitest";
import { dimensionCommand } from "./dimension";
import type { CommandContext, Project } from "../types";
import type { Drawing2D } from "../../model/types";
function project(): Project {
function project(extra: Drawing2D[] = []): Project {
return {
id: "t",
name: "T",
@@ -27,12 +30,12 @@ function project(): Project {
ceilings: [],
stairs: [],
rooms: [],
drawings2d: [],
drawings2d: extra,
context: [],
};
}
function makeCtx(p: Project): CommandContext {
function makeCtx(p: Project, selectedDrawingId: string | null = null): CommandContext {
return {
project: p,
level: p.drawingLevels[0],
@@ -40,11 +43,11 @@ function makeCtx(p: Project): CommandContext {
activeLineStyleId: "solid",
activeWallTypeId: "aw",
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", () => {
const p = project();
const ctx = makeCtx(p);
@@ -63,8 +66,7 @@ describe("dimensionCommand", () => {
const g = next.drawings2d[0].geom;
expect(g.shape).toBe("dimension");
if (g.shape === "dimension") {
expect(g.a).toEqual({ x: 0, y: 0 });
expect(g.b).toEqual({ x: 10, y: 0 });
expect(g.pts).toEqual([{ x: 0, y: 0 }, { x: 10, y: 0 }]);
expect(g.offset).toBeCloseTo(2, 6);
}
});
@@ -85,7 +87,7 @@ describe("dimensionCommand", () => {
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 ctx = makeCtx(p);
let [state] = dimensionCommand.onInput(
@@ -98,10 +100,12 @@ describe("dimensionCommand", () => {
[state] = dimensionCommand.onConfirm(state, ctx);
const [, result] = dimensionCommand.onInput(state, { kind: "number", value: 2 }, ctx);
const next = result.commit!(p);
expect(next.drawings2d.length).toBe(2);
const segs = next.drawings2d.map((d) => d.geom).filter((g) => g.shape === "dimension");
expect(segs[0]).toMatchObject({ a: { x: 0, y: 0 }, b: { x: 4, y: 0 }, offset: 2 });
expect(segs[1]).toMatchObject({ a: { x: 4, y: 0 }, b: { x: 10, y: 0 }, offset: 2 });
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 }]);
expect(g.offset).toBe(2);
}
});
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);
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)
const g = next.drawings2d[0].geom;
if (g.shape === "dimension") {
expect(g.pts[2].y).toBeCloseTo(0, 6); // auf die Gerade y=0 projiziert
expect(g.pts[2].x).toBeCloseTo(10, 6); // x-Komponente bleibt (Projektion entlang x)
}
});
@@ -141,13 +144,11 @@ describe("dimensionCommand", () => {
[state] = dimensionCommand.onConfirm(state, ctx);
const [, result] = dimensionCommand.onInput(state, { kind: "number", value: 1 }, ctx);
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;
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 ctx = makeCtx(p);
let [state] = dimensionCommand.onInput(
@@ -175,3 +176,88 @@ describe("dimensionCommand", () => {
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
// weitere Klick wird auf diese Gerade projiziert (Reihenbemassung misst
// 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
// 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
// beim Zeichnen gewählten Offset).
// 3) „Lage der Masslinie:" → Punkt/Zahl → commit neues Element
//
// Ergänzen einer bestehenden Kette (Rhino-Muster: erst selektieren, dann
// 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)
// — 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 };
}
/**
* 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). */
function offsetPoints(a: Vec2, b: Vec2, offset: number): { a: Vec2; b: Vec2 } {
const dx = b.x - a.x, dy = b.y - a.y;
@@ -67,11 +104,14 @@ interface DimNext extends CommandState {
phase: "next";
pts: Vec2[];
cursor: Vec2 | null;
/** ID der bearbeiteten Kette (Ergänzen-Modus) — null = neue Kette. */
editId: string | null;
}
interface DimOffset extends CommandState {
phase: "offset";
pts: Vec2[];
cursor: Vec2 | null;
editId: string | null;
}
type DimState = DimStart | DimNext | DimOffset;
@@ -110,21 +150,26 @@ function offsetDraft(pts: Vec2[], offset: number, at: Vec2 | null): ToolDraft {
return draft;
}
function appendDimensionChain(p: Project, pts: Vec2[], offset: number, ctx: CommandContext): Project {
const added: Drawing2D[] = [];
for (let i = 0; i < pts.length - 1; i++) {
const a = pts[i], b = pts[i + 1];
if (dist(a, b) < EPS) continue;
added.push({
/** Committet eine Kette: neu anhängen (editId null) ODER dasselbe Element ersetzen. */
function commitChain(p: Project, pts: Vec2[], offset: number, editId: string | null, ctx: CommandContext): Project {
const cleaned = pts.filter((pt, i) => i === 0 || dist(pts[i - 1], pt) >= EPS);
if (cleaned.length < 2) return p; // zu wenig gültige Punkte → nichts committen/ändern
if (editId) {
return {
...p,
drawings2d: p.drawings2d.map((d) =>
d.id === editId ? { ...d, geom: { shape: "dimension", pts: cleaned, offset } } : d,
),
};
}
const d: Drawing2D = {
id: uniqueId("dr2d"),
type: "drawing2d",
levelId: ctx.level.id,
categoryCode: ctx.defaultCategoryCode,
geom: { shape: "dimension", a, b, offset },
});
}
if (added.length === 0) return p;
return { ...p, drawings2d: [...p.drawings2d, ...added] };
geom: { shape: "dimension", pts: cleaned, offset },
};
return { ...p, drawings2d: [...p.drawings2d, d] };
}
const idle = (): [CommandState, CommandResult] => [
@@ -154,11 +199,11 @@ export const dimensionCommand: Command = {
if (input.id === "undo") {
const pts = s.pts.slice(0, -1);
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) }];
}
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 [s, { draft: chainDraft(s.pts, s.cursor) }];
@@ -168,14 +213,19 @@ export const dimensionCommand: Command = {
if (input.kind !== "point") return [s, { draft: null }];
const pt = input.point;
if (s.phase !== "next") {
const ns: DimNext = { phase: "next", pts: [pt], cursor: pt, lastPoint: pt };
return [ns, { draft: chainDraft([pt], pt) }];
// Erster Klick: bei selektierter Kette wird er EINSORTIERT (Ergänzen);
// 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.
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) }];
// Ab dem dritten Punkt (bzw. jedem weiteren Klick im Ergänzen-Modus):
// auf die durch Punkt 1/2 festgelegte Gerade projizieren/einsortieren.
const pts = s.pts.length >= 2 ? insertIntoChain(s.pts, pt) : [...s.pts, pt];
const ns: DimNext = { phase: "next", pts, cursor: pt, editId: s.editId, lastPoint: pt };
return [ns, { draft: chainDraft(pts, pt) }];
}
// 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 return [s, { draft: offsetDraft(s.pts, 0, null) }];
const pts = s.pts;
const editId = s.editId;
return [
{ 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] => {
const s = state as DimState;
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 idle();
+1 -1
View File
@@ -113,7 +113,7 @@ export function translateProject(project: Project, dx: number, dy: number): Proj
case "spline":
return { ...d, geom: { ...g, pts: shiftAll(g.pts, dx, dy) } };
case "dimension":
return { ...d, geom: { ...g, a: shift(g.a, dx, dy), b: shift(g.b, dx, dy) } };
return { ...d, geom: { ...g, pts: shiftAll(g.pts, dx, dy) } };
case "kote":
return { ...d, geom: { ...g, at: shift(g.at, dx, dy) } };
case "text":
+9 -1
View File
@@ -1533,7 +1533,15 @@ export type Drawing2DGeom =
* beim Verschieben der Griffe automatisch korrekt. Rein 2D (kein Bezug auf
* andere Elemente — Assoziativität nur über die eigenen Endpunkte).
*/
| { shape: "dimension"; a: Vec2; b: Vec2; offset: number }
/**
* `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,
* ±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") {
g.pts.forEach(acc);
} else if (g.shape === "dimension") {
acc(g.a);
acc(g.b);
g.pts.forEach(acc);
} 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); }
+3 -3
View File
@@ -129,7 +129,7 @@ describe("addDrawing2D — dimension (SIA 400 B.5.3)", () => {
type: "drawing2d",
levelId: "eg",
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[] = [];
addDrawing2D(out, project(), d, false, lwByCode, colorByCode, catByCode);
@@ -152,7 +152,7 @@ describe("addDrawing2D — dimension (SIA 400 B.5.3)", () => {
type: "drawing2d",
levelId: "eg",
categoryCode: "20",
geom: { shape: "dimension", a: { x: 0, y: 0 }, b: { x: 3, y: 4 }, offset: 0.5 }, // 3-4-5-Dreieck → 5m
geom: { shape: "dimension", pts: [{ x: 0, y: 0 }, { x: 3, y: 4 }], offset: 0.5 }, // 3-4-5-Dreieck → 5m
};
const out: Primitive[] = [];
addDrawing2D(out, project(), d, false, lwByCode, colorByCode, catByCode);
@@ -167,7 +167,7 @@ describe("addDrawing2D — dimension (SIA 400 B.5.3)", () => {
type: "drawing2d",
levelId: "eg",
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[] = [];
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);
}
/**
* 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) ────────────────────────────────────────────────
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;
@@ -185,62 +256,11 @@ export function addDrawing2D(
break;
}
case "dimension": {
// SIA 400 B.5.3: Masshilfslinien + Masslinie in der dünnsten Linie;
// Massbegrenzungslinien (Massstriche) doppelt so dick; Masszahl aus
// dist(a,b) — nicht gespeichert, folgt Griff-Änderungen automatisch.
const dx = g.b.x - g.a.x, dy = g.b.y - g.a.y;
const lineLen = Math.hypot(dx, dy);
if (lineLen < 1e-6) break;
const ux = dx / lineLen, uy = dy / lineLen; // Einheitsrichtung a→b
const nx = -uy, ny = ux; // Links-Normale (Einheit) — Richtung des Offsets
const off = g.offset;
const sign = off >= 0 ? 1 : -1;
const dimA: Vec2 = { x: g.a.x + nx * off, y: g.a.y + ny * off };
const dimB: Vec2 = { x: g.b.x + nx * off, y: g.b.y + ny * off };
const extLine = (from: Vec2, to: Vec2) =>
out.push({ kind: "line", a: from, b: to, cls: "draw2d", weightMm, dash, color, greyed, drawingId: d.id });
extLine(
{ x: g.a.x + nx * DIM_GAP_M * sign, y: g.a.y + ny * DIM_GAP_M * sign },
{ x: dimA.x + nx * DIM_OVERSHOOT_M * sign, y: dimA.y + ny * DIM_OVERSHOOT_M * sign },
);
extLine(
{ x: g.b.x + nx * DIM_GAP_M * sign, y: g.b.y + ny * DIM_GAP_M * sign },
{ x: dimB.x + nx * DIM_OVERSHOOT_M * sign, y: dimB.y + ny * DIM_OVERSHOOT_M * sign },
);
extLine(dimA, dimB);
// Massstriche: 45°-Diagonale (Winkelhalbierende von Richtung/Normale),
// an beiden Enden PARALLEL (nicht gespiegelt) — wie in SIA 400 Figur 13.
const tx = (ux + nx) / Math.SQRT2, ty = (uy + ny) / Math.SQRT2;
const half = DIM_TICK_LEN_M / 2;
const tick = (pt: Vec2) =>
out.push({
kind: "line",
a: { x: pt.x - tx * half, y: pt.y - ty * half },
b: { x: pt.x + tx * half, y: pt.y + ty * half },
cls: "draw2d",
weightMm: weightMm * 2,
dash: null,
color,
greyed,
drawingId: d.id,
});
tick(dimA);
tick(dimB);
// Masszahl: Mitte der Masslinie, leicht abgesetzt, nie kopfüber lesbar.
let angle = Math.atan2(dy, dx);
if (Math.cos(angle) < 0) angle += Math.PI;
const upx = -Math.sin(angle), upy = Math.cos(angle); // Links-Normale der ENDGÜLTIGEN Richtung
const mid: Vec2 = { x: (dimA.x + dimB.x) / 2, y: (dimA.y + dimB.y) / 2 };
out.push({
kind: "drawingText",
at: { x: mid.x + upx * DIM_TEXT_HEIGHT_M * 0.6, y: mid.y + upy * DIM_TEXT_HEIGHT_M * 0.6 },
text: formatDimLabel(lineLen),
heightM: DIM_TEXT_HEIGHT_M,
angle,
color,
greyed,
drawingId: d.id,
});
// SIA 400 B.5.3, Figur 14 (Reihenbemassung): jedes aufeinanderfolgende
// Punktpaar der Kette ist ein eigenes Mass-Segment, alle teilen `offset`.
for (let i = 0; i < g.pts.length - 1; i++) {
addDimensionSegment(out, g.pts[i], g.pts[i + 1], g.offset, weightMm, dash, color, greyed, d.id);
}
break;
}
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).
if (g.shape === "bezier" || g.shape === "spline") return [...g.pts];
// Bemassung: Griff 0/1 = die beiden gemessenen Punkte (a/b); Griff 2 sitzt
// AUF der Masslinie (Mitte) — ihn zu ziehen ändert nur `offset` (s. moveGrip).
// Bemassung: Griff 0..N-1 = jeder Punkt der Kette (wie polyline); EIN
// 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") {
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 nx = (-dy / len) * g.offset, ny = (dx / len) * g.offset;
const mid: Vec2 = { x: (g.a.x + g.b.x) / 2 + nx, y: (g.a.y + g.b.y) / 2 + ny };
return [g.a, g.b, mid];
const mid: Vec2 = { x: (a.x + b.x) / 2 + nx, y: (a.y + b.y) / 2 + ny };
return [...g.pts, mid];
}
// Kote: EIN Griff an der Dreieck-Spitze (zum Verschieben; siehe moveGrip).
if (g.shape === "kote") return [g.at];
@@ -1592,16 +1594,20 @@ function moveGrip(
const pts = g.pts.map((p, i) => (i === index ? pt : p));
return { ...d, geom: { ...g, pts } };
}
// Bemassung: Griff 0/1 verschiebt den gemessenen Punkt a/b (Masszahl
// folgt automatisch, s. Rendering); Griff 2 (auf der Masslinie) ändert
// nur `offset` — Normalabstand von `pt` zur (unveränderten) Strecke a-b.
// Bemassung: Griff 0..N-1 verschiebt den jeweiligen Kettenpunkt
// (Masszahlen folgen automatisch, s. Rendering); der LETZTE Griff
// (auf der Masslinie) ändert nur `offset` — Normalabstand von `pt`
// zur (unveränderten) Punktreihe.
if (g.shape === "dimension") {
if (index === 0) return { ...d, geom: { ...g, a: pt } };
if (index === 1) return { ...d, geom: { ...g, b: pt } };
const dx = g.b.x - g.a.x, dy = g.b.y - g.a.y;
if (index < g.pts.length) {
const pts = g.pts.map((p, i) => (i === index ? pt : p));
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 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 } };
}
if (g.shape === "rect" || g.shape === "image") {
@@ -1698,9 +1704,8 @@ function moveElementBy(
return { ...d, geom: { ...g, center: mv(g.center) } };
case "bezier":
case "spline":
return { ...d, geom: { ...g, pts: g.pts.map(mv) } };
case "dimension":
return { ...d, geom: { ...g, a: mv(g.a), b: mv(g.b) } };
return { ...d, geom: { ...g, pts: g.pts.map(mv) } };
case "kote":
return { ...d, geom: { ...g, at: mv(g.at) } };
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);
break;
case "dimension": {
acc(g.a.x, g.a.y);
acc(g.b.x, g.b.y);
const dx = g.b.x - g.a.x, dy = g.b.y - g.a.y;
for (const p of g.pts) acc(p.x, p.y);
if (g.pts.length >= 2) {
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 nx = (-dy / len) * g.offset, ny = (dx / len) * g.offset;
acc(g.a.x + nx, g.a.y + ny);
acc(g.b.x + nx, g.b.y + ny);
for (const p of g.pts) acc(p.x + nx, p.y + ny);
}
break;
}
case "kote":
+2 -3
View File
@@ -55,8 +55,7 @@ function drawingsBBox(
} else if (g.shape === "bezier" || g.shape === "spline") {
for (const p of g.pts) acc(p.x, p.y);
} else if (g.shape === "dimension") {
acc(g.a.x, g.a.y);
acc(g.b.x, g.b.y);
for (const p of g.pts) acc(p.x, p.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);
}
@@ -76,7 +75,7 @@ function translateDrawing(d: Drawing2D, dx: number, dy: number): Drawing2D {
case "ellipse": return { ...d, geom: { ...g, center: mv(g.center) } };
case "bezier": return { ...d, geom: { ...g, pts: g.pts.map(mv) } };
case "spline": return { ...d, geom: { ...g, pts: g.pts.map(mv) } };
case "dimension": return { ...d, geom: { ...g, a: mv(g.a), b: mv(g.b) } };
case "dimension": return { ...d, geom: { ...g, pts: g.pts.map(mv) } };
case "kote": return { ...d, geom: { ...g, at: mv(g.at) } };
case "text": return { ...d, geom: { ...g, at: mv(g.at) } };
default: return d;
+1 -1
View File
@@ -186,7 +186,7 @@ const mvGeom = (geom: Drawing2DGeom, fn: (p: Vec2) => Vec2): Drawing2DGeom => {
case "spline":
return { ...geom, pts: geom.pts.map(fn) };
case "dimension":
return { ...geom, a: fn(geom.a), b: fn(geom.b) };
return { ...geom, pts: geom.pts.map(fn) };
case "kote":
return { ...geom, at: fn(geom.at) };
case "text":
+1 -2
View File
@@ -24,8 +24,7 @@ export function drawingGripVertices(d: Drawing2D): Vec2[] {
{ x: g.min.x, y: g.max.y },
];
}
if (g.shape === "bezier" || g.shape === "spline") return [...g.pts];
if (g.shape === "dimension") return [g.a, g.b];
if (g.shape === "bezier" || g.shape === "spline" || g.shape === "dimension") return [...g.pts];
return [];
}