2D-Plan-Renderer auf WebGL2 (GPU) + akkumulierter Funktionsstand

Neuer GPU-Renderer fuer den Grundriss (src/plan/glPlan/): Earcut-Tessellierung
(konkav-faehig), gehrte Linienzuege (Miter), echte Papier-mm-Strichbreiten im
Massstab (repliziert den SVG-printStrokeVb-Pfad), Hybrid mit scharfem SVG-Text-
Overlay. GPU ist der Standardpfad; der SVG-Renderer bleibt automatischer Fallback,
falls WebGL2/Shader nicht verfuegbar sind. Imperativer Pan (rAF + CSS-transform)
fuer fluessige Interaktion ohne React-Re-Render je Frame.

Enthaelt zudem den bisher nicht committeten Arbeitsstand des Browser-BIM
(Oeffnungen, Treppen, Raeume, Decken, DXF-Export, Materialbibliothek, Kontext-
Import, Tauri-Compute-Boundary-PoC).
This commit is contained in:
2026-07-02 00:12:39 +02:00
parent 7b3b597abc
commit 8fd8987b70
184 changed files with 29421 additions and 669 deletions
+1461 -109
View File
File diff suppressed because it is too large Load Diff
+250
View File
@@ -0,0 +1,250 @@
// Ceiling — Decke als echter Engine-Befehl (analog polyline: ein GESCHLOSSENER
// Umriss), committet aber ein einzelnes `Ceiling`-Bauteil statt einer 2D-Form.
// Schritte:
// 1) „Erster Umrisspunkt der Decke:" → Punkt
// 2) „Nächster Punkt ( Schliessen Zurück ):" → Punkt … Der Umriss schließt per
// Klick auf den Startpunkt, Option „Schliessen" (C) oder Enter (≥3 Punkte).
//
// Akzeptiert je Punkt Maus-Picks UND getippte Koordinaten (0,0 · r3,0 · 5<45) —
// derselbe Eingabepfad wie Polylinie/Wand. Die fertige Decke rendert über die
// bestehende Decken-Darstellung (generatePlan / 3D); der Draft zeigt den Umriss.
//
// Herkunft der Decken-Felder (CommandContext):
// • wallTypeId ← ctx.activeWallTypeId (Fallback: erster Projekt-Wandtyp) — der
// Schichtaufbau (Bauteil/Schraffur/Material) wird identisch zur Wand aufgelöst.
// • floorId ← ctx.level.id (aktives Geschoss)
// • categoryCode← "30" (Ebene „Decken"; Fallback ctx.defaultCategoryCode)
import type { Ceiling } from "../../model/types";
import { normalizeOutline, ceilingArea } from "../../geometry/ceiling";
import { uniqueId } from "../../tools/types";
import type {
Command,
CommandContext,
CommandField,
CommandResult,
CommandState,
CmdOption,
DraftShape,
Project,
ToolDraft,
Vec2,
} from "../types";
const EPS = 1e-6;
const DEG = Math.PI / 180;
const CLOSE_HIT = 0.08; // Modell-Meter: Klick nahe Startpunkt schließt den Umriss
/** Default-Ebene (Kategorie) für Decken. */
const CEILING_CATEGORY = "30";
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;
/** Tab-Felder je weiteren Punkt: Länge + Winkel relativ zum letzten Punkt. */
const CEIL_FIELDS: CommandField[] = [
{ id: "length", labelKey: "cmd.field.length" },
{ id: "angle", labelKey: "cmd.field.angle" },
];
/** Nächster Punkt aus gelockten Feldern (length/angle) + Cursor, relativ zu `last`. */
function nextFromFields(
last: Vec2,
locks: Record<string, number>,
cursor: Vec2 | null,
): Vec2 {
const ref = cursor ?? last;
const length = "length" in locks ? locks.length : segLen(last, ref);
const angleDeg = "angle" in locks ? locks.angle : segAngleDeg(last, ref);
const ang = angleDeg * DEG;
return { x: last.x + Math.cos(ang) * length, y: last.y + Math.sin(ang) * length };
}
/** Aktiver Aufbau-Typ-ID; Fallback auf den ersten Projekt-Wandtyp. */
function activeTypeId(ctx: CommandContext): string {
const wt =
ctx.project.wallTypes.find((t) => t.id === ctx.activeWallTypeId) ??
ctx.project.wallTypes[0];
return wt ? wt.id : ctx.activeWallTypeId;
}
/** Existiert die Decken-Kategorie („30")? Sonst Fallback auf die aktive Kategorie. */
function ceilingCategory(ctx: CommandContext): string {
const flat: { code: string }[] = [];
const walk = (list: { code: string; children?: unknown[] }[]) => {
for (const c of list) {
flat.push({ code: c.code });
if (c.children) walk(c.children as { code: string; children?: unknown[] }[]);
}
};
walk(ctx.project.layers as { code: string; children?: unknown[] }[]);
return flat.some((c) => c.code === CEILING_CATEGORY)
? CEILING_CATEGORY
: ctx.defaultCategoryCode;
}
interface CeilIdle extends CommandState {
phase: "start";
}
interface CeilDrawing extends CommandState {
phase: "next";
points: Vec2[];
cursor: Vec2 | null;
}
type CeilState = CeilIdle | CeilDrawing;
const CLOSE: CmdOption = { id: "close", labelKey: "cmd.polyline.close" };
const UNDO: CmdOption = { id: "undo", labelKey: "cmd.polyline.undo" };
/** Vorschau: geschlossener Umriss-Ring (ab 3 Punkten) + Gummiband + HUD. */
function ceilingDraft(points: Vec2[], cursor: Vec2 | null): ToolDraft {
const pts = cursor ? [...points, cursor] : points;
const showClosed = pts.length >= 3;
const preview: DraftShape[] = [{ kind: "poly", pts, closed: showClosed }];
const draft: ToolDraft = { preview, vertices: points };
const last = points[points.length - 1];
if (cursor && last && segLen(last, cursor) >= EPS) {
draft.hud = {
at: cursor,
text: `${segLen(last, cursor).toFixed(2)} m · ${Math.abs(
(segAngleDeg(last, cursor) + 360) % 360,
).toFixed(0)}°`,
};
}
return draft;
}
/** Hängt eine Decke ans Projekt (immutabel). Entartete Umrisse werden verworfen. */
function appendCeiling(p: Project, pts: Vec2[], ctx: CommandContext): Project {
const outline = normalizeOutline(pts);
if (!outline || ceilingArea(outline) < 1e-4) return p;
const ceiling: Ceiling = {
id: uniqueId("C"),
type: "ceiling",
floorId: ctx.level.id,
categoryCode: ceilingCategory(ctx),
outline,
wallTypeId: activeTypeId(ctx),
};
const ceilings = p.ceilings ? [...p.ceilings, ceiling] : [ceiling];
return { ...p, ceilings };
}
const idle = (): [CommandState, CommandResult] => [
{ phase: "start", lastPoint: null } as CeilIdle,
{ draft: null, done: true },
];
export const ceilingCommand: Command = {
name: "ceiling",
labelKey: "cmd.ceiling.label",
// Decken leben auf Geschossen — wie die Wand nur dort aktiv.
floorOnly: true,
prompt: (s) =>
(s as CeilState).phase === "next" ? "cmd.ceiling.next" : "cmd.ceiling.start",
accepts: (s) =>
(s as CeilState).phase === "next"
? ["point", "number", "option"]
: ["point", "number"],
options: (s) => {
const ps = s as CeilState;
if (ps.phase !== "next") return [];
return ps.points.length >= 3 ? [CLOSE, UNDO] : [UNDO];
},
init: (): CeilIdle => ({ phase: "start", lastPoint: null }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as CeilState;
if (input.kind === "option") {
if (s.phase !== "next") return [s, { draft: null }];
if (input.id === "undo") {
const pts = s.points.slice(0, -1);
if (pts.length === 0)
return [{ phase: "start", lastPoint: null } as CeilIdle, { draft: null }];
const ns: CeilDrawing = {
phase: "next",
points: pts,
cursor: s.cursor,
lastPoint: pts[pts.length - 1],
};
return [ns, { draft: ceilingDraft(pts, s.cursor) }];
}
// Sofort schließen: committet die Decke (unabhängig, ≥3 Punkte).
if (input.id === "close" && s.points.length >= 3) {
const pts = s.points;
return [
{ phase: "start", lastPoint: null } as CeilIdle,
{ draft: null, done: true, commit: (p) => appendCeiling(p, pts, ctx) },
];
}
return [s, { draft: ceilingDraft(s.points, s.cursor) }];
}
if (input.kind !== "point") {
return [
s,
{ draft: s.phase === "next" ? ceilingDraft(s.points, s.cursor) : null },
];
}
const pt = input.point;
if (s.phase !== "next") {
const ns: CeilDrawing = { phase: "next", points: [pt], cursor: pt, lastPoint: pt };
return [ns, { draft: ceilingDraft([pt], pt) }];
}
// Klick nahe Startpunkt → schließen (committet die Decke).
if (s.points.length >= 3 && segLen(s.points[0], pt) < CLOSE_HIT) {
const pts = s.points;
return [
{ phase: "start", lastPoint: null } as CeilIdle,
{ draft: null, done: true, commit: (p) => appendCeiling(p, pts, ctx) },
];
}
// Null-Strecke (Klick auf denselben Punkt) ignorieren.
const last = s.points[s.points.length - 1];
if (segLen(last, pt) < EPS) {
return [s, { draft: ceilingDraft(s.points, s.cursor) }];
}
const points = [...s.points, pt];
const ns: CeilDrawing = { phase: "next", points, cursor: pt, lastPoint: pt };
return [ns, { draft: ceilingDraft(points, pt) }];
},
onMove: (state, point): [CommandState, CommandResult] => {
const s = state as CeilState;
if (s.phase !== "next") return [s, { draft: null }];
const ns: CeilDrawing = { ...s, cursor: point };
return [ns, { draft: ceilingDraft(s.points, point) }];
},
// Enter/Space/Rechtsklick: Umriss schließen + committen (≥3 Punkte).
onConfirm: (state, ctx): [CommandState, CommandResult] => {
const s = state as CeilState;
if (s.phase === "next" && s.points.length >= 3) {
const pts = s.points;
return [
{ phase: "start", lastPoint: null } as CeilIdle,
{ draft: null, done: true, commit: (p) => appendCeiling(p, pts, ctx) },
];
}
return idle();
},
onCancel: (): [CommandState, CommandResult] => idle(),
// Tab-Feld-Zyklus nur im „next"-Schritt: Länge + Winkel relativ zum letzten Punkt.
fields: (state) => ((state as CeilState).phase === "next" ? CEIL_FIELDS : []),
pointFromFields: (state, locks, cursor) => {
const s = state as CeilState;
if (s.phase !== "next" || s.points.length === 0) return null;
return nextFromFields(s.points[s.points.length - 1], locks, cursor);
},
fieldValues: (state, _locks, cursor): Record<string, number> => {
const s = state as CeilState;
if (s.phase !== "next" || s.points.length === 0 || !cursor) return {};
const last = s.points[s.points.length - 1];
return {
length: segLen(last, cursor),
angle: ((segAngleDeg(last, cursor) % 360) + 360) % 360,
};
},
};
+5
View File
@@ -133,6 +133,11 @@ export const circleCommand: Command = {
// Tab-Feld-Zyklus nur im „radius"-Schritt: ein Radius-Feld; der gelockte Wert
// committet direkt (Punkt liegt auf dem Kreis, dist(center,point)=radius).
fields: (state) => ((state as CircleState).phase === "radius" ? CIRCLE_FIELDS : []),
fieldValues: (state, _locks, cursor): Record<string, number> => {
const s = state as CircleState;
if (s.phase !== "radius" || !cursor) return {};
return { radius: dist(s.center, cursor) };
},
pointFromFields: (state, locks, cursor) => {
const s = state as CircleState;
if (s.phase !== "radius") return null;
+64
View File
@@ -0,0 +1,64 @@
// Join — verbindet die gewählten 2D-Zeichenelemente an koinzidenten Endpunkten zu
// Polylinien (geschlossen, wenn sich eine Kette schließt). Sofort-Befehl (kein
// Punkt-Picken): läuft direkt auf der bestehenden Mehrfachauswahl. So ist „Ctrl+J"
// und getipptes „join" EIN Pfad (§Task C).
//
// Wände werden nicht verbunden (2D-Editier-Operation); nicht-polyline Formen
// (circle/arc/text) reicht `joinDrawings` unverändert durch.
import { joinDrawings } from "../../editors/splitJoin";
import type { Drawing2D } from "../../model/types";
import type {
Command,
CommandContext,
CommandResult,
CommandSelection,
CommandState,
Project,
} from "../types";
/** Alle gewählten 2D-Element-IDs (Mehrfachauswahl mit Fallback aufs Einzelelement). */
function selectedDrawingIds(sel: CommandSelection): string[] {
if (sel.drawingIds && sel.drawingIds.length) return sel.drawingIds;
return sel.drawingId ? [sel.drawingId] : [];
}
/** Ersetzt die gewählten Quell-Drawings durch das Join-Ergebnis (immutabel). */
function applyJoin(project: Project, ids: string[]): Project {
const idSet = new Set(ids);
const srcs = project.drawings2d.filter((d) => idSet.has(d.id));
if (srcs.length < 2) return project; // <2 → nichts zu verbinden
const joined: Drawing2D[] = joinDrawings(srcs);
const rest = project.drawings2d.filter((d) => !idSet.has(d.id));
return { ...project, drawings2d: [...rest, ...joined] };
}
interface JoinState extends CommandState {
phase: "done";
}
const done = (): [CommandState, CommandResult] => [
{ phase: "done", lastPoint: null } as JoinState,
{ draft: null, done: true },
];
export const joinCommand: Command = {
name: "join",
labelKey: "edit.join.label",
prompt: () => "cmd.join.prompt",
accepts: () => [],
options: () => [],
init: (): JoinState => ({ phase: "done", lastPoint: null }),
// Sofort auf der Auswahl ausführen (kein Pick nötig).
autoRun: (ctx: CommandContext): CommandResult => {
const ids = selectedDrawingIds(ctx.selection);
if (ids.length < 2) return { draft: null, done: true };
return { draft: null, done: true, commit: (p) => applyJoin(p, ids) };
},
onInput: (): [CommandState, CommandResult] => done(),
onMove: (state): [CommandState, CommandResult] => [state, { draft: null }],
onConfirm: (): [CommandState, CommandResult] => done(),
onCancel: (): [CommandState, CommandResult] => done(),
};
+8
View File
@@ -137,4 +137,12 @@ export const lineCommand: Command = {
if (s.phase !== "end") return null;
return lineEndFromFields(s.a, locks, cursor);
},
fieldValues: (state, _locks, cursor): Record<string, number> => {
const s = state as LineState;
if (s.phase !== "end" || !cursor) return {};
return {
length: segLen(s.a, cursor),
angle: ((segAngleDeg(s.a, cursor) % 360) + 360) % 360,
};
},
};
+160
View File
@@ -0,0 +1,160 @@
// Mirror — spiegelt die AKTUELLE Auswahl (Wände + ein Drawing2D) an einer frei
// gewählten Achse (Rhino/Vectorworks-Spiegeln). Schritte:
// 1) „Erster Achsenpunkt:" → Punkt
// 2) „Zweiter Achsenpunkt:" → Punkt (Live-Vorschau der Spiegelung) → commit
//
// Die Auswahl kommt vorab über ctx.selection (erst selektieren, dann Befehl,
// wie Move/Copy). Ist sie leer, ist nichts zu spiegeln: kurzer No-op mit Hinweis
// („Nichts gewählt") und Beenden.
//
// Der Commit nutzt den bestehenden, getesteten `commitTransform` (op:"mirror",
// mode:"copy") — er spiegelt Wände UND alle 2D-Formen immutabel an der Achse und
// hängt das Spiegelbild als KOPIE an (das Original bleibt stehen). Das ist das
// klassische Mirror „Kopie spiegeln" (Rhino/Vectorworks-Default).
import { commitTransform } from "../../tools/transform";
import type { TransformSelection } from "../../tools/transform";
import { transformPreview } from "../../tools/transform";
import type {
Command,
CommandResult,
CommandSelection,
CommandState,
Project,
ToolDraft,
Vec2,
} from "../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;
const EPS = 1e-6;
/** Hat die Auswahl überhaupt etwas Spiegelbares? */
function hasSelection(sel: CommandSelection): boolean {
return sel.wallIds.length > 0 || sel.drawingId !== null;
}
/** Auswahl → TransformSelection (gleiche Form). */
function toTransformSel(sel: CommandSelection): TransformSelection {
return { wallIds: sel.wallIds, drawingId: sel.drawingId };
}
// Zustand: erst ersten Achsenpunkt setzen, dann den zweiten (mit Auswahl-Snapshot).
interface MirrorFirst extends CommandState {
phase: "first";
}
interface MirrorSecond extends CommandState {
phase: "second";
sel: CommandSelection;
a: Vec2;
cursor: Vec2 | null;
}
// „done": leere Auswahl → No-op, Befehl beendet.
interface MirrorDone extends CommandState {
phase: "done";
}
type MirrorState = MirrorFirst | MirrorSecond | MirrorDone;
/** Live-Vorschau der an der Achse a→b gespiegelten Auswahl. */
function mirrorDraft(
project: Project,
sel: CommandSelection,
a: Vec2,
b: Vec2,
): ToolDraft {
// Ohne echte Achse (Null-Strecke) nur den ersten Punkt markieren.
if (segLen(a, b) < EPS) {
return { preview: [], vertices: [a] };
}
const preview = transformPreview(
project,
toTransformSel(sel),
"mirror",
[a, b],
"copy", // Spiegelbild als Kopie (Original bleibt stehen)
1,
);
return {
// Spiegelbild-Vorschau + die Achse selbst als Hilfslinie.
preview: [...preview, { kind: "line", a, b }],
// Beide Achsenpunkte als Stützpunkte (Marker).
vertices: [a, b],
hud: {
at: b,
text: `${((segAngleDeg(a, b) + 360) % 360).toFixed(0)}°`,
},
};
}
const idle = (): [CommandState, CommandResult] => [
{ phase: "done", lastPoint: null } as MirrorDone,
{ draft: null, done: true },
];
export const mirrorCommand: Command = {
name: "mirror",
labelKey: "cmd.mirror.label",
prompt: (s) => {
const ms = s as MirrorState;
if (ms.phase === "second") return "cmd.mirror.second";
if (ms.phase === "done") return "cmd.mirror.empty";
return "cmd.mirror.first";
},
accepts: () => ["point"],
options: () => [],
init: (): MirrorFirst => ({ phase: "first", lastPoint: null }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as MirrorState;
if (s.phase === "done") return idle();
// Leere Auswahl → nichts zu spiegeln (No-op + Hinweis, dann beenden).
if (!hasSelection(ctx.selection)) {
return [{ phase: "done", lastPoint: null } as MirrorDone, { draft: null, done: true }];
}
if (input.kind !== "point") {
if (s.phase === "second") {
return [s, { draft: s.cursor ? mirrorDraft(ctx.project, s.sel, s.a, s.cursor) : null }];
}
return [s, { draft: null }];
}
const pt = input.point;
if (s.phase !== "second") {
// Erster Achsenpunkt gesetzt → Auswahl-Snapshot mitführen.
const next: MirrorSecond = {
phase: "second",
sel: ctx.selection,
a: pt,
cursor: pt,
lastPoint: pt,
};
return [next, { draft: mirrorDraft(ctx.project, next.sel, pt, pt) }];
}
// Zweiter Achsenpunkt → commit (Auswahl an der Achse spiegeln). Null-Achse
// verwerfen (kein Spiegeln ohne Richtung).
if (segLen(s.a, pt) < EPS) {
return [{ phase: "done", lastPoint: null } as MirrorDone, { draft: null, done: true }];
}
const a = s.a;
const sel = s.sel;
return [
{ phase: "done", lastPoint: null } as MirrorDone,
{
draft: null,
done: true,
commit: (p) => commitTransform(p, toTransformSel(sel), "mirror", [a, pt], "copy", 1),
},
];
},
onMove: (state, point, _snap, ctx): [CommandState, CommandResult] => {
const s = state as MirrorState;
if (s.phase !== "second") return [s, { draft: null }];
const ns: MirrorSecond = { ...s, cursor: point };
return [ns, { draft: mirrorDraft(ctx.project, s.sel, s.a, point) }];
},
onConfirm: (): [CommandState, CommandResult] => idle(),
onCancel: (): [CommandState, CommandResult] => idle(),
};
+368
View File
@@ -0,0 +1,368 @@
// Opening — Öffnung (Fenster/Tür) als Engine-Befehl. Sie wird in eine BESTEHENDE
// Wand gehostet. Ablauf:
// 1) „Wand für die Öffnung wählen:" → Klick auf/nahe eine Wand (Host).
// 2) „Position entlang der Wand:" → Klick auf der Wandachse ODER getippter
// Abstand vom Wand-Startpunkt (Zahl). Die Position wird auf die Achse
// projiziert; der HUD zeigt den Abstand vom Wandanfang.
// 3) Commit: hängt eine `Opening` ans Projekt.
//
// Tab-Felder im Positionsschritt: Breite / Höhe / Brüstung (sillHeight) /
// Schwenkwinkel (nur Tür). Optionen: Fenster⇄Tür-Umschalter, Anschlag
// (Scharnier start/end), Aufschlagseite (links/rechts), Richtung (innen/außen).
//
// Bezeichner englisch, sichtbarer Text via t() (CONVENTIONS.md).
import type { Opening } from "../../model/types";
import { uniqueId } from "../../tools/types";
import type {
Command,
CommandContext,
CommandField,
CommandResult,
CommandState,
CmdOption,
DraftShape,
Project,
ToolDraft,
Vec2,
} from "../types";
/** Default-Ebene (Kategorie) für Öffnungen: „21" Türen/Fenster (Fallback: aktive). */
const OPENING_CATEGORY = "21";
/** Toleranz (Modell-Meter) für das Picken einer Host-Wand per Klick. */
const WALL_PICK_DIST = 0.6;
/** Default-Maße (Meter). */
const DEF_WINDOW = { width: 1.2, height: 1.2, sill: 0.9 };
const DEF_DOOR = { width: 0.9, height: 2.1, sill: 0 };
const sub = (a: Vec2, b: Vec2): Vec2 => ({ x: a.x - b.x, y: a.y - b.y });
const dot = (a: Vec2, b: Vec2): number => a.x * b.x + a.y * b.y;
const add = (a: Vec2, b: Vec2): Vec2 => ({ x: a.x + b.x, y: a.y + b.y });
const scale = (a: Vec2, s: number): Vec2 => ({ x: a.x * s, y: a.y * s });
/** Existiert die Öffnungs-Kategorie („21")? Sonst Fallback auf die aktive. */
function openingCategory(ctx: CommandContext): string {
const flat: { code: string }[] = [];
const walk = (list: { code: string; children?: unknown[] }[]) => {
for (const c of list) {
flat.push({ code: c.code });
if (c.children) walk(c.children as { code: string; children?: unknown[] }[]);
}
};
walk(ctx.project.layers as { code: string; children?: unknown[] }[]);
return flat.some((c) => c.code === OPENING_CATEGORY)
? OPENING_CATEGORY
: ctx.defaultCategoryCode;
}
/** Auf die Wandachse projizierter Fußpunkt + Parameter t (0..len) + Distanz. */
function projectOntoWall(
wall: { start: Vec2; end: Vec2 },
p: Vec2,
): { t: number; foot: Vec2; dist: number } {
const ax = sub(wall.end, wall.start);
const len2 = dot(ax, ax) || 1e-9;
const raw = dot(sub(p, wall.start), ax) / len2; // 0..1
const t = Math.max(0, Math.min(1, raw));
const foot = add(wall.start, scale(ax, t));
const dist = Math.hypot(p.x - foot.x, p.y - foot.y);
return { t: t * Math.sqrt(len2), foot, dist };
}
/** Nächste Wand auf dem aktiven Geschoss zum Klickpunkt (innerhalb Toleranz). */
function pickWall(
ctx: CommandContext,
p: Vec2,
): { id: string; start: Vec2; end: Vec2 } | null {
let best: { id: string; start: Vec2; end: Vec2; d: number } | null = null;
for (const w of ctx.project.walls) {
if (w.floorId !== ctx.level.id) continue;
const { dist } = projectOntoWall(w, p);
if (dist <= WALL_PICK_DIST && (!best || dist < best.d)) {
best = { id: w.id, start: w.start, end: w.end, d: dist };
}
}
return best ? { id: best.id, start: best.start, end: best.end } : null;
}
/** Tab-Felder im Positionsschritt: Breite/Höhe/Brüstung (+ Winkel bei Tür). */
const BASE_FIELDS: CommandField[] = [
{ id: "width", labelKey: "cmd.field.width" },
{ id: "height", labelKey: "cmd.field.height" },
{ id: "sill", labelKey: "cmd.opening.sillField" },
];
const SWING_FIELD: CommandField = { id: "swing", labelKey: "cmd.opening.swingField" };
const KIND: CmdOption = { id: "kind", labelKey: "cmd.opening.kind", value: "window" };
const HINGE: CmdOption = { id: "hinge", labelKey: "cmd.opening.hinge", value: "start" };
const SIDE: CmdOption = { id: "side", labelKey: "cmd.opening.side", value: "left" };
const DIR: CmdOption = { id: "dir", labelKey: "cmd.opening.dir", value: "in" };
interface OpIdle extends CommandState {
phase: "wall";
kind: "window" | "door";
hinge: "start" | "end";
side: "left" | "right";
dir: "in" | "out";
width: number;
height: number;
sill: number;
swingAngle: number;
}
interface OpPos extends CommandState {
phase: "pos";
wallId: string;
start: Vec2;
end: Vec2;
cursor: Vec2 | null;
posAlong: number; // Abstand vom Wandanfang (Meter)
kind: "window" | "door";
hinge: "start" | "end";
side: "left" | "right";
dir: "in" | "out";
width: number;
height: number;
sill: number;
swingAngle: number;
}
type OpState = OpIdle | OpPos;
function initState(kind: "window" | "door" = "window"): OpIdle {
const def = kind === "door" ? DEF_DOOR : DEF_WINDOW;
return {
phase: "wall",
lastPoint: null,
kind,
hinge: "start",
side: "left",
dir: "in",
width: def.width,
height: def.height,
sill: def.sill,
swingAngle: 90,
};
}
/** Vorschau: Lücken-Balken entlang der Wand + Positionsmarke. */
function posDraft(s: OpPos): ToolDraft {
const ax = sub(s.end, s.start);
const len = Math.hypot(ax.x, ax.y) || 1e-9;
const u = { x: ax.x / len, y: ax.y / len };
const n = { x: -u.y, y: u.x };
const from = Math.max(0, Math.min(s.posAlong, len));
const to = Math.max(from, Math.min(from + s.width, len));
const a = add(s.start, scale(u, from));
const b = add(s.start, scale(u, to));
const half = 0.15; // Vorschau-Balken quer zur Wand
const quad: Vec2[] = [
add(a, scale(n, half)),
add(b, scale(n, half)),
add(b, scale(n, -half)),
add(a, scale(n, -half)),
];
const preview: DraftShape[] = [{ kind: "poly", pts: quad, closed: true }];
// Symbol-Andeutung: Fenster = Mittellinie, Tür = Blatt-Linie in Schwenkrichtung.
if (s.kind === "window") {
preview.push({ kind: "line", a, b });
} else {
const swingSign = s.side === "left" ? 1 : -1;
const dirSign = s.dir === "in" ? 1 : -1;
const hinge = s.hinge === "start" ? a : b;
const leafEnd = add(hinge, scale(n, swingSign * dirSign * s.width));
preview.push({ kind: "line", a: hinge, b: leafEnd });
}
const draft: ToolDraft = { preview, vertices: [a, b] };
const mid = add(a, scale(u, (to - from) / 2));
draft.hud = {
at: s.cursor ?? mid,
text: `${from.toFixed(2)} m · ${s.kind === "door" ? "Tür" : "Fenster"} ${(
s.width * 100
).toFixed(0)}×${(s.height * 100).toFixed(0)}`,
};
return draft;
}
/** Hängt eine Öffnung ans Projekt (immutabel). */
function appendOpening(p: Project, s: OpPos, ctx: CommandContext): Project {
const len = Math.hypot(s.end.x - s.start.x, s.end.y - s.start.y);
const width = Math.max(0.1, Math.min(s.width, len));
const position = Math.max(0, Math.min(s.posAlong, Math.max(0, len - width)));
const opening: Opening = {
id: uniqueId("O"),
type: "opening",
hostWallId: s.wallId,
categoryCode: openingCategory(ctx),
kind: s.kind,
position,
width,
height: s.height,
sillHeight: s.kind === "door" ? 0 : s.sill,
...(s.kind === "door"
? { hinge: s.hinge, swing: s.side, openingDir: s.dir, swingAngle: s.swingAngle }
: {}),
};
const openings = p.openings ? [...p.openings, opening] : [opening];
return { ...p, openings };
}
const idle = (from?: OpState): [CommandState, CommandResult] => [
initState(from?.kind ?? "window"),
{ draft: null, done: true },
];
export const openingCommand: Command = {
name: "opening",
labelKey: "cmd.opening.label",
floorOnly: true,
prompt: (s) =>
(s as OpState).phase === "pos" ? "cmd.opening.pos" : "cmd.opening.wall",
accepts: (s) =>
(s as OpState).phase === "pos"
? ["point", "number", "option"]
: ["point", "option"],
options: (s): CmdOption[] => {
const st = s as OpState;
const kind: CmdOption = { ...KIND, value: st.kind };
if (st.kind === "door") {
return [
kind,
{ ...HINGE, value: st.hinge },
{ ...SIDE, value: st.side },
{ ...DIR, value: st.dir },
];
}
return [kind];
},
init: (): OpIdle => initState("window"),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as OpState;
if (input.kind === "option") {
// Umschalter zyklen ihren Wert. Bei kind-Wechsel Default-Maße übernehmen.
const next = { ...s } as OpState;
if (input.id === "kind") {
next.kind = s.kind === "window" ? "door" : "window";
const def = next.kind === "door" ? DEF_DOOR : DEF_WINDOW;
next.width = def.width;
next.height = def.height;
next.sill = def.sill;
} else if (input.id === "hinge") {
next.hinge = s.hinge === "start" ? "end" : "start";
} else if (input.id === "side") {
next.side = s.side === "left" ? "right" : "left";
} else if (input.id === "dir") {
next.dir = s.dir === "in" ? "out" : "in";
}
return [next, { draft: next.phase === "pos" ? posDraft(next as OpPos) : null }];
}
if (input.kind === "number") {
// Im Positionsschritt: getippte Zahl = Abstand vom Wandanfang (Meter).
if (s.phase === "pos") {
const ns: OpPos = { ...s, posAlong: input.value };
return [ns, { draft: posDraft(ns) }];
}
return [s, { draft: null }];
}
if (input.kind !== "point") {
return [s, { draft: s.phase === "pos" ? posDraft(s as OpPos) : null }];
}
const pt = input.point;
if (s.phase === "wall") {
const w = pickWall(ctx, pt);
if (!w) return [s, { draft: null }]; // kein Treffer: Schritt wiederholen
const proj = projectOntoWall(w, pt);
const ns: OpPos = {
phase: "pos",
lastPoint: pt,
wallId: w.id,
start: w.start,
end: w.end,
cursor: pt,
posAlong: proj.t,
kind: s.kind,
hinge: s.hinge,
side: s.side,
dir: s.dir,
width: s.width,
height: s.height,
sill: s.sill,
swingAngle: s.swingAngle,
};
return [ns, { draft: posDraft(ns) }];
}
// Positionsschritt: Klick projiziert auf die Achse → committen.
const proj = projectOntoWall(s, pt);
const ns: OpPos = { ...s, posAlong: proj.t, cursor: pt };
return [
idle(s)[0],
{ draft: null, done: true, commit: (p) => appendOpening(p, ns, ctx) },
];
},
onMove: (state, point): [CommandState, CommandResult] => {
const s = state as OpState;
if (s.phase !== "pos") return [s, { draft: null }];
const proj = projectOntoWall(s, point);
const ns: OpPos = { ...s, posAlong: proj.t, cursor: point };
return [ns, { draft: posDraft(ns) }];
},
onConfirm: (state, ctx): [CommandState, CommandResult] => {
const s = state as OpState;
if (s.phase === "pos") {
const ns = s;
return [
idle(s)[0],
{ draft: null, done: true, commit: (p) => appendOpening(p, ns, ctx) },
];
}
return idle(s);
},
onCancel: (state): [CommandState, CommandResult] => idle(state as OpState),
fields: (state) => {
const s = state as OpState;
if (s.phase !== "pos") return [];
return s.kind === "door" ? [...BASE_FIELDS, SWING_FIELD] : BASE_FIELDS;
},
// Getippte Tab-Felder sticky in den State übernehmen; kein Koordinatenpunkt.
pointFromFields: (state, locks) => {
const s = state as OpState;
if ("width" in locks && locks.width > 0) s.width = locks.width;
if ("height" in locks && locks.height > 0) s.height = locks.height;
if ("sill" in locks && locks.sill >= 0) s.sill = locks.sill;
if ("swing" in locks && locks.swing > 0) s.swingAngle = locks.swing;
return null;
},
fieldValues: (state): Record<string, number> => {
const s = state as OpState;
const out: Record<string, number> = {
width: s.width,
height: s.height,
sill: s.sill,
};
if (s.kind === "door") out.swing = s.swingAngle;
return out;
},
};
/** „Fenster"-Variante: identischer Befehl, startet aber im Fenster-Modus. */
export const fensterCommand: Command = {
...openingCommand,
name: "fenster",
labelKey: "cmd.opening.windowLabel",
init: (): OpIdle => initState("window"),
};
/** „Tür"-Variante: identischer Befehl, startet aber im Tür-Modus. */
export const tuerCommand: Command = {
...openingCommand,
name: "tuer",
labelKey: "cmd.opening.doorLabel",
init: (): OpIdle => initState("door"),
};
+68 -27
View File
@@ -44,21 +44,34 @@ function polyNextFromFields(last: Vec2, locks: Record<string, number>, cursor: V
interface PolyIdle extends CommandState {
phase: "start";
/** Gewählter Schließ-Modus (bleibt über den Befehl hinweg erhalten). */
closed: boolean;
}
interface PolyDrawing extends CommandState {
phase: "next";
points: Vec2[];
cursor: Vec2 | null;
/** Soll der committete Zug als Ring (geschlossen) gelten? Default: offen. */
closed: boolean;
}
type PolyState = PolyIdle | PolyDrawing;
/** Sofort-schließen-Aktion (nur ≥3 Punkte): committet als Ring und beendet. */
const CLOSE: CmdOption = { id: "close", labelKey: "cmd.polyline.close" };
const UNDO: CmdOption = { id: "undo", labelKey: "cmd.polyline.undo" };
/** Toggle-Option: Ergebnis geschlossen (Ring) oder offen — im Feld-Row sichtbar. */
const closedOption = (closed: boolean): CmdOption => ({
id: "mode",
labelKey: "cmd.polyline.closedOpt",
value: closed ? "on" : "off",
});
/** Vorschau (Zug + Gummiband zum Cursor) + HUD (Länge·Winkel des letzten Segments). */
function polyDraft(points: Vec2[], cursor: Vec2 | null): ToolDraft {
function polyDraft(points: Vec2[], cursor: Vec2 | null, closed = false): ToolDraft {
const pts = cursor ? [...points, cursor] : points;
const preview: DraftShape[] = [{ kind: "poly", pts, closed: false }];
// Ring nur ab 3 Punkten zeigen; darunter bleibt die Vorschau offen.
const showClosed = closed && pts.length >= 3;
const preview: DraftShape[] = [{ kind: "poly", pts, closed: showClosed }];
const draft: ToolDraft = { preview, vertices: points };
const last = points[points.length - 1];
if (cursor && last && segLen(last, cursor) >= EPS) {
@@ -89,8 +102,9 @@ function appendPolyline(
return { ...p, drawings2d: [...p.drawings2d, d] };
}
const idle = (): [CommandState, CommandResult] => [
{ phase: "start", lastPoint: null },
/** Ruhezustand; behält den zuletzt gewählten Schließ-Modus bei. */
const idle = (closed = false): [CommandState, CommandResult] => [
{ phase: "start", lastPoint: null, closed } as PolyIdle,
{ draft: null, done: true },
];
@@ -98,77 +112,95 @@ export const polylineCommand: Command = {
name: "polyline",
labelKey: "cmd.polyline.label",
prompt: (s) => ((s as PolyState).phase === "next" ? "cmd.polyline.next" : "cmd.polyline.start"),
accepts: (s) =>
(s as PolyState).phase === "next" ? ["point", "number", "option"] : ["point", "number"],
// Auch im Startschritt darf der Schließ-Modus gewählt werden.
accepts: () => ["point", "number", "option"],
options: (s) => {
const ps = s as PolyState;
if (ps.phase !== "next") return [];
return ps.points.length >= 2 ? [CLOSE, UNDO] : [UNDO];
const toggle = closedOption(ps.closed);
if (ps.phase !== "next") return [toggle];
return ps.points.length >= 2 ? [CLOSE, toggle, UNDO] : [toggle, UNDO];
},
init: (): PolyIdle => ({ phase: "start", lastPoint: null }),
init: (): PolyIdle => ({ phase: "start", lastPoint: null, closed: false }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as PolyState;
if (input.kind === "option") {
// Schließ-Modus umschalten (Tastenkürzel „C" bzw. Klick im Feld-Row);
// in jedem Schritt verfügbar, ohne den laufenden Zug zu beenden.
if (input.id === "mode") {
const ns = { ...s, closed: !s.closed } as PolyState;
return [ns, { draft: s.phase === "next" ? polyDraft(s.points, s.cursor, ns.closed) : null }];
}
if (s.phase !== "next") return [s, { draft: null }];
if (input.id === "undo") {
const pts = s.points.slice(0, -1);
if (pts.length === 0) return [{ phase: "start", lastPoint: null }, { draft: null }];
const ns: PolyDrawing = { phase: "next", points: pts, cursor: s.cursor, lastPoint: pts[pts.length - 1] };
return [ns, { draft: polyDraft(pts, s.cursor) }];
if (pts.length === 0)
return [{ phase: "start", lastPoint: null, closed: s.closed } as PolyIdle, { draft: null }];
const ns: PolyDrawing = {
phase: "next",
points: pts,
cursor: s.cursor,
lastPoint: pts[pts.length - 1],
closed: s.closed,
};
return [ns, { draft: polyDraft(pts, s.cursor, s.closed) }];
}
// Sofort schließen: committet als Ring (unabhängig vom Toggle-Zustand).
if (input.id === "close" && s.points.length >= 3) {
const pts = s.points;
return [
{ phase: "start", lastPoint: null },
{ phase: "start", lastPoint: null, closed: s.closed } as PolyIdle,
{ draft: null, done: true, commit: (p) => appendPolyline(p, pts, true, ctx) },
];
}
return [s, { draft: polyDraft(s.points, s.cursor) }];
return [s, { draft: polyDraft(s.points, s.cursor, s.closed) }];
}
if (input.kind !== "point") {
return [s, { draft: s.phase === "next" ? polyDraft(s.points, s.cursor) : null }];
return [s, { draft: s.phase === "next" ? polyDraft(s.points, s.cursor, s.closed) : null }];
}
const pt = input.point;
if (s.phase !== "next") {
const ns: PolyDrawing = { phase: "next", points: [pt], cursor: pt, lastPoint: pt };
return [ns, { draft: polyDraft([pt], pt) }];
const ns: PolyDrawing = { phase: "next", points: [pt], cursor: pt, lastPoint: pt, closed: s.closed };
return [ns, { draft: polyDraft([pt], pt, s.closed) }];
}
// Klick nahe Startpunkt → schließen.
// Klick nahe Startpunkt → schließen (committet als Ring).
if (s.points.length >= 3 && segLen(s.points[0], pt) < CLOSE_HIT) {
const pts = s.points;
return [
{ phase: "start", lastPoint: null },
{ phase: "start", lastPoint: null, closed: s.closed } as PolyIdle,
{ draft: null, done: true, commit: (p) => appendPolyline(p, pts, true, ctx) },
];
}
const points = [...s.points, pt];
const ns: PolyDrawing = { phase: "next", points, cursor: pt, lastPoint: pt };
return [ns, { draft: polyDraft(points, pt) }];
const ns: PolyDrawing = { phase: "next", points, cursor: pt, lastPoint: pt, closed: s.closed };
return [ns, { draft: polyDraft(points, pt, s.closed) }];
},
onMove: (state, point): [CommandState, CommandResult] => {
const s = state as PolyState;
if (s.phase !== "next") return [s, { draft: null }];
const ns: PolyDrawing = { ...s, cursor: point };
return [ns, { draft: polyDraft(s.points, point) }];
return [ns, { draft: polyDraft(s.points, point, s.closed) }];
},
// Enter/Space/Rechtsklick: offenen Zug beenden (≥2 Punkte) und committen.
// Enter/Space/Rechtsklick: Zug beenden (≥2 Punkte). Der Schließ-Modus des
// Toggles bestimmt, ob als Ring oder offen committet wird. Ein Ring braucht
// ≥3 Punkte, sonst fällt er auf „offen" zurück.
onConfirm: (state, ctx): [CommandState, CommandResult] => {
const s = state as PolyState;
if (s.phase === "next" && s.points.length >= 2) {
const pts = s.points;
const close = s.closed && pts.length >= 3;
return [
{ phase: "start", lastPoint: null },
{ draft: null, done: true, commit: (p) => appendPolyline(p, pts, false, ctx) },
{ phase: "start", lastPoint: null, closed: s.closed } as PolyIdle,
{ draft: null, done: true, commit: (p) => appendPolyline(p, pts, close, ctx) },
];
}
return idle();
return idle(s.closed);
},
onCancel: (): [CommandState, CommandResult] => idle(),
onCancel: (state): [CommandState, CommandResult] => idle((state as PolyState).closed),
// Tab-Feld-Zyklus nur im „next"-Schritt: Länge + Winkel relativ zum letzten
// Punkt. (Erster Punkt = freie Koordinate, kein Feld-Modus.)
@@ -178,4 +210,13 @@ export const polylineCommand: Command = {
if (s.phase !== "next" || s.points.length === 0) return null;
return polyNextFromFields(s.points[s.points.length - 1], locks, cursor);
},
fieldValues: (state, _locks, cursor): Record<string, number> => {
const s = state as PolyState;
if (s.phase !== "next" || s.points.length === 0 || !cursor) return {};
const last = s.points[s.points.length - 1];
return {
length: segLen(last, cursor),
angle: ((segAngleDeg(last, cursor) % 360) + 360) % 360,
};
},
};
+332 -51
View File
@@ -1,9 +1,20 @@
// Rectangle — zwei Ecken (portiert rectTool). Schritte:
// 1) „Erste Ecke:" → Punkt
// 2) „Gegenüberliegende Ecke:" → Punkt → commit Drawing2D rect → done
// Rectangle — mehrere Konstruktionsmethoden (portiert + erweitert rectTool).
// Wählbar über die Option „Methode" (klickbar im Feld-Row, tippbar als „m",
// zyklisch): 2-Punkt · 3-Punkt · Zentrum.
//
// Getippte Maße: die zweite Ecke kann als `r<breite>,<höhe>` relativ zur ersten
// eingegeben werden (die Engine löst `r…` relativ zu lastPoint auf).
// • 2-Punkt (Default): zwei gegenüberliegende Ecken, achsparallel.
// 1) „Erste Ecke:" → Punkt
// 2) „Gegenüberliegende Ecke:" → Punkt → commit (shape:"rect")
// • 3-Punkt (gedreht): Basiskante + Höhe → beliebig gedrehtes Rechteck.
// 1) „Erste Ecke:" → Punkt (Basis-Start)
// 2) „Zweite Ecke:" → Punkt (Basis-Ende, definiert Winkel+Breite)
// 3) „Höhe:" → Punkt → commit (geschlossene Polylinie)
// • Zentrum: Mittelpunkt + Ecke, achsparallel.
// 1) „Mittelpunkt:" → Punkt
// 2) „Ecke:" → Punkt → commit (shape:"rect")
//
// Getippte Maße: die zweite/dritte Ecke kann relativ (`r<dx>,<dy>`) eingegeben
// werden (die Engine löst `r…` relativ zu lastPoint auf).
import type { Drawing2D } from "../../model/types";
import { uniqueId } from "../../tools/types";
@@ -13,19 +24,50 @@ import type {
CommandField,
CommandResult,
CommandState,
CmdOption,
DraftShape,
Project,
ToolDraft,
Vec2,
} from "../types";
import type { TranslationKey } from "../../i18n";
const EPS = 1e-6;
/** Tab-Felder des zweiten-Ecke-Schritts: Breite, Höhe. */
/** Konstruktionsmethode. */
type RectMethod = "corner" | "three" | "center";
const METHOD_ORDER: RectMethod[] = ["corner", "three", "center"];
const nextMethod = (m: RectMethod): RectMethod =>
METHOD_ORDER[(METHOD_ORDER.indexOf(m) + 1) % METHOD_ORDER.length];
const methodValue = (m: RectMethod): string =>
m === "corner" ? "2pt" : m === "three" ? "3pt" : "center";
/** Umschalt-Option für die Methode (im Feld-Row sichtbar, tippbar als „m"). */
const methodOption = (m: RectMethod): CmdOption => ({
id: "method",
labelKey: "cmd.rect.method",
value: methodValue(m),
});
/** Tab-Felder des Breite/Höhe-Schritts (2-Punkt & Zentrum). */
const RECT_FIELDS: CommandField[] = [
{ id: "width", labelKey: "cmd.field.width" },
{ id: "height", labelKey: "cmd.field.height" },
];
/** Tab-Felder des Basis-Schritts (3-Punkt): Länge + Winkel der Basiskante. */
const BASE_FIELDS: CommandField[] = [
{ id: "length", labelKey: "cmd.field.length" },
{ id: "angle", labelKey: "cmd.field.angle" },
];
/** Tab-Feld des Höhen-Schritts (3-Punkt): senkrechter Abstand. */
const RISE_FIELDS: CommandField[] = [{ id: "height", labelKey: "cmd.field.height" }];
const DEG = Math.PI / 180;
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;
// ── 2-Punkt / Zentrum: achsparallele Ableitung ───────────────────────────────
/**
* Gegenüberliegende Ecke aus gelockten Feldern (width/height) + Cursor: eine
@@ -43,15 +85,107 @@ function rectCornerFromFields(a: Vec2, locks: Record<string, number>, cursor: Ve
return { x: a.x + w, y: a.y + h };
}
// ── 3-Punkt: Basiskante + Höhe ───────────────────────────────────────────────
/** Basis-Endpunkt aus gelockten Feldern (length/angle) + Cursor, relativ zu `a`. */
function baseFromFields(a: Vec2, locks: Record<string, number>, cursor: Vec2 | null): Vec2 {
const ref = cursor ?? a;
const length = "length" in locks ? locks.length : segLen(a, ref);
const angleDeg = "angle" in locks ? locks.angle : segAngleDeg(a, ref);
const ang = angleDeg * DEG;
return { x: a.x + Math.cos(ang) * length, y: a.y + Math.sin(ang) * length };
}
/**
* Vier Ecken des gedrehten Rechtecks aus Basiskante (a→b) und einem dritten
* Punkt `p`: die Höhe ist der vorzeichenbehaftete senkrechte Abstand von `p` zur
* Basislinie; das Rechteck wird zu dieser Seite hin aufgespannt.
*/
function rotatedCorners(a: Vec2, b: Vec2, p: Vec2): Vec2[] {
const dx = b.x - a.x;
const dy = b.y - a.y;
const len = Math.hypot(dx, dy);
if (len < EPS) return [a, b, b, a];
// Einheits-Normale (links der Basisrichtung).
const nx = -dy / len;
const ny = dx / len;
const h = (p.x - a.x) * nx + (p.y - a.y) * ny; // signierte Höhe
const c3 = { x: b.x + nx * h, y: b.y + ny * h };
const c4 = { x: a.x + nx * h, y: a.y + ny * h };
return [a, b, c3, c4];
}
/** Senkrechter (signierter) Abstand eines Punktes zur Basislinie a→b. */
function riseFrom(a: Vec2, b: Vec2, p: Vec2): number {
const dx = b.x - a.x;
const dy = b.y - a.y;
const len = Math.hypot(dx, dy);
if (len < EPS) return 0;
return (p.x - a.x) * (-dy / len) + (p.y - a.y) * (dx / len);
}
/** Dritten Punkt aus gelockter Höhe rekonstruieren (auf der Cursor-Seite). */
function riseToPoint(a: Vec2, b: Vec2, height: number, cursor: Vec2 | null): Vec2 {
const dx = b.x - a.x;
const dy = b.y - a.y;
const len = Math.hypot(dx, dy);
if (len < EPS) return cursor ?? b;
const nx = -dy / len;
const ny = dx / len;
const sign = cursor && riseFrom(a, b, cursor) < 0 ? -1 : 1;
const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
const h = sign * Math.abs(height);
return { x: mid.x + nx * h, y: mid.y + ny * h };
}
// ── Zentrum: Ecke aus Mittelpunkt ────────────────────────────────────────────
/** Achsparallele Ecken aus Zentrum + einer Ecke. */
function centerGeom(center: Vec2, corner: Vec2): { min: Vec2; max: Vec2 } {
const hx = Math.abs(corner.x - center.x);
const hy = Math.abs(corner.y - center.y);
return {
min: { x: center.x - hx, y: center.y - hy },
max: { x: center.x + hx, y: center.y + hy },
};
}
/** Ecke aus Zentrum + gelockten Halbmaßen (width/height als volle Kantenmaße). */
function centerCornerFromFields(
center: Vec2,
locks: Record<string, number>,
cursor: Vec2 | null,
): Vec2 {
const cur = cursor ?? center;
const dx = cur.x - center.x;
const dy = cur.y - center.y;
const sx = dx < 0 ? -1 : 1;
const sy = dy < 0 ? -1 : 1;
const hx = "width" in locks ? Math.abs(locks.width) / 2 : Math.abs(dx);
const hy = "height" in locks ? Math.abs(locks.height) / 2 : Math.abs(dy);
return { x: center.x + sx * hx, y: center.y + sy * hy };
}
// ── Zustand ──────────────────────────────────────────────────────────────────
interface RectIdle extends CommandState {
phase: "corner1";
method: RectMethod;
}
interface RectDrawing extends CommandState {
interface RectCorner2 extends CommandState {
phase: "corner2";
method: RectMethod;
a: Vec2;
cursor: Vec2 | null;
}
type RectState = RectIdle | RectDrawing;
interface RectRise extends CommandState {
phase: "rise";
method: "three";
a: Vec2;
b: Vec2;
cursor: Vec2 | null;
}
type RectState = RectIdle | RectCorner2 | RectRise;
function rectGeom(a: Vec2, b: Vec2): { min: Vec2; max: Vec2 } {
return {
@@ -60,84 +194,231 @@ function rectGeom(a: Vec2, b: Vec2): { min: Vec2; max: Vec2 } {
};
}
/** Vorschau (geschlossenes Rechteck) + HUD (Breite × Höhe). */
function rectDraft(a: Vec2, cursor: Vec2 | null): ToolDraft {
if (!cursor) return { preview: [], vertices: [a] };
const g = rectGeom(a, cursor);
const pts: Vec2[] = [
g.min,
{ x: g.max.x, y: g.min.y },
g.max,
{ x: g.min.x, y: g.max.y },
];
// ── Vorschau ─────────────────────────────────────────────────────────────────
function corners4Draft(pts: Vec2[], hudAt: Vec2 | null, hudText: string): ToolDraft {
const preview: DraftShape[] = [{ kind: "poly", pts, closed: true }];
const w = g.max.x - g.min.x;
const h = g.max.y - g.min.y;
return {
preview,
vertices: [a],
hud: { at: cursor, text: `${w.toFixed(2)} × ${h.toFixed(2)} m` },
};
const draft: ToolDraft = { preview, vertices: [pts[0]] };
if (hudAt) draft.hud = { at: hudAt, text: hudText };
return draft;
}
function appendRect(p: Project, a: Vec2, b: Vec2, ctx: CommandContext): Project {
const g = rectGeom(a, b);
if (g.max.x - g.min.x < EPS || g.max.y - g.min.y < EPS) return p; // Null-Rechteck
/** Vorschau des 2-Punkt-Rechtecks (achsparallel). */
function cornerDraft(a: Vec2, cursor: Vec2 | null): ToolDraft {
if (!cursor) return { preview: [], vertices: [a] };
const g = rectGeom(a, cursor);
const pts: Vec2[] = [g.min, { x: g.max.x, y: g.min.y }, g.max, { x: g.min.x, y: g.max.y }];
const w = g.max.x - g.min.x;
const h = g.max.y - g.min.y;
return corners4Draft(pts, cursor, `${w.toFixed(2)} × ${h.toFixed(2)} m`);
}
/** Vorschau des Zentrum-Rechtecks (achsparallel). */
function centerDraft(center: Vec2, cursor: Vec2 | null): ToolDraft {
if (!cursor) return { preview: [], vertices: [center] };
const g = centerGeom(center, cursor);
const pts: Vec2[] = [g.min, { x: g.max.x, y: g.min.y }, g.max, { x: g.min.x, y: g.max.y }];
const w = g.max.x - g.min.x;
const h = g.max.y - g.min.y;
return corners4Draft(pts, cursor, `${w.toFixed(2)} × ${h.toFixed(2)} m`);
}
/** Vorschau der Basiskante (3-Punkt, erster Teil). */
function baseDraft(a: Vec2, cursor: Vec2 | null): ToolDraft {
if (!cursor || segLen(a, cursor) < EPS) return { preview: [{ kind: "line", a, b: a }], vertices: [a] };
const draft: ToolDraft = { preview: [{ kind: "line", a, b: cursor }], vertices: [a] };
draft.hud = {
at: cursor,
text: `${segLen(a, cursor).toFixed(2)} m · ${Math.abs((segAngleDeg(a, cursor) + 360) % 360).toFixed(0)}°`,
};
return draft;
}
/** Vorschau des gedrehten Rechtecks (3-Punkt, zweiter Teil). */
function riseDraft(a: Vec2, b: Vec2, cursor: Vec2 | null): ToolDraft {
if (!cursor) return { preview: [{ kind: "line", a, b }], vertices: [a] };
const pts = rotatedCorners(a, b, cursor);
const w = segLen(a, b);
const h = Math.abs(riseFrom(a, b, cursor));
return corners4Draft(pts, cursor, `${w.toFixed(2)} × ${h.toFixed(2)} m`);
}
// ── Commit ───────────────────────────────────────────────────────────────────
/** Achsparalleles Rechteck als `shape:"rect"` (geschlossen behandelt). */
function appendRect(p: Project, min: Vec2, max: Vec2, ctx: CommandContext): Project {
if (max.x - min.x < EPS || max.y - min.y < EPS) return p; // Null-Rechteck
const d: Drawing2D = {
id: uniqueId("dr2d"),
type: "drawing2d",
levelId: ctx.level.id,
categoryCode: ctx.defaultCategoryCode,
geom: { shape: "rect", min: g.min, max: g.max },
geom: { shape: "rect", min, max },
};
return { ...p, drawings2d: [...p.drawings2d, d] };
}
const idle = (): [CommandState, CommandResult] => [
{ phase: "corner1", lastPoint: null },
/**
* Gedrehtes Rechteck als geschlossene Polylinie (`shape:"polyline", closed:true`)
* — `shape:"rect"` kann nur achsparallel, deshalb hier ein Ring aus vier Ecken.
* Downstream (Füllung/Offset) behandelt einen geschlossenen Polygonzug als Ring.
*/
function appendRotated(p: Project, pts: Vec2[], ctx: CommandContext): Project {
// Fläche prüfen: entartete Rechtecke verwerfen.
const w = segLen(pts[0], pts[1]);
const h = segLen(pts[1], pts[2]);
if (w < EPS || h < EPS) return p;
const d: Drawing2D = {
id: uniqueId("dr2d"),
type: "drawing2d",
levelId: ctx.level.id,
categoryCode: ctx.defaultCategoryCode,
geom: { shape: "polyline", pts, closed: true },
};
return { ...p, drawings2d: [...p.drawings2d, d] };
}
const idle = (method: RectMethod): [CommandState, CommandResult] => [
{ phase: "corner1", lastPoint: null, method } as RectIdle,
{ draft: null, done: true },
];
// ── Prompt je Schritt/Methode ────────────────────────────────────────────────
function promptKey(s: RectState): TranslationKey {
if (s.phase === "corner1") return s.method === "center" ? "cmd.rect.center" : "cmd.rect.first";
if (s.phase === "rise") return "cmd.rect.rise";
// corner2
if (s.method === "three") return "cmd.rect.baseEnd";
if (s.method === "center") return "cmd.rect.corner";
return "cmd.rect.second";
}
export const rectCommand: Command = {
name: "rect",
labelKey: "cmd.rect.label",
prompt: (s) => ((s as RectState).phase === "corner2" ? "cmd.rect.second" : "cmd.rect.first"),
accepts: () => ["point", "number"],
options: () => [],
init: (): RectIdle => ({ phase: "corner1", lastPoint: null }),
prompt: (s) => promptKey(s as RectState),
accepts: () => ["point", "number", "option"],
options: (s) => [methodOption((s as RectState).method)],
init: (): RectIdle => ({ phase: "corner1", lastPoint: null, method: "corner" }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as RectState;
if (input.kind === "option") {
if (input.id === "method") {
// Methode zyklisch umschalten; laufende Konstruktion verwerfen und den
// Befehl AKTIV lassen (kein done) — nur zum ersten Schritt zurück.
const m = nextMethod(s.method);
const ns: RectIdle = { phase: "corner1", lastPoint: null, method: m };
return [ns, { draft: null }];
}
return [s, { draft: null }];
}
if (input.kind !== "point") {
return [s, { draft: s.phase === "corner2" ? rectDraft(s.a, s.cursor) : null }];
return [s, { draft: draftFor(s) }];
}
const pt = input.point;
if (s.phase !== "corner2") {
const ns: RectDrawing = { phase: "corner2", a: pt, cursor: pt, lastPoint: pt };
return [ns, { draft: rectDraft(pt, pt) }];
if (s.phase === "corner1") {
if (s.method === "three") {
const ns: RectCorner2 = { phase: "corner2", method: "three", a: pt, cursor: pt, lastPoint: pt };
return [ns, { draft: baseDraft(pt, pt) }];
}
const ns: RectCorner2 = { phase: "corner2", method: s.method, a: pt, cursor: pt, lastPoint: pt };
return [ns, { draft: draftFor(ns) }];
}
const a = s.a;
if (s.phase === "corner2") {
if (s.method === "three") {
// Basiskante gesetzt → dritter Punkt bestimmt Höhe.
if (segLen(s.a, pt) < EPS) return [s, { draft: baseDraft(s.a, s.cursor) }];
const ns: RectRise = { phase: "rise", method: "three", a: s.a, b: pt, cursor: pt, lastPoint: pt };
return [ns, { draft: riseDraft(s.a, pt, pt) }];
}
const a = s.a;
const method = s.method;
return [
{ phase: "corner1", lastPoint: null, method } as RectIdle,
{
draft: null,
done: true,
commit: (p) => {
if (method === "center") {
const g = centerGeom(a, pt);
return appendRect(p, g.min, g.max, ctx);
}
const g = rectGeom(a, pt);
return appendRect(p, g.min, g.max, ctx);
},
},
];
}
// phase "rise" (3-Punkt) → committen als gedrehte, geschlossene Polylinie.
const { a, b } = s;
return [
{ phase: "corner1", lastPoint: null },
{ draft: null, done: true, commit: (p) => appendRect(p, a, pt, ctx) },
{ phase: "corner1", lastPoint: null, method: "three" } as RectIdle,
{ draft: null, done: true, commit: (p) => appendRotated(p, rotatedCorners(a, b, pt), ctx) },
];
},
onMove: (state, point): [CommandState, CommandResult] => {
const s = state as RectState;
if (s.phase !== "corner2") return [s, { draft: null }];
const ns: RectDrawing = { ...s, cursor: point };
return [ns, { draft: rectDraft(s.a, point) }];
if (s.phase === "corner1") return [s, { draft: null }];
const ns = { ...s, cursor: point } as RectState;
return [ns, { draft: draftFor(ns) }];
},
onConfirm: (): [CommandState, CommandResult] => idle(),
onCancel: (): [CommandState, CommandResult] => idle(),
onConfirm: (state): [CommandState, CommandResult] => idle((state as RectState).method),
onCancel: (state): [CommandState, CommandResult] => idle((state as RectState).method),
// Tab-Feld-Zyklus nur im „corner2"-Schritt: Breite + Höhe (signiert nach Cursor).
fields: (state) => ((state as RectState).phase === "corner2" ? RECT_FIELDS : []),
// Tab-Feld-Zyklus je Schritt/Methode.
fields: (state) => {
const s = state as RectState;
if (s.phase === "corner2") return s.method === "three" ? BASE_FIELDS : RECT_FIELDS;
if (s.phase === "rise") return RISE_FIELDS;
return [];
},
pointFromFields: (state, locks, cursor) => {
const s = state as RectState;
if (s.phase !== "corner2") return null;
return rectCornerFromFields(s.a, locks, cursor);
if (s.phase === "corner2") {
if (s.method === "three") return baseFromFields(s.a, locks, cursor);
if (s.method === "center") return centerCornerFromFields(s.a, locks, cursor);
return rectCornerFromFields(s.a, locks, cursor);
}
if (s.phase === "rise") {
if ("height" in locks) return riseToPoint(s.a, s.b, locks.height, cursor);
return cursor;
}
return null;
},
fieldValues: (state, _locks, cursor): Record<string, number> => {
const s = state as RectState;
if (!cursor) return {};
if (s.phase === "corner2") {
if (s.method === "three") {
return {
length: segLen(s.a, cursor),
angle: ((segAngleDeg(s.a, cursor) % 360) + 360) % 360,
};
}
return { width: Math.abs(cursor.x - s.a.x) * (s.method === "center" ? 2 : 1),
height: Math.abs(cursor.y - s.a.y) * (s.method === "center" ? 2 : 1) };
}
if (s.phase === "rise") return { height: Math.abs(riseFrom(s.a, s.b, cursor)) };
return {};
},
};
/** Vorschau passend zum Zustand. */
function draftFor(s: RectState): ToolDraft {
if (s.phase === "corner2") {
if (s.method === "three") return baseDraft(s.a, s.cursor);
if (s.method === "center") return centerDraft(s.a, s.cursor);
return cornerDraft(s.a, s.cursor);
}
if (s.phase === "rise") return riseDraft(s.a, s.b, s.cursor);
return { preview: [], vertices: [] };
}
+355
View File
@@ -0,0 +1,355 @@
// Raum (Room / SIA-416-Fläche) als Engine-Befehl. Zwei Modi (togglebar über die
// Option „Modus"):
//
// 1) „innen" (Default) — EIN Klick IN einen von Wänden umschlossenen Bereich.
// Der Umriss wird über den reinen Rechenkern (geometry/roomBoundary,
// `roomFromPointInside`) aus den Wänden des aktiven Geschosses automatisch
// als lichte Innenkontur erkannt. Findet der Klick keine geschlossene Zelle,
// passiert nichts (der Nutzer kann in den Manuell-Modus wechseln).
//
// 2) „manuell" — ein GESCHLOSSENER Umriss wird Punkt für Punkt gezeichnet
// (wie eine geschlossene Polylinie); Klick auf den Start / Option „Schliessen"
// / Enter (≥3 Punkte) committet den Raum.
//
// Der committete Raum bekommt SIA-Blatt HNF, einen Default-Namen und die
// Kategorie „45 Räume" (Fallback: aktive Kategorie). Fläche/Umfang/Schwerpunkt
// werden NICHT gespeichert (bei jedem Rendern aus `boundary` abgeleitet).
//
// Bezeichner englisch, sichtbarer Text via t() (CONVENTIONS.md). Einheit: METER.
import type { Room } from "../../model/types";
import { wallTypeThickness } from "../../model/types";
import { roomFromPointInside, type WallSegment } from "../../geometry/roomBoundary";
import { polygonArea, centroid } from "../../geometry/roomArea";
import { docFromText } from "../../text/richText";
import { uniqueId } from "../../tools/types";
import type {
Command,
CommandContext,
CommandField,
CommandResult,
CommandState,
CmdOption,
DraftShape,
Project,
ToolDraft,
Vec2,
} from "../types";
const EPS = 1e-6;
const DEG = Math.PI / 180;
const CLOSE_HIT = 0.08; // Modell-Meter: Klick nahe Startpunkt schließt den Umriss
/** Default-Ebene (Kategorie) für Räume: „60" Räume (Fallback: aktive). */
const ROOM_CATEGORY = "60";
/** Default-Raumfarbe (falls Kategorie nicht auflösbar). */
const ROOM_COLOR = "#5a7a9a";
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;
/** Existiert die Raum-Kategorie („45")? Sonst Fallback auf die aktive Kategorie. */
function roomCategory(ctx: CommandContext): string {
const flat: { code: string }[] = [];
const walk = (list: { code: string; children?: unknown[] }[]) => {
for (const c of list) {
flat.push({ code: c.code });
if (c.children) walk(c.children as { code: string; children?: unknown[] }[]);
}
};
walk(ctx.project.layers as { code: string; children?: unknown[] }[]);
return flat.some((c) => c.code === ROOM_CATEGORY)
? ROOM_CATEGORY
: ctx.defaultCategoryCode;
}
/** Farbe der Raum-Kategorie (Ebene) — sonst Default. */
function roomColor(ctx: CommandContext, code: string): string {
const flat: { code: string; color?: string; children?: unknown[] }[] = [];
const walk = (list: { code: string; color?: string; children?: unknown[] }[]) => {
for (const c of list) {
flat.push(c);
if (c.children)
walk(
c.children as {
code: string;
color?: string;
children?: unknown[];
}[],
);
}
};
walk(
ctx.project.layers as {
code: string;
color?: string;
children?: unknown[];
}[],
);
return flat.find((c) => c.code === code)?.color ?? ROOM_COLOR;
}
/**
* Wände des aktiven Geschosses in die vom Rechenkern erwartete Form bringen:
* Mittellinie (start→end) + Gesamtdicke des Wandtyps. Grafik-2D-Geometrie und
* andere Geschosse bleiben außen vor.
*/
function wallsOfFloor(ctx: CommandContext): WallSegment[] {
const { project, level } = ctx;
const out: WallSegment[] = [];
for (const w of project.walls) {
if (w.floorId !== level.id) continue;
const wt = project.wallTypes.find((t) => t.id === w.wallTypeId);
const thickness = wt ? wallTypeThickness(wt) : 0.2;
out.push({ a: w.start, b: w.end, thickness });
}
return out;
}
/** Baut einen Raum aus einem fertigen Umriss (immutabel; keine abgeleiteten Felder). */
function buildRoom(ctx: CommandContext, boundary: Vec2[]): Room {
const code = roomCategory(ctx);
const name = defaultRoomName(ctx);
return {
id: uniqueId("RM"),
type: "room",
floorId: ctx.level.id,
categoryCode: code,
siaCategory: "HNF",
name,
boundary,
color: roomColor(ctx, code),
// Stempel-Anker EINMALIG auf den Zentroid setzen (danach frei verschiebbar,
// bleibt beim Ändern der Kontur an seiner Stelle).
stampAnchor: centroid(boundary),
// Frei editierbarer Stempel-Text: ein zentrierter Absatz mit dem Raumnamen.
stampDoc: { ...docFromText(name), paragraphs: docFromText(name).paragraphs.map((p) => ({ ...p, align: "center" as const })) },
};
}
/** Fortlaufender Default-Name „Raum N" (N = Anzahl vorhandener Räume + 1). */
function defaultRoomName(ctx: CommandContext): string {
const n = (ctx.project.rooms ?? []).length + 1;
return `Raum ${n}`;
}
/** Hängt einen Raum ans Projekt (immutabel); entartete Umrisse werden verworfen. */
function appendRoom(p: Project, boundary: Vec2[], ctx: CommandContext): Project {
if (boundary.length < 3 || polygonArea(boundary) < 1e-4) return p;
const room = buildRoom(ctx, boundary);
const rooms = p.rooms ? [...p.rooms, room] : [room];
return { ...p, rooms };
}
// ── Zustand ──────────────────────────────────────────────────────────────────
type RoomMode = "inside" | "manual";
interface RoomIdle extends CommandState {
phase: "start";
mode: RoomMode;
}
interface RoomDrawing extends CommandState {
phase: "draw";
mode: RoomMode;
points: Vec2[];
cursor: Vec2 | null;
}
type RoomCmdState = RoomIdle | RoomDrawing;
const MODE: CmdOption = { id: "mode", labelKey: "cmd.room.mode", value: "inside" };
const CLOSE: CmdOption = { id: "close", labelKey: "cmd.polyline.close" };
const UNDO: CmdOption = { id: "undo", labelKey: "cmd.polyline.undo" };
/** Tab-Felder je weiteren Punkt (Manuell-Modus): Länge + Winkel relativ zum letzten. */
const ROOM_FIELDS: CommandField[] = [
{ id: "length", labelKey: "cmd.field.length" },
{ id: "angle", labelKey: "cmd.field.angle" },
];
function nextFromFields(
last: Vec2,
locks: Record<string, number>,
cursor: Vec2 | null,
): Vec2 {
const ref = cursor ?? last;
const length = "length" in locks ? locks.length : segLen(last, ref);
const angleDeg = "angle" in locks ? locks.angle : segAngleDeg(last, ref);
const ang = angleDeg * DEG;
return { x: last.x + Math.cos(ang) * length, y: last.y + Math.sin(ang) * length };
}
/** Vorschau des manuellen Umrisses (geschlossener Ring ab 3 Punkten) + HUD. */
function manualDraft(points: Vec2[], cursor: Vec2 | null): ToolDraft {
const pts = cursor ? [...points, cursor] : points;
const showClosed = pts.length >= 3;
const preview: DraftShape[] = [{ kind: "poly", pts, closed: showClosed }];
const draft: ToolDraft = { preview, vertices: points };
const last = points[points.length - 1];
if (cursor && last && segLen(last, cursor) >= EPS) {
draft.hud = {
at: cursor,
text: `${segLen(last, cursor).toFixed(2)} m · ${Math.abs(
(segAngleDeg(last, cursor) + 360) % 360,
).toFixed(0)}°`,
};
}
return draft;
}
const idle = (mode: RoomMode): [CommandState, CommandResult] => [
{ phase: "start", lastPoint: null, mode } as RoomIdle,
{ draft: null, done: true },
];
export const roomCommand: Command = {
name: "room",
labelKey: "cmd.room.label",
// Räume leben auf Geschossen — wie Wand/Decke nur dort aktiv.
floorOnly: true,
prompt: (s) => {
const st = s as RoomCmdState;
if (st.phase === "draw") return "cmd.room.next";
return st.mode === "inside" ? "cmd.room.inside" : "cmd.room.first";
},
accepts: (s) =>
(s as RoomCmdState).phase === "draw"
? ["point", "number", "option"]
: ["point", "option"],
options: (s): CmdOption[] => {
const st = s as RoomCmdState;
const modeOpt: CmdOption = { ...MODE, value: st.mode };
if (st.phase !== "draw") return [modeOpt];
return st.points.length >= 3 ? [modeOpt, CLOSE, UNDO] : [modeOpt, UNDO];
},
init: (): RoomIdle => ({ phase: "start", lastPoint: null, mode: "inside" }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as RoomCmdState;
if (input.kind === "option") {
if (input.id === "mode") {
// Modus umschalten; ein laufender Manuell-Entwurf wird verworfen.
const mode: RoomMode = s.mode === "inside" ? "manual" : "inside";
return idle(mode);
}
if (s.phase !== "draw") return [s, { draft: null }];
if (input.id === "undo") {
const pts = s.points.slice(0, -1);
if (pts.length === 0)
return [
{ phase: "start", lastPoint: null, mode: s.mode } as RoomIdle,
{ draft: null },
];
const ns: RoomDrawing = {
phase: "draw",
mode: s.mode,
points: pts,
cursor: s.cursor,
lastPoint: pts[pts.length - 1],
};
return [ns, { draft: manualDraft(pts, s.cursor) }];
}
if (input.id === "close" && s.points.length >= 3) {
const pts = s.points;
return [
{ phase: "start", lastPoint: null, mode: s.mode } as RoomIdle,
{ draft: null, done: true, commit: (p) => appendRoom(p, pts, ctx) },
];
}
return [s, { draft: s.phase === "draw" ? manualDraft(s.points, s.cursor) : null }];
}
if (input.kind !== "point") {
return [
s,
{ draft: s.phase === "draw" ? manualDraft(s.points, s.cursor) : null },
];
}
const pt = input.point;
// ── Modus „innen": ein Klick → Umriss automatisch erkennen + committen ──
if (s.mode === "inside" && s.phase !== "draw") {
const boundary = roomFromPointInside(pt, wallsOfFloor(ctx));
if (boundary && boundary.length >= 3) {
return [
{ phase: "start", lastPoint: null, mode: "inside" } as RoomIdle,
{ draft: null, done: true, commit: (p) => appendRoom(p, boundary, ctx) },
];
}
// Kein umschlossener Bereich getroffen → im Werkzeug bleiben (kein Commit).
return [s, { draft: null }];
}
// ── Modus „manuell": Umriss Punkt für Punkt ──
if (s.phase !== "draw") {
const ns: RoomDrawing = {
phase: "draw",
mode: "manual",
points: [pt],
cursor: pt,
lastPoint: pt,
};
return [ns, { draft: manualDraft([pt], pt) }];
}
// Klick nahe Startpunkt → schließen + committen.
if (s.points.length >= 3 && segLen(s.points[0], pt) < CLOSE_HIT) {
const pts = s.points;
return [
{ phase: "start", lastPoint: null, mode: s.mode } as RoomIdle,
{ draft: null, done: true, commit: (p) => appendRoom(p, pts, ctx) },
];
}
const last = s.points[s.points.length - 1];
if (segLen(last, pt) < EPS) {
return [s, { draft: manualDraft(s.points, s.cursor) }];
}
const points = [...s.points, pt];
const ns: RoomDrawing = {
phase: "draw",
mode: "manual",
points,
cursor: pt,
lastPoint: pt,
};
return [ns, { draft: manualDraft(points, pt) }];
},
onMove: (state, point): [CommandState, CommandResult] => {
const s = state as RoomCmdState;
if (s.phase !== "draw") return [s, { draft: null }];
const ns: RoomDrawing = { ...s, cursor: point };
return [ns, { draft: manualDraft(s.points, point) }];
},
onConfirm: (state, ctx): [CommandState, CommandResult] => {
const s = state as RoomCmdState;
if (s.phase === "draw" && s.points.length >= 3) {
const pts = s.points;
return [
{ phase: "start", lastPoint: null, mode: s.mode } as RoomIdle,
{ draft: null, done: true, commit: (p) => appendRoom(p, pts, ctx) },
];
}
return idle(s.mode);
},
onCancel: (state): [CommandState, CommandResult] =>
idle((state as RoomCmdState).mode),
fields: (state) => ((state as RoomCmdState).phase === "draw" ? ROOM_FIELDS : []),
pointFromFields: (state, locks, cursor) => {
const s = state as RoomCmdState;
if (s.phase !== "draw" || s.points.length === 0) return null;
return nextFromFields(s.points[s.points.length - 1], locks, cursor);
},
fieldValues: (state, _locks, cursor): Record<string, number> => {
const s = state as RoomCmdState;
if (s.phase !== "draw" || s.points.length === 0 || !cursor) return {};
const last = s.points[s.points.length - 1];
return {
length: segLen(last, cursor),
angle: ((segAngleDeg(last, cursor) % 360) + 360) % 360,
};
},
};
+268
View File
@@ -0,0 +1,268 @@
// Treppe (Stair) als Engine-Befehl. Ablauf:
// 1) „Startpunkt der Treppe:" → Punkt (Antritt, unterste Stufe).
// 2) „Laufrichtung / Ende:" → Punkt: definiert Richtung + Lauflänge des ersten
// Laufs. Bei L folgt ein zweiter Lauf (Ende des zweiten Laufs); bei Wendel
// ist der zweite Punkt der äußere Rand (Radius). Committet die Treppe.
//
// Optionen (togglebar): Grundform gerade/L/Wendel (Auf-/Ab-Umschalter). Tab-Felder
// im Laufschritt: Breite / Stufenanzahl / Steighöhe (totalRise). Live-Vorschau
// zeigt die Tritt-Linien + die Lauflinie.
//
// Herkunft der Treppen-Felder (CommandContext):
// • floorId ← ctx.level.id (aktives Geschoss)
// • categoryCode← "40 Treppen" (Fallback ctx.defaultCategoryCode)
// • totalRise ← Geschosshöhe (ctx.level.floorHeight), damit die Treppe genau
// ins nächste Geschoss steigt (geschossübergreifend).
//
// Bezeichner englisch, sichtbarer Text via t() (CONVENTIONS.md).
import type { Stair, StairShape, Vec2 as MVec2 } from "../../model/types";
import { stairGeometry, defaultStepCount } from "../../geometry/stair";
import { uniqueId } from "../../tools/types";
import type {
Command,
CommandContext,
CommandField,
CommandResult,
CommandState,
CmdOption,
DraftShape,
Project,
ToolDraft,
Vec2,
} from "../types";
/** Default-Ebene (Kategorie) für Treppen: „40" Treppen (Fallback: aktive). */
const STAIR_CATEGORY = "40";
/** Default-Laufbreite (Meter). */
const DEF_WIDTH = 1.0;
const EPS = 1e-6;
const sub = (a: Vec2, b: Vec2): Vec2 => ({ x: a.x - b.x, y: a.y - b.y });
const lenOf = (a: Vec2): number => Math.hypot(a.x, a.y);
const normv = (a: Vec2): Vec2 => {
const l = lenOf(a) || 1e-9;
return { x: a.x / l, y: a.y / l };
};
/** Existiert die Treppen-Kategorie („40")? Sonst Fallback auf die aktive. */
function stairCategory(ctx: CommandContext): string {
const flat: { code: string }[] = [];
const walk = (list: { code: string; children?: unknown[] }[]) => {
for (const c of list) {
flat.push({ code: c.code });
if (c.children) walk(c.children as { code: string; children?: unknown[] }[]);
}
};
walk(ctx.project.layers as { code: string; children?: unknown[] }[]);
return flat.some((c) => c.code === STAIR_CATEGORY)
? STAIR_CATEGORY
: ctx.defaultCategoryCode;
}
/** Geschosshöhe als Default-Steighöhe (Fallback 2.6 m). */
function floorRise(ctx: CommandContext): number {
return ctx.level.floorHeight && ctx.level.floorHeight > 0 ? ctx.level.floorHeight : 2.6;
}
/** Tab-Felder im Laufschritt: Breite / Stufenanzahl / Steighöhe. */
const RUN_FIELDS: CommandField[] = [
{ id: "width", labelKey: "cmd.field.width" },
{ id: "steps", labelKey: "cmd.stair.stepsField" },
{ id: "rise", labelKey: "cmd.stair.riseField" },
];
const SHAPE: CmdOption = { id: "shape", labelKey: "cmd.stair.shape", value: "straight" };
const UPDOWN: CmdOption = { id: "updown", labelKey: "cmd.stair.updown", value: "up" };
interface StairIdle extends CommandState {
phase: "start";
shape: StairShape;
up: boolean;
width: number;
steps: number | null; // null = automatisch
rise: number | null; // null = Geschosshöhe
}
interface StairRun extends CommandState {
phase: "run";
shape: StairShape;
up: boolean;
width: number;
steps: number | null;
rise: number | null;
start: Vec2;
cursor: Vec2;
}
type StairCmdState = StairIdle | StairRun;
function initState(shape: StairShape = "straight"): StairIdle {
return { phase: "start", lastPoint: null, shape, up: true, width: DEF_WIDTH, steps: null, rise: null };
}
/**
* Baut aus dem Laufzustand ein konkretes `Stair`-Objekt (aufgelöste Felder). Die
* Richtung/Länge folgen aus start→cursor; bei L bricht der zweite Lauf um 90°
* (links) ab, bei Wendel wird der Cursorabstand zum Radius.
*/
function buildStair(s: StairRun, ctx: CommandContext): Stair {
const rise = s.rise ?? floorRise(ctx);
const d = sub(s.cursor, s.start);
const dist = Math.max(0.1, lenOf(d));
const dir = dist > EPS ? normv(d) : ({ x: 1, y: 0 } as MVec2);
const runLength = s.shape === "spiral" ? Math.max(0.5, dist) : dist;
const steps = s.steps ?? defaultStepCount(rise, runLength);
const base: Stair = {
id: uniqueId("ST"),
type: "stair",
floorId: ctx.level.id,
categoryCode: stairCategory(ctx),
shape: s.shape,
start: s.start,
dir,
runLength,
width: s.width,
totalRise: rise,
stepCount: steps,
up: s.up,
};
if (s.shape === "L") {
base.run2Length = runLength; // symmetrischer zweiter Lauf
base.turn = 1;
} else if (s.shape === "spiral") {
base.center = s.start;
base.radius = dist;
base.sweep = 270;
// Startpunkt am äußeren Rand: Lauflinie beginnt hier.
base.start = s.cursor;
base.dir = dir;
}
return base;
}
/** Vorschau: Tritt-Linien + Lauflinie + HUD (Länge · Stufenanzahl). */
function runDraft(s: StairRun, ctx: CommandContext): ToolDraft {
const stair = buildStair(s, ctx);
const rise = stair.totalRise ?? floorRise(ctx);
const geo = stairGeometry(stair, rise);
const preview: DraftShape[] = [];
for (const tr of geo.treads) preview.push({ kind: "poly", pts: tr.pts, closed: true });
if (geo.landing) preview.push({ kind: "poly", pts: geo.landing, closed: true });
if (geo.runLine.length >= 2) preview.push({ kind: "poly", pts: geo.runLine, closed: false });
const draft: ToolDraft = { preview, vertices: [s.start] };
draft.hud = {
at: s.cursor,
text: `${lenOf(sub(s.cursor, s.start)).toFixed(2)} m · ${stair.stepCount} STG`,
};
return draft;
}
/** Hängt eine Treppe ans Projekt (immutabel). */
function appendStair(p: Project, s: StairRun, ctx: CommandContext): Project {
const stair = buildStair(s, ctx);
const stairs = p.stairs ? [...p.stairs, stair] : [stair];
return { ...p, stairs };
}
const idle = (from?: StairCmdState): [CommandState, CommandResult] => [
initState(from?.shape ?? "straight"),
{ draft: null, done: true },
];
export const stairCommand: Command = {
name: "stair",
labelKey: "cmd.stair.label",
floorOnly: true,
prompt: (s) => ((s as StairCmdState).phase === "run" ? "cmd.stair.run" : "cmd.stair.start"),
accepts: (s) =>
(s as StairCmdState).phase === "run"
? ["point", "number", "option"]
: ["point", "option"],
options: (s): CmdOption[] => {
const st = s as StairCmdState;
return [
{ ...SHAPE, value: st.shape },
{ ...UPDOWN, value: st.up ? "up" : "down" },
];
},
init: (): StairIdle => initState("straight"),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as StairCmdState;
if (input.kind === "option") {
const next = { ...s } as StairCmdState;
if (input.id === "shape") {
next.shape = s.shape === "straight" ? "L" : s.shape === "L" ? "spiral" : "straight";
} else if (input.id === "updown") {
next.up = !s.up;
}
return [next, { draft: next.phase === "run" ? runDraft(next as StairRun, ctx) : null }];
}
if (input.kind !== "point") {
return [s, { draft: s.phase === "run" ? runDraft(s as StairRun, ctx) : null }];
}
const pt = input.point;
if (s.phase !== "run") {
const ns: StairRun = {
phase: "run",
lastPoint: pt,
shape: s.shape,
up: s.up,
width: s.width,
steps: s.steps,
rise: s.rise,
start: pt,
cursor: pt,
};
return [ns, { draft: null }];
}
// Zweiter Punkt: Lauf definieren + committen (Null-Strecke ignorieren).
if (lenOf(sub(pt, s.start)) < 0.05) {
return [{ ...s }, { draft: runDraft(s, ctx) }];
}
const ns: StairRun = { ...s, cursor: pt };
return [
idle(s)[0],
{ draft: null, done: true, commit: (p) => appendStair(p, ns, ctx) },
];
},
onMove: (state, point, _snap, ctx): [CommandState, CommandResult] => {
const s = state as StairCmdState;
if (s.phase !== "run") return [s, { draft: null }];
const ns: StairRun = { ...s, cursor: point };
return [ns, { draft: runDraft(ns, ctx) }];
},
onConfirm: (state, ctx): [CommandState, CommandResult] => {
const s = state as StairCmdState;
if (s.phase === "run" && lenOf(sub(s.cursor, s.start)) >= 0.05) {
const ns = s;
return [
idle(s)[0],
{ draft: null, done: true, commit: (p) => appendStair(p, ns, ctx) },
];
}
return idle(s);
},
onCancel: (state): [CommandState, CommandResult] => idle(state as StairCmdState),
fields: (state) => ((state as StairCmdState).phase === "run" ? RUN_FIELDS : []),
// Getippte Tab-Felder sticky in den State übernehmen; kein Koordinatenpunkt.
pointFromFields: (state, locks) => {
const s = state as StairCmdState;
if ("width" in locks && locks.width > 0) s.width = locks.width;
if ("steps" in locks && locks.steps >= 2) s.steps = Math.round(locks.steps);
if ("rise" in locks && locks.rise > 0) s.rise = locks.rise;
return null;
},
fieldValues: (state, _locks, _cursor): Record<string, number> => {
const s = state as StairCmdState;
const out: Record<string, number> = { width: s.width };
if (s.steps != null) out.steps = s.steps;
if (s.rise != null) out.rise = s.rise;
return out;
},
};
+242
View File
@@ -0,0 +1,242 @@
// Trim — Quick-Trim („stutzen") für 2D-Kurven (line/polyline/rect), Rhino/
// AutoCAD-artig (docs §2.10/§3.4 Tier-1). Modell:
// • Cutters sind IMPLIZIT alle ANDEREN Drawing2D-Elemente der aktiven Ebene
// (Quick-Trim — kein separater Cutter-Auswahlschritt; üblich + einfacher).
// • Ein Schritt: der Nutzer klickt auf den WEGZUSCHNEIDENDEN Teil einer Kurve.
// Wir bestimmen das getroffene Element (Nähe), zerlegen es an seinen
// Schnittpunkten mit allen Cuttern und ENTFERNEN den angeklickten Abschnitt
// (zwischen den beiden nächstgelegenen Schnittpunkten um den Klick — bzw. bis
// zum Element-Ende, wenn nur einseitig ein Schnitt liegt).
// • Wiederholend, bis Esc/Enter.
//
// Linie → wird gekürzt/aufgeteilt; Polylinie → der getroffene Abschnitt fällt
// raus (kann die Polylinie in Stücke teilen). Reine Geometrie liegt im Kernel
// (`trimPolyline`); hier nur Treffer-Bestimmung, Drawing2D ↔ {pts,closed} und der
// Commit. Wände sind ausgenommen (nur Drawing2D).
//
// Bezeichner englisch, Kommentare/UI deutsch (CONVENTIONS.md).
import type { Drawing2D, Drawing2DGeom } from "../../model/types";
import { uniqueId } from "../../tools/types";
import {
pointSegmentDistance,
polylineEdges,
trimPolyline,
vecEqual,
} from "../../geometry/kernel2d";
import type {
Command,
CommandResult,
CommandState,
DraftShape,
Project,
Vec2,
} from "../types";
const HIT_TOL = 0.25; // Modell-Meter: Pick-Toleranz für die Kurvenwahl
// ── Kurven-Abstraktion: line/polyline/rect → Punktliste + closed ──────────────
interface Curve {
pts: Vec2[];
closed: boolean;
}
/** Wandelt eine trim-fähige 2D-Form in eine Punktliste (+closed) oder null. */
function geomToCurve(g: Drawing2DGeom): Curve | null {
if (g.shape === "line") return { pts: [g.a, g.b], closed: false };
if (g.shape === "polyline") return { pts: g.pts, closed: g.closed };
if (g.shape === "rect") {
return {
pts: [
g.min,
{ x: g.max.x, y: g.min.y },
g.max,
{ x: g.min.x, y: g.max.y },
],
closed: true,
};
}
return null; // circle/arc/text: hier nicht trim-fähig (offener Punkt)
}
/** Ist diese Form trim-fähig (line/polyline/rect)? */
function isTrimmable(d: Drawing2D): boolean {
return (
d.geom.shape === "line" ||
d.geom.shape === "polyline" ||
d.geom.shape === "rect"
);
}
/** Nächstes trim-fähiges Drawing2D der Ebene zum Klickpunkt (innerhalb Toleranz). */
function pickCurveAt(
project: Project,
levelId: string,
pt: Vec2,
): Drawing2D | null {
let best: Drawing2D | null = null;
let bestD = HIT_TOL;
for (const d of project.drawings2d) {
if (d.levelId !== levelId) continue;
if (!isTrimmable(d)) continue;
const curve = geomToCurve(d.geom);
if (!curve) continue;
for (const [a, b] of polylineEdges(curve.pts, curve.closed)) {
const dd = pointSegmentDistance(pt, a, b);
if (dd < bestD) {
bestD = dd;
best = d;
}
}
}
return best;
}
/** Alle anderen trim-fähigen Drawing2D der Ebene als Cutter-Kurven. */
function cuttersFor(
project: Project,
levelId: string,
exceptId: string,
): Curve[] {
const out: Curve[] = [];
for (const d of project.drawings2d) {
if (d.id === exceptId) continue;
if (d.levelId !== levelId) continue;
const curve = geomToCurve(d.geom);
if (curve) out.push(curve);
}
return out;
}
/** Sind zwei Ketten geometrisch identisch (gleiches closed + gleiche Punkte)? */
function sameChain(a: { pts: Vec2[]; closed: boolean }, b: Curve): boolean {
if (a.closed !== b.closed || a.pts.length !== b.pts.length) return false;
for (let i = 0; i < a.pts.length; i++) {
if (!vecEqual(a.pts[i], b.pts[i])) return false;
}
return true;
}
/** Eine getrimmte Rest-Kette als passende Drawing2D-Geometrie. */
function chainToGeom(chain: { pts: Vec2[]; closed: boolean }): Drawing2DGeom {
if (chain.pts.length === 2 && !chain.closed) {
return { shape: "line", a: chain.pts[0], b: chain.pts[1] };
}
return { shape: "polyline", pts: chain.pts, closed: chain.closed };
}
/**
* Wendet einen Trim auf das Projekt an: ersetzt das getroffene Element durch
* seine Rest-Ketten (Attribute geerbt). Bleibt nichts übrig, fällt es weg.
*/
function applyTrim(
p: Project,
target: Drawing2D,
pick: Vec2,
): Project {
const curve = geomToCurve(target.geom);
if (!curve) return p;
const cutters = cuttersFor(p, target.levelId, target.id).map((c) => ({
pts: c.pts,
closed: c.closed,
}));
const rest = trimPolyline(curve.pts, curve.closed, cutters, pick);
// Echtes No-op: genau eine Kette, identisch (gleiche Punkte) zur Quelle →
// nichts wurde weggeschnitten (kein Schnitt mit Cuttern).
if (rest.length === 1 && sameChain(rest[0], curve)) return p;
const replacements: Drawing2D[] = rest
.filter((c) => c.pts.length >= 2)
.map((c) => ({ ...target, id: uniqueId("dr2d"), geom: chainToGeom(c) }));
const others = p.drawings2d.filter((d) => d.id !== target.id);
return { ...p, drawings2d: [...others, ...replacements] };
}
/** Vorschau-Formen der NACH dem Trim verbleibenden Ketten (für den Draft). */
function trimPreview(
project: Project,
target: Drawing2D,
pick: Vec2,
): DraftShape[] {
const curve = geomToCurve(target.geom);
if (!curve) return [];
const cutters = cuttersFor(project, target.levelId, target.id).map((c) => ({
pts: c.pts,
closed: c.closed,
}));
const rest = trimPolyline(curve.pts, curve.closed, cutters, pick);
const out: DraftShape[] = [];
for (const c of rest) {
if (c.pts.length < 2) continue;
if (c.pts.length === 2 && !c.closed) {
out.push({ kind: "line", a: c.pts[0], b: c.pts[1] });
} else {
out.push({ kind: "poly", pts: c.pts, closed: c.closed });
}
}
return out;
}
// ── Zustand ───────────────────────────────────────────────────────────────────
// Einziger Schritt „pick": auf den wegzuschneidenden Abschnitt klicken;
// wiederholend, bis Esc/Enter. „done": beendet.
interface TrimPick extends CommandState {
phase: "pick";
}
interface TrimDone extends CommandState {
phase: "done";
}
type TrimState = TrimPick | TrimDone;
const finish = (): [CommandState, CommandResult] => [
{ phase: "done", lastPoint: null } as TrimDone,
{ draft: null, done: true },
];
export const trimCommand: Command = {
name: "trim",
labelKey: "cmd.trim.label",
prompt: () => "cmd.trim.pick",
accepts: () => ["point"],
options: () => [],
init: (): TrimPick => ({ phase: "pick", lastPoint: null }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as TrimState;
if (s.phase === "done") return finish();
if (input.kind !== "point") return [s, { draft: null }];
const target = pickCurveAt(ctx.project, ctx.level.id, input.point);
if (!target) {
// Daneben geklickt → Befehl bleibt aktiv (weiter trimmen).
return [s, { draft: null }];
}
const pick = input.point;
// Wiederholend: NICHT done — nach dem Commit bleibt „pick" aktiv.
return [
s,
{ draft: null, commit: (p) => applyTrim(p, target, pick) },
];
},
onMove: (state, point, _snap, ctx): [CommandState, CommandResult] => {
const s = state as TrimState;
if (s.phase !== "pick") return [s, { draft: null }];
const target = pickCurveAt(ctx.project, ctx.level.id, point);
if (!target) return [s, { draft: null }];
const preview = trimPreview(ctx.project, target, point);
return [
s,
{
draft: {
preview,
vertices: [],
hud: { at: point, text: "✂" },
},
},
];
},
onConfirm: (): [CommandState, CommandResult] => finish(),
onCancel: (): [CommandState, CommandResult] => finish(),
};
+350
View File
@@ -0,0 +1,350 @@
// Wall — Wand als echter Engine-Befehl (analog polyline, aber je Achssegment
// entsteht ein eigenständiges `Wall`). Schritte:
// 1) „Startpunkt der Wand:" → Punkt
// 2) „Nächster Punkt ( Zurück ):" → Punkt … (chainend: jede weitere Wand
// beginnt am letzten Endpunkt) bis Esc/Enter den Zug beendet.
//
// Akzeptiert je Punkt Maus-Picks UND getippte Koordinaten (0,0 · r3,0 · 5<45) —
// derselbe Eingabepfad wie Linie/Polylinie. Die fertige Wand rendert über die
// bestehende Wand-Darstellung (generatePlan / 3D); der Draft zeigt die Achse +
// eine einfache Band-Vorschau in der Dicke des aktiven Wandtyps.
//
// Herkunft der Wand-Felder (CommandContext):
// • wallTypeId ← ctx.activeWallTypeId (Fallback: project.wallTypes[0].id)
// • height ← aktives Geschoss (ctx.level.floorHeight, sonst 2.6 m)
// • floorId ← ctx.level.id (aktives Geschoss)
// • categoryCode← ctx.defaultCategoryCode (aktive Kategorie, i. d. R. „20")
import type { Wall, WallType } from "../../model/types";
import { wallTypeThickness } from "../../model/types";
import { wallCorners } from "../../model/geometry";
import { uniqueId } from "../../tools/types";
import type {
Command,
CommandContext,
CommandField,
CommandResult,
CommandState,
CmdOption,
DraftShape,
Project,
ToolDraft,
Vec2,
} from "../types";
const EPS = 1e-6;
const DEG = Math.PI / 180;
const DEFAULT_HEIGHT = 2.6; // Fallback-Wandhöhe (Meter), wenn das Geschoss keine hat
const DEFAULT_THICKNESS = 0.2; // Fallback-Banddicke für die Vorschau (Meter)
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;
const MIN_THICKNESS = 0.01; // kleinste sinnvolle Wanddicke (Meter)
/** Tab-Felder je weiteren Punkt: Länge + Winkel relativ zum letzten Punkt. */
const WALL_FIELDS: CommandField[] = [
{ id: "length", labelKey: "cmd.field.length" },
{ id: "angle", labelKey: "cmd.field.angle" },
];
/** Zusätzliches Dicke-Feld (nur bei einschichtigen Wandtypen). */
const THICK_FIELD: CommandField = { id: "thick", labelKey: "cmd.field.thickness" };
/** Nächster Punkt aus gelockten Feldern (length/angle) + Cursor, relativ zu `last`. */
function wallNextFromFields(last: Vec2, locks: Record<string, number>, cursor: Vec2 | null): Vec2 {
const ref = cursor ?? last;
const length = "length" in locks ? locks.length : segLen(last, ref);
const angleDeg = "angle" in locks ? locks.angle : segAngleDeg(last, ref);
const ang = angleDeg * DEG;
return { x: last.x + Math.cos(ang) * length, y: last.y + Math.sin(ang) * length };
}
/**
* Dicke des aktiven Wandtyps (für die Band-Vorschau). Liest `ctx.activeWallTypeId`;
* fehlt der Typ, Fallback auf den ersten Projekt-Wandtyp bzw. DEFAULT_THICKNESS.
*/
function activeWallThickness(ctx: CommandContext): number {
const wt =
ctx.project.wallTypes.find((t) => t.id === ctx.activeWallTypeId) ??
ctx.project.wallTypes[0];
return wt ? wallTypeThickness(wt) : DEFAULT_THICKNESS;
}
/** Aktives Wandtyp-Objekt (Fallback: erster Projekt-Wandtyp). */
function activeWallType(ctx: CommandContext): WallType | undefined {
return (
ctx.project.wallTypes.find((t) => t.id === ctx.activeWallTypeId) ??
ctx.project.wallTypes[0]
);
}
/** Aktiver Wandtyp-ID; Fallback auf den ersten Projekt-Wandtyp. */
function activeWallTypeId(ctx: CommandContext): string {
const wt = activeWallType(ctx);
return wt ? wt.id : ctx.activeWallTypeId;
}
/** Einschichtig? Nur dann ist das Dicke-Feld sinnvoll (überschreibt die Schicht). */
function isSingleLayer(ctx: CommandContext): boolean {
const wt = activeWallType(ctx);
return !!wt && wt.layers.length === 1;
}
/**
* Liefert die effektive Vorschau-/Commit-Dicke: eine gelockte `thick`-Eingabe
* (nur bei einschichtigen Typen) überschreibt die Wandtyp-Dicke.
*/
function effectiveThickness(ctx: CommandContext, override: number | null): number {
if (override != null && override >= MIN_THICKNESS && isSingleLayer(ctx)) return override;
return activeWallThickness(ctx);
}
/**
* Findet einen einschichtigen Wandtyp mit gegebener Dicke (gleiches Bauteil wie
* `base`) oder erzeugt ihn. Gibt das (evtl. erweiterte) Projekt + die Typ-ID
* zurück — so bleibt die Plan-/3D-Darstellung unverändert (sie liest die Dicke
* aus den Schichten des Wandtyps).
*/
function wallTypeForThickness(
p: Project,
base: WallType,
thickness: number,
): { project: Project; wallTypeId: string } {
const componentId = base.layers[0].componentId;
const near = (a: number, b: number) => Math.abs(a - b) < 1e-4;
const existing = p.wallTypes.find(
(t) =>
t.layers.length === 1 &&
t.layers[0].componentId === componentId &&
near(t.layers[0].thickness, thickness),
);
if (existing) return { project: p, wallTypeId: existing.id };
const nt: WallType = {
id: uniqueId("WT"),
name: `${base.name} ${(thickness * 100).toFixed(0)} cm`,
layers: [{ componentId, thickness }],
};
return { project: { ...p, wallTypes: [...p.wallTypes, nt] }, wallTypeId: nt.id };
}
interface WallIdle extends CommandState {
phase: "start";
/** Sticky Dicke-Übersteuerung (nur einschichtig); null = Wandtyp-Dicke. */
thickOverride: number | null;
/** Ist der aktive Wandtyp einschichtig? (aus ctx in onMove/onInput gesetzt) */
single: boolean;
}
interface WallDrawing extends CommandState {
phase: "next";
points: Vec2[];
cursor: Vec2 | null;
/** Sticky Dicke-Übersteuerung (nur einschichtig); null = Wandtyp-Dicke. */
thickOverride: number | null;
/** Ist der aktive Wandtyp einschichtig? (aus ctx in onMove/onInput gesetzt) */
single: boolean;
}
type WallCmdState = WallIdle | WallDrawing;
const UNDO: CmdOption = { id: "undo", labelKey: "cmd.polyline.undo" };
/**
* Vorschau: Achs-Polylinie + Band-Vorschau je Segment (Dicke = aktiver Wandtyp) +
* HUD (Länge·Winkel des lebenden Segments). Die FERTIGE Wand rendert über die
* bestehende Wand-Darstellung — hier ist nur der Rubber-Band-Entwurf.
*/
function wallDraft(points: Vec2[], cursor: Vec2 | null, thickness: number): ToolDraft {
const all = cursor ? [...points, cursor] : points;
const preview: DraftShape[] = [];
for (let i = 0; i < all.length - 1; i++) {
const a = all[i];
const b = all[i + 1];
if (segLen(a, b) < EPS) continue;
preview.push({ kind: "poly", pts: wallCorners(a, b, thickness), closed: true });
preview.push({ kind: "line", a, b }); // Achse
}
const draft: ToolDraft = { preview, vertices: points };
const last = points[points.length - 1];
if (cursor && last && segLen(last, cursor) >= EPS) {
draft.hud = {
at: cursor,
text: `${segLen(last, cursor).toFixed(2)} m · ${Math.abs(
(segAngleDeg(last, cursor) + 360) % 360,
).toFixed(0)}°`,
};
}
return draft;
}
/**
* Hängt je Achssegment ein `Wall` des aktiven Typs ans Projekt (immutabel). Höhe
* aus dem aktiven Geschoss (floorHeight), Geschoss + Kategorie + Wandtyp aus dem
* Context. Null-Strecken werden übersprungen. Gehrung an Ecken entsteht später
* automatisch aus computeJoins (generatePlan).
*/
function appendWalls(
p: Project,
pts: Vec2[],
ctx: CommandContext,
thickOverride: number | null,
): Project {
const height =
ctx.level.floorHeight && ctx.level.floorHeight > 0 ? ctx.level.floorHeight : DEFAULT_HEIGHT;
// Dicke-Übersteuerung (nur einschichtig): passenden Wandtyp finden/erzeugen,
// damit Plan/3D — die die Dicke aus dem Wandtyp lesen — korrekt darstellen.
let project = p;
let wallTypeId = activeWallTypeId(ctx);
const base = activeWallType(ctx);
if (
base &&
base.layers.length === 1 &&
thickOverride != null &&
thickOverride >= MIN_THICKNESS &&
Math.abs(thickOverride - wallTypeThickness(base)) > 1e-4
) {
const r = wallTypeForThickness(project, base, thickOverride);
project = r.project;
wallTypeId = r.wallTypeId;
}
const newWalls: Wall[] = [];
for (let i = 0; i < pts.length - 1; i++) {
const a = pts[i];
const b = pts[i + 1];
if (segLen(a, b) < EPS) continue;
newWalls.push({
id: uniqueId("W"),
type: "wall",
floorId: ctx.level.id,
categoryCode: ctx.defaultCategoryCode,
start: a,
end: b,
wallTypeId,
height,
});
}
if (newWalls.length === 0) return p;
return { ...project, walls: [...project.walls, ...newWalls] };
}
const idle = (
thickOverride: number | null = null,
single = false,
): [CommandState, CommandResult] => [
{ phase: "start", lastPoint: null, thickOverride, single } as WallIdle,
{ draft: null, done: true },
];
export const wallCommand: Command = {
name: "wall",
labelKey: "cmd.wall.label",
// Wände leben auf Geschossen — wie das Legacy-Werkzeug nur dort aktiv.
floorOnly: true,
prompt: (s) => ((s as WallCmdState).phase === "next" ? "cmd.wall.next" : "cmd.wall.start"),
accepts: (s) =>
(s as WallCmdState).phase === "next" ? ["point", "number", "option"] : ["point", "number"],
options: (s) => {
const ps = s as WallCmdState;
return ps.phase === "next" ? [UNDO] : [];
},
init: (): WallIdle => ({ phase: "start", lastPoint: null, thickOverride: null, single: false }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as WallCmdState;
const thickness = effectiveThickness(ctx, s.thickOverride);
const to = s.thickOverride;
const single = isSingleLayer(ctx);
if (input.kind === "option") {
if (s.phase !== "next") return [s, { draft: null }];
if (input.id === "undo") {
const pts = s.points.slice(0, -1);
if (pts.length === 0)
return [{ phase: "start", lastPoint: null, thickOverride: to, single } as WallIdle, { draft: null }];
const ns: WallDrawing = {
phase: "next",
points: pts,
cursor: s.cursor,
lastPoint: pts[pts.length - 1],
thickOverride: to,
single,
};
return [ns, { draft: wallDraft(pts, s.cursor, thickness) }];
}
return [{ ...s, single } as WallCmdState, { draft: wallDraft(s.points, s.cursor, thickness) }];
}
if (input.kind !== "point") {
return [{ ...s, single } as WallCmdState, { draft: s.phase === "next" ? wallDraft(s.points, s.cursor, thickness) : null }];
}
const pt = input.point;
if (s.phase !== "next") {
const ns: WallDrawing = { phase: "next", points: [pt], cursor: pt, lastPoint: pt, thickOverride: to, single };
return [ns, { draft: wallDraft([pt], pt, thickness) }];
}
// Chaining: jede weitere Wand beginnt am letzten Endpunkt. Null-Strecke
// (Klick auf denselben Punkt) ignorieren — nicht abbrechen.
const last = s.points[s.points.length - 1];
if (segLen(last, pt) < EPS) {
return [{ ...s, single } as WallCmdState, { draft: wallDraft(s.points, s.cursor, thickness) }];
}
const points = [...s.points, pt];
const ns: WallDrawing = { phase: "next", points, cursor: pt, lastPoint: pt, thickOverride: to, single };
return [ns, { draft: wallDraft(points, pt, thickness) }];
},
onMove: (state, point, _snap, ctx): [CommandState, CommandResult] => {
const s = state as WallCmdState;
if (s.phase !== "next") return [s, { draft: null }];
const ns: WallDrawing = { ...s, cursor: point, single: isSingleLayer(ctx) };
return [ns, { draft: wallDraft(s.points, point, effectiveThickness(ctx, s.thickOverride)) }];
},
// Enter/Space/Rechtsklick: offenen Zug beenden (≥2 Punkte) und committen.
onConfirm: (state, ctx): [CommandState, CommandResult] => {
const s = state as WallCmdState;
if (s.phase === "next" && s.points.length >= 2) {
const pts = s.points;
const to = s.thickOverride;
return [
{ phase: "start", lastPoint: null, thickOverride: to, single: s.single } as WallIdle,
{ draft: null, done: true, commit: (p) => appendWalls(p, pts, ctx, to) },
];
}
return idle(s.thickOverride, s.single);
},
onCancel: (state): [CommandState, CommandResult] => {
const s = state as WallCmdState;
return idle(s.thickOverride, s.single);
},
// Tab-Feld-Zyklus im „next"-Schritt: Länge + Winkel relativ zum letzten Punkt.
// Bei EINSCHICHTIGEN Wandtypen zusätzlich ein Dicke-Feld (überschreibt die
// Bandbreite live + auf Commit). Mehrschichtige Typen: kein Dicke-Feld
// (der aktive Wandtyp wird in onMove/onInput aus dem Context ermittelt).
fields: (state) => {
const s = state as WallCmdState;
if (s.phase !== "next") return [];
return s.single ? [...WALL_FIELDS, THICK_FIELD] : WALL_FIELDS;
},
pointFromFields: (state, locks, cursor) => {
const s = state as WallCmdState;
// Gelockte Dicke sticky in den State übernehmen (kein Koordinatenfeld):
// die Engine ruft diese Funktion bei jeder Maus-/Pick-/Enter-Aktion, bevor
// sie den Punkt an onInput reicht — so kennt der Commit die Dicke.
if ("thick" in locks && locks.thick >= MIN_THICKNESS) s.thickOverride = locks.thick;
if (s.phase !== "next" || s.points.length === 0) return null;
return wallNextFromFields(s.points[s.points.length - 1], locks, cursor);
},
fieldValues: (state, locks, cursor): Record<string, number> => {
const s = state as WallCmdState;
if (s.phase !== "next") return {};
const out: Record<string, number> = {};
// Dicke-Feld: gelockt → Lock, sonst die aktuelle (sticky) Übersteuerung.
if ("thick" in locks) out.thick = locks.thick;
else if (s.thickOverride != null) out.thick = s.thickOverride;
if (s.points.length === 0 || !cursor) return out;
const last = s.points[s.points.length - 1];
out.length = segLen(last, cursor);
out.angle = ((segAngleDeg(last, cursor) % 360) + 360) % 360;
return out;
},
};
+65 -21
View File
@@ -29,11 +29,21 @@ import type {
* Brücke zur App: liefert den Live-Kontext + Snapping und wendet die Ergebnisse
* an (Vorschau zeigen, committen). So bleibt die Engine frei von React/Store.
*/
/** Modifikatoren (Shift=Ortho, Ctrl=Snap aus) beim Snap eines Befehlsschritts. */
export interface EngineMods {
shift: boolean;
ctrl: boolean;
}
export interface EngineHost {
/** Aktueller Befehls-Kontext (Projekt, aktive Ebene/Kategorie, lastPoint). */
context(lastPoint: Vec2 | null): CommandContext;
/** Snap für einen rohen Cursor-Punkt (oder null), inkl. der Draft-Punkte. */
snap(raw: Vec2, draftPoints: Vec2[]): SnapResult | null;
/**
* Snap für einen rohen Cursor-Punkt (oder null), inkl. der Draft-Punkte. `mods`
* trägt Shift (Ortho) / Ctrl (Snap aus) durch — auch der ERSTE Punkt (ohne
* Draft) wird so gesnappt bzw. per Ctrl unterdrückt.
*/
snap(raw: Vec2, draftPoints: Vec2[], mods?: EngineMods): SnapResult | null;
/** Aktuelle Pixel/Meter-Skala (für Snap-Toleranz). */
pxPerMeter(): number;
/** Vorschau setzen (oder löschen). */
@@ -57,10 +67,17 @@ export interface EngineView {
/**
* Geordnete Zahlenfelder des aktuellen Schritts (Tab-Feld-Zyklus §2.7). Leer,
* wenn der aktive Befehl/Schritt keine Felder anbietet. `value` ist der
* gelockte Wert (oder null = folgt der Maus), `active` markiert das aktuelle
* Tab-Ziel.
* gelockte Wert ODER — wenn ungelockt — der LIVE-Wert unter der Maus (oder null,
* wenn (noch) keiner bestimmbar ist). `locked` unterscheidet beide für die UI;
* `active` markiert das aktuelle Tab-Ziel.
*/
fields: { id: string; labelKey: string; value: number | null; active: boolean }[];
fields: {
id: string;
labelKey: string;
value: number | null;
locked: boolean;
active: boolean;
}[];
}
export class CommandEngine {
@@ -104,12 +121,26 @@ export class CommandEngine {
}
this.syncFields();
const defs = this.currentFields();
const fields = defs.map((f, i) => ({
id: f.id,
labelKey: f.labelKey as string,
value: f.id in this.locks ? this.locks[f.id] : null,
active: i === this.activeFieldIndex,
}));
// Live-Werte (Maus-Vorschau) für ungelockte Felder, falls der Befehl sie
// liefert — so zeigt die Befehlszeile beim Zeichnen die folgende Länge/Winkel.
const live = this.command.fieldValues
? this.command.fieldValues(this.state, this.locks, this.lastCursor)
: {};
const fields = defs.map((f, i) => {
const locked = f.id in this.locks;
const value = locked
? this.locks[f.id]
: f.id in live
? live[f.id]
: null;
return {
id: f.id,
labelKey: f.labelKey as string,
value,
locked,
active: i === this.activeFieldIndex,
};
});
return {
active: true,
promptKey: this.command.prompt(this.state),
@@ -224,6 +255,13 @@ export class CommandEngine {
this.lastCursor = null;
this.resetFields();
this.host.setDraft(null);
// Sofort-Befehl (Join/Split u. Ä.): direkt ausführen, ohne auf einen Pick zu
// warten. So sind Tastenkürzel und getippter Befehl EIN Pfad (§Task C).
if (cmd.autoRun) {
const res = cmd.autoRun(this.context());
this.applyResult(res);
return true;
}
this.emit();
return true;
}
@@ -324,10 +362,10 @@ export class CommandEngine {
}
/** Maus-Pick aus PlanView (linker Klick) — gesnappter Modellpunkt. */
pick(raw: Vec2): void {
pick(raw: Vec2, mods?: EngineMods): void {
if (!this.command || !this.state) return;
if (!this.command.accepts(this.state).includes("point")) return;
const snap = this.host.snap(raw, this.draftPoints());
const snap = this.host.snap(raw, this.draftPoints(), mods);
const point = snap?.point ?? raw;
this.lastCursor = point;
// Tab-Feld-Modus: gelockte Felder überschreiben den Klick (z. B. Länge fix,
@@ -341,9 +379,9 @@ export class CommandEngine {
}
/** Hover/Drag aus PlanView — nur Vorschau (kein Commit). */
move(raw: Vec2): void {
move(raw: Vec2, mods?: EngineMods): void {
if (!this.command || !this.state) return;
const snap = this.host.snap(raw, this.draftPoints());
const snap = this.host.snap(raw, this.draftPoints(), mods);
const point = snap?.point ?? raw;
this.lastCursor = point;
const ctx = this.context();
@@ -379,12 +417,18 @@ export class CommandEngine {
this.repeatLast();
return;
}
// Tab-Feld-Modus (§2.7): existieren Felder, committet Enter den Schritt mit
// dem aus (Locks + Cursor) gebildeten Punkt — statt den Befehl abzubrechen.
const fieldPt = this.fieldResultPoint(this.lastCursor);
if (fieldPt) {
this.feed({ kind: "point", point: fieldPt });
return;
// Tab-Feld-Modus (§2.7): ist ein Feld GELOCKT (getippte Länge/Winkel/Dicke),
// committet Enter den Schritt mit dem aus (Locks + Cursor) gebildeten Punkt —
// statt den Befehl zu beenden. OHNE Lock ist Enter/Rechtsklick das normale
// Bestätigen: mehrteilige Befehle (Wand/Polylinie) beenden ihren Zug über
// onConfirm (sonst feuerte die Feld-Auflösung einen Cursor-Punkt und der Zug
// liesse sich nie abschliessen).
if (Object.keys(this.locks).length > 0) {
const fieldPt = this.fieldResultPoint(this.lastCursor);
if (fieldPt) {
this.feed({ kind: "point", point: fieldPt });
return;
}
}
const ctx = this.context();
const [st, res] = this.command.onConfirm(this.state, ctx);
+35
View File
@@ -6,23 +6,41 @@
import type { Command } from "./types";
import { lineCommand } from "./cmds/line";
import { polylineCommand } from "./cmds/polyline";
import { wallCommand } from "./cmds/wall";
import { ceilingCommand } from "./cmds/ceiling";
import { openingCommand, fensterCommand, tuerCommand } from "./cmds/opening";
import { stairCommand } from "./cmds/stair";
import { roomCommand } from "./cmds/room";
import { rectCommand } from "./cmds/rect";
import { circleCommand } from "./cmds/circle";
import { moveCommand } from "./cmds/move";
import { mirrorCommand } from "./cmds/mirror";
import { joinCommand } from "./cmds/join";
import { copyCommand } from "./cmds/copy";
import { offsetCommand } from "./cmds/offset";
import { trimCommand } from "./cmds/trim";
import { importCommand } from "./cmds/import";
import { terrainCommand } from "./cmds/terrain";
/** Alle bekannten Befehle, Schlüssel = Befehlsname (lowercase). */
export const COMMANDS: Record<string, Command> = {
wall: wallCommand,
ceiling: ceilingCommand,
opening: openingCommand,
fenster: fensterCommand,
tuer: tuerCommand,
stair: stairCommand,
room: roomCommand,
line: lineCommand,
polyline: polylineCommand,
rect: rectCommand,
circle: circleCommand,
move: moveCommand,
mirror: mirrorCommand,
join: joinCommand,
copy: copyCommand,
offset: offsetCommand,
trim: trimCommand,
import: importCommand,
terrain: terrainCommand,
};
@@ -33,13 +51,30 @@ export const COMMANDS: Record<string, Command> = {
* relativ-Koordinaten-Präfix `r5,3`).
*/
export const ALIASES: Record<string, string> = {
w: "wall",
wand: "wall",
d: "ceiling",
decke: "ceiling",
f: "fenster",
t: "tuer",
tür: "tuer",
oe: "opening",
treppe: "stair",
tp: "stair",
raum: "room",
rm: "room",
l: "line",
pl: "polyline",
rec: "rect",
c: "circle",
m: "move",
s: "mirror",
spiegeln: "mirror",
j: "join",
verbinden: "join",
cp: "copy",
o: "offset",
tr: "trim",
imp: "import",
ter: "terrain",
};
+36
View File
@@ -60,6 +60,13 @@ export interface CmdOption {
export interface CommandSelection {
wallIds: string[];
drawingId: string | null;
/**
* ALLE gewählten 2D-Elemente (Mehrfachauswahl). `drawingId` bleibt das einzeln
* selektierte Element (Move/Copy/Mirror operieren darauf); Mengen-Aktionen wie
* Join/Split lesen `drawingIds`. Optional/abwärtskompatibel: fehlt es, gilt
* `drawingId` (falls gesetzt) als einziges Element.
*/
drawingIds?: string[];
}
/** Live-Kontext, den ein Befehl bei jedem Schritt erhält (analog ToolContext). */
@@ -71,6 +78,11 @@ export interface CommandContext {
defaultCategoryCode: string;
/** Aktiver Linienstil-Code für 2D-Primitive. */
activeLineStyleId: string;
/**
* Aktiver Wandtyp (für den `wall`-Befehl). Liefert Schichtaufbau + Dicke neuer
* Wände. Aus dem Store gefüllt (analog ToolContext.activeWallTypeId).
*/
activeWallTypeId: string;
/**
* Letzter gesetzter Punkt im laufenden Befehl (für `r`/polar-Auflösung); null
* am ersten Punkt.
@@ -158,6 +170,17 @@ export interface Command {
/** Esc: Entwurf verwerfen, Befehl beenden. */
onCancel(state: CommandState): [CommandState, CommandResult];
/**
* Sofort-Befehl (optional): läuft direkt beim Start OHNE Punkt-Picken und liefert
* sein Ergebnis (i. d. R. commit + done). Für Aktionen auf der bestehenden
* Auswahl (z. B. Join/Split), die keine interaktive Geste brauchen. Ist diese
* Methode gesetzt, ruft die Engine sie unmittelbar nach `init()` auf und der
* Befehl kehrt (bei done) sofort in den Ruhezustand zurück. So ist „Ctrl+J" und
* getipptes „join" derselbe Pfad. Fehlt sie, verhält sich der Befehl wie gehabt
* (interaktiv, Punkt-getrieben).
*/
autoRun?(ctx: CommandContext): CommandResult;
// ── Optionaler Tab-Feld-Zyklus (additiv; Befehle ohne diese Methoden
// verhalten sich exakt wie bisher) ──────────────────────────────────────
@@ -177,6 +200,19 @@ export interface Command {
locks: Record<string, number>,
cursor: Vec2 | null,
): Vec2 | null;
/**
* LIVE-Zahlenwerte je Feld für die aktuelle Cursor-Position (Maus-Vorschau in
* der Befehlszeile). Gelockte Felder behalten ihren Lock; ungelockte Felder
* folgen der Maus — dieser Wert zeigt, was das Feld GERADE annimmt. Liefert die
* Werte je Feld-ID; fehlt ein Feld (oder kein Cursor), zeigt die UI „—".
* Additiv: Befehle ohne diese Methode zeigen ungelockte Felder als „—".
*/
fieldValues?(
state: CommandState,
locks: Record<string, number>,
cursor: Vec2 | null,
): Record<string, number>;
}
/** Convenience-Re-Export für Befehls-Module. */
+101
View File
@@ -0,0 +1,101 @@
// Compute-Grenze: der EINE Einstiegspunkt für rechenintensive Operationen.
// Läuft die App unter Tauri, werden Ops an einen Rust-`#[tauri::command]`
// geroutet; sonst greift die bestehende TS-Implementierung. In dieser PoC ist
// nur `computeJoins` real an Rust angebunden — die übrigen Funktionen sind
// dünne Pass-throughs auf ihre TS-Impls (Grenze etabliert, Migration
// inkrementell).
import type { Project, Wall } from "../model/types";
import { getWallType, wallTypeThickness } from "../model/types";
import { wallReferenceOffset } from "../model/wall";
import { computeJoins as computeJoinsTS } from "../model/joins";
import type { WallCuts } from "../model/joins";
import type { Line } from "../model/geometry";
import { detectRooms as detectRoomsTS } from "../geometry/roomBoundary";
import type { WallSegment, DetectRoomsOptions } from "../geometry/roomBoundary";
import { parseDwg as parseDwgTS } from "../io/dwgParser";
import type { DxfImportResult } from "../io/dxfParser";
import type { Vec2 } from "../model/types";
// Tauri-invoke, wenn vorhanden — sonst null (Browser/kein Tauri). Dynamischer,
// GEGUARDETER Import, damit der Build ohne @tauri-apps/api grün bleibt.
async function tauriInvoke<T>(cmd: string, args: Record<string, unknown>): Promise<T | null> {
try {
// @ts-ignore optionales Paket
const mod = await import(/* @vite-ignore */ "@tauri-apps/api/core").catch(() => null);
if (!mod || typeof (mod as any).invoke !== "function") return null;
return await (mod as any).invoke(cmd, args) as T;
} catch {
return null;
}
}
/** Serialisierbare Wand-Repräsentation für den Rust-Kern. */
interface FlatWall {
id: string;
start: Vec2;
end: Vec2;
thickness: number;
referenceOffset: number;
}
/** Rohantwort des Rust-Befehls `compute_joins`. */
interface RustWallCut {
wallId: string;
startCut: Line | null;
endCut: Line | null;
}
/**
* Gehrungs-Schnittlinien pro Wand. Unter Tauri via Rust-Kern, sonst TS-Fallback.
* Die Signatur ist synchron-kompatibel zur TS-Impl, aber async (Rust-Aufruf).
*/
export async function computeJoins(
project: Project,
walls: Wall[],
): Promise<Map<string, WallCuts>> {
try {
const flattened: FlatWall[] = walls.map((w) => {
const thickness = wallTypeThickness(getWallType(project, w));
return {
id: w.id,
start: w.start,
end: w.end,
thickness,
referenceOffset: wallReferenceOffset(w, thickness),
};
});
const res = await tauriInvoke<RustWallCut[]>("compute_joins", {
input: { walls: flattened },
});
if (res != null) {
const map = new Map<string, WallCuts>();
for (const c of res) {
map.set(c.wallId, { startCut: c.startCut, endCut: c.endCut });
}
return map;
}
} catch {
// fällt unten in den TS-Pfad
}
console.warn("Rust compute_joins nicht verfügbar, TS-Fallback");
return computeJoinsTS(project, walls);
}
// ── Pass-throughs (noch reines TS; bereit für spätere Rust-Migration) ─────────
/** Raumerkennung aus Wandsegmenten. Aktuell TS; Grenze für spätere Migration. */
export async function detectRooms(
walls: WallSegment[],
options: DetectRoomsOptions = {},
): Promise<Vec2[][]> {
return detectRoomsTS(walls, options);
}
/** DWG-Import (LibreDWG-WASM). Aktuell TS; Grenze für spätere Migration. */
export async function parseShapeFromDwg(
data: ArrayBuffer,
): Promise<DxfImportResult> {
return parseDwgTS(data);
}
+88
View File
@@ -0,0 +1,88 @@
// TS<->Rust-Paritaet fuer compute_joins: dieselben Sample-Waende, flach gemacht
// wie in der Compute-Boundary, einmal durch die TS-Implementierung und einmal
// durch das Rust-geometry-Crate (examples/parity) — die Gehrungs-Schnittlinien
// muessen numerisch identisch sein. Belegt, dass der Rust-Port die Semantik
// erhaelt (Kernbeweis der Migration).
// @ts-ignore -- node:child_process ohne globales @types/node; im Vitest-Node-Lauf vorhanden
import { execFileSync } from "node:child_process";
import { describe, expect, it } from "vitest";
import { sampleProject } from "../model/sampleProject";
import { computeJoins as computeJoinsTS } from "../model/joins";
import type { WallCuts } from "../model/joins";
import type { Line } from "../model/geometry";
import { getWallType, wallTypeThickness } from "../model/types";
import { wallReferenceOffset } from "../model/wall";
const EPS = 1e-9;
/** Flach wie in src/compute/index.ts (Boundary) — Eingang fuer das Rust-Crate. */
function flatten() {
return sampleProject.walls.map((w) => {
const thickness = wallTypeThickness(getWallType(sampleProject, w));
return {
id: w.id,
start: w.start,
end: w.end,
thickness,
referenceOffset: wallReferenceOffset(w, thickness),
};
});
}
/** Rust-Ausgabe ueber das geometry-Beispiel (liest stdin-JSON, druckt stdout-JSON). */
function runRust(input: unknown): Map<string, WallCuts> {
const out = execFileSync(
"cargo",
[
"run",
"-q",
"--manifest-path",
"src-tauri/geometry/Cargo.toml",
"--example",
"parity",
],
{ input: JSON.stringify(input), encoding: "utf8", timeout: 180000 },
);
const arr = JSON.parse(out) as Array<{
wallId: string;
startCut: Line | null;
endCut: Line | null;
}>;
const map = new Map<string, WallCuts>();
for (const c of arr) map.set(c.wallId, { startCut: c.startCut, endCut: c.endCut });
return map;
}
function lineClose(a: Line | null, b: Line | null): boolean {
if (a === null || b === null) return a === b;
return (
Math.abs(a.point.x - b.point.x) < EPS &&
Math.abs(a.point.y - b.point.y) < EPS &&
Math.abs(a.dir.x - b.dir.x) < EPS &&
Math.abs(a.dir.y - b.dir.y) < EPS
);
}
describe("compute_joins TS<->Rust Paritaet", () => {
it("liefert fuer alle Sample-Waende identische Gehrungslinien", () => {
const walls = sampleProject.walls;
const ts = computeJoinsTS(sampleProject, walls);
const rust = runRust({ walls: flatten() });
expect(rust.size).toBe(ts.size);
// Mindestens eine Wand muss eine echte Gehrung haben (sonst prueft der Test nichts).
let cutsSeen = 0;
for (const w of walls) {
const t = ts.get(w.id)!;
const r = rust.get(w.id);
expect(r, `Wand ${w.id} fehlt in Rust-Ausgabe`).toBeDefined();
expect(lineClose(t.startCut, r!.startCut), `startCut ${w.id}`).toBe(true);
expect(lineClose(t.endCut, r!.endCut), `endCut ${w.id}`).toBe(true);
if (t.startCut) cutsSeen++;
if (t.endCut) cutsSeen++;
}
expect(cutsSeen, "keine einzige Gehrung im Sample — Test waere aussagelos").toBeGreaterThan(0);
});
});
+201
View File
@@ -0,0 +1,201 @@
// 2D-Boolean-Operationen (Union / Subtract / Intersect) für GESCHLOSSENE
// 2D-Zeichenelemente (Drawing2D). Ergänzt Split/Join (`splitJoin.ts`). Wandelt
// Drawing2D ↔ polygon-clipping-Format (Martinez-Algorithmus, robust) und liefert
// neue Drawing2D-Listen. Stil-, Kategorie- und Level-Attribute werden geerbt
// (vom ersten bzw. Basis-Element).
//
// Loch-Handling: Das Drawing2D-Modell kennt KEINE Innenringe (Löcher). Liefert
// polygon-clipping ein Polygon mit Innenringen, wird der Außenring als gefüllte
// Form emittiert und JEDER Innenring als SEPARATE, ungefüllte geschlossene
// Polylinie (Loch-Umriss). So geht keine Geometrie verloren und nichts crasht;
// der Lochrand bleibt sichtbar, ohne dass eine gefüllte Insel entsteht.
//
// Nur geschlossene Elemente (rect / polyline closed) zählen; offene Linien/
// Polylinien und Kreise/Bögen/Text werden ignoriert. Wände sind kein Drawing2D
// und damit ohnehin ausgenommen (No-op).
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import polygonClipping from "polygon-clipping";
import type { MultiPolygon, Polygon, Ring } from "polygon-clipping";
import type { Drawing2D, Drawing2DGeom, Vec2 } from "../model/types";
import { toPolyForm } from "./splitJoin";
import type { PolyForm } from "./splitJoin";
/** Ist die Geometrie eines Drawing2D eine GESCHLOSSENE Fläche (für Boolean)? */
export function isClosedArea(geom: Drawing2DGeom): boolean {
if (geom.shape === "rect") return true;
if (geom.shape === "polyline") return geom.closed && geom.pts.length >= 3;
return false;
}
/**
* Wandelt ein geschlossenes Drawing2D in ein polygon-clipping-`Polygon`
* (Ring-Liste). Reuse: `toPolyForm` aus splitJoin liefert die Stützpunkte. Der
* Ring wird als `[x,y]`-Paare gebildet; polygon-clipping schließt selbst
* (erster==letzter ist nicht erforderlich, aber unschädlich). Liefert `null`,
* wenn die Form nicht geschlossen ist oder zu wenige Punkte hat.
*/
export function drawingToPolygon(geom: Drawing2DGeom): Polygon | null {
if (!isClosedArea(geom)) return null;
const form = toPolyForm(geom);
if (!form) return null;
const ring = formToRing(form);
if (ring.length < 3) return null;
return [ring];
}
/** PolyForm → polygon-clipping-Ring (`[x,y]`-Paare). */
function formToRing(form: PolyForm): Ring {
return form.pts.map((p): [number, number] => [p.x, p.y]);
}
/** Ein polygon-clipping-Ring → Vec2-Punktliste (für fromPolyForm/Drawing2D). */
function ringToPts(ring: Ring): Vec2[] {
// polygon-clipping liefert geschlossene Ringe (letzter == erster Punkt). Den
// doppelten Schlusspunkt entfernen, damit unsere Polyline-Form sauber ist.
const pts = ring.map((p): Vec2 => ({ x: p[0], y: p[1] }));
if (
pts.length > 1 &&
pts[0].x === pts[pts.length - 1].x &&
pts[0].y === pts[pts.length - 1].y
) {
pts.pop();
}
return pts;
}
let counter = 0;
/** Eindeutige Drawing2D-ID (zeitbasiert + Zähler). */
function newId(prefix: string): string {
counter += 1;
return `${prefix}-${Date.now()}-${counter}`;
}
/**
* Erzeugt aus einem Punktring ein neues geschlossenes Drawing2D, das Stil/
* Kategorie/Level der Quelle erbt. `filled` steuert, ob Füllung/Schraffur
* mitwandern (Außenring = ja, Loch-Umriss = nein).
*/
function ringDrawing(src: Drawing2D, pts: Vec2[], filled: boolean): Drawing2D {
const out: Drawing2D = {
id: newId("bool"),
type: "drawing2d",
levelId: src.levelId,
categoryCode: src.categoryCode,
geom: { shape: "polyline", pts, closed: true },
};
if (src.lineStyleId !== undefined) out.lineStyleId = src.lineStyleId;
if (src.color !== undefined) out.color = src.color;
if (src.weightMm !== undefined) out.weightMm = src.weightMm;
if (filled) {
if (src.hatchId !== undefined) out.hatchId = src.hatchId;
if (src.fillColor !== undefined) out.fillColor = src.fillColor;
}
return out;
}
/**
* Wandelt eine polygon-clipping-`MultiPolygon` in Drawing2D[] zurück. Je Polygon
* wird der Außenring (filled) emittiert; jeder Innenring (Loch) als separater
* ungefüllter geschlossener Umriss. Quelle liefert Stil/Kategorie/Level.
*/
function multiPolygonToDrawings(
src: Drawing2D,
mp: MultiPolygon,
): Drawing2D[] {
const out: Drawing2D[] = [];
for (const poly of mp) {
poly.forEach((ring, i) => {
const pts = ringToPts(ring);
if (pts.length < 3) return;
// Erster Ring = Außenkontur (gefüllt), weitere = Löcher (nur Umriss).
out.push(ringDrawing(src, pts, i === 0));
});
}
return out;
}
/** Geschlossene Quellen → polygon-clipping-Polygone (mit Drawing-Zuordnung). */
function collectPolygons(
srcs: Drawing2D[],
): { poly: Polygon; src: Drawing2D }[] {
const out: { poly: Polygon; src: Drawing2D }[] = [];
for (const d of srcs) {
const poly = drawingToPolygon(d.geom);
if (poly) out.push({ poly, src: d });
}
return out;
}
/**
* Vereinigung (Union) der gegebenen geschlossenen Drawing2D. Liefert die neuen
* Drawing2D (≥1 — MultiPolygon → mehrere) und die IDs der ERSETZTEN Quellen.
* Stil/Kategorie/Level werden vom ersten geschlossenen Element geerbt. Weniger
* als 2 geschlossene Quellen → No-op (`{ created: [], replacedIds: [] }`).
*/
export function unionDrawings(srcs: Drawing2D[]): BooleanResult {
const polys = collectPolygons(srcs);
if (polys.length < 2) return EMPTY_RESULT;
const first = polys[0];
const rest = polys.slice(1).map((p) => p.poly);
const mp = polygonClipping.union(first.poly, ...rest);
return {
created: multiPolygonToDrawings(first.src, mp),
replacedIds: polys.map((p) => p.src.id),
};
}
/**
* Differenz (Subtract): das ERSTE geschlossene Element ist die Basis, alle
* übrigen werden abgezogen. Liefert neue Drawing2D + ersetzte IDs (nur die
* tatsächlich beteiligten geschlossenen Quellen). Stil/Kategorie/Level von der
* Basis. Weniger als 2 geschlossene Quellen → No-op.
*/
export function differenceDrawings(srcs: Drawing2D[]): BooleanResult {
const polys = collectPolygons(srcs);
if (polys.length < 2) return EMPTY_RESULT;
const base = polys[0];
const clips = polys.slice(1).map((p) => p.poly);
const mp = polygonClipping.difference(base.poly, ...clips);
return {
created: multiPolygonToDrawings(base.src, mp),
replacedIds: polys.map((p) => p.src.id),
};
}
/**
* Schnittmenge (Intersection): nur die gemeinsame Überlappung aller geschlossenen
* Elemente bleibt. Liefert neue Drawing2D + ersetzte IDs. Stil/Kategorie/Level
* vom ersten Element. Weniger als 2 geschlossene Quellen → No-op. Ohne
* Überlappung ist `created` leer (die Quellen werden trotzdem ersetzt = entfernt,
* weil der leere Schnitt das gewünschte Ergebnis ist).
*/
export function intersectionDrawings(srcs: Drawing2D[]): BooleanResult {
const polys = collectPolygons(srcs);
if (polys.length < 2) return EMPTY_RESULT;
const first = polys[0];
const rest = polys.slice(1).map((p) => p.poly);
const mp = polygonClipping.intersection(first.poly, ...rest);
return {
created: multiPolygonToDrawings(first.src, mp),
replacedIds: polys.map((p) => p.src.id),
};
}
/** Ergebnis einer Boolean-Operation: neue Elemente + zu ersetzende Quell-IDs. */
export interface BooleanResult {
/** Neu erzeugte Drawing2D (ersetzen die Quellen). */
created: Drawing2D[];
/** IDs der geschlossenen Quellen, die durch das Ergebnis ersetzt werden. */
replacedIds: string[];
}
const EMPTY_RESULT: BooleanResult = { created: [], replacedIds: [] };
/** Zählt geschlossene Flächen in einer Auswahl (für Menü-Aktivierung). */
export function countClosedAreas(srcs: Drawing2D[]): number {
let n = 0;
for (const d of srcs) if (isClosedArea(d.geom)) n++;
return n;
}
+1 -1
View File
@@ -3,7 +3,7 @@
// Kernel (`../geometry/kernel2d`) und liefern neue Drawing2D-Listen. Stil-,
// Kategorie- und Level-Attribute der Quelle werden geerbt.
//
// Bezeichner englisch, Kommentare deutsch (CLAUDE.md).
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import type { Drawing2D, Drawing2DGeom, Vec2 } from "../model/types";
import {
+280
View File
@@ -0,0 +1,280 @@
// Schlanker, handgeschriebener DXF-Writer (ASCII, AutoCAD R2000/AC1015-kompatibel).
// Erzeugt eine gültige Datei mit HEADER-, TABLES- (LAYER) und ENTITIES-Sektion und
// korrektem EOF. Geometrie wird in MODELL-SPACE in ECHTEN Metern geschrieben
// ($INSUNITS = 6 = Meter); nachgelagerte Nutzer skalieren beim Plot.
//
// Bewusst minimaler Funktionsumfang (LINE, LWPOLYLINE, CIRCLE, ARC, TEXT), aber mit
// korrekten Gruppen-Codes, damit AutoCAD/LibreCAD/andere Viewer die Datei sauber
// öffnen. Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
/** Ein Layer im DXF (Kategorie): Name, ACI-Farbindex, Linienstärke (mm × 100). */
export interface DxfLayer {
/** Layer-Name (keine Leerzeichen/Sonderzeichen → werden bereinigt). */
name: string;
/** AutoCAD Color Index (1..255). */
color: number;
/** True-Color (0xRRGGBB) für Viewer mit 24-Bit-Farbe; optional. */
trueColor?: number;
/** Linienstärke in 1/100 mm (DXF-LWEIGHT-Konvention), z. B. 25 = 0.25 mm. */
lineWeight: number;
}
/** 2D-Punkt (Meter, Modell-Space). */
export interface DxfPoint {
x: number;
y: number;
}
/**
* Sammelt Entities und Layer und serialisiert sie zu einem gültigen DXF-String.
* Alle Koordinaten sind Meter (Modell-Space). Der Y-Wert wird 1:1 übernommen
* (DXF-Y zeigt wie das Weltmodell nach oben — keine Spiegelung wie beim SVG).
*/
export class DxfWriter {
private layers = new Map<string, DxfLayer>();
private entities: string[] = [];
private handle = 0x100;
/** Nächstes eindeutiges Entity-Handle (Hex), fortlaufend. */
private nextHandle(): string {
return (this.handle++).toString(16).toUpperCase();
}
/** Registriert einen Layer (idempotent über den Namen). */
addLayer(layer: DxfLayer): void {
const name = sanitizeLayerName(layer.name);
if (!this.layers.has(name)) {
this.layers.set(name, { ...layer, name });
}
}
/** Stellt sicher, dass ein Layer existiert; liefert den bereinigten Namen. */
private ensureLayer(name: string): string {
const clean = sanitizeLayerName(name);
if (!this.layers.has(clean)) {
this.layers.set(clean, { name: clean, color: 7, lineWeight: 25 });
}
return clean;
}
addLine(layer: string, a: DxfPoint, b: DxfPoint): void {
const l = this.ensureLayer(layer);
this.entities.push(
tag(0, "LINE"),
tag(5, this.nextHandle()),
tag(100, "AcDbEntity"),
tag(8, l),
tag(100, "AcDbLine"),
num(10, a.x),
num(20, a.y),
num(30, 0),
num(11, b.x),
num(21, b.y),
num(31, 0),
);
}
/**
* LWPOLYLINE (leichte 2D-Polylinie). `closed` setzt das Ringflag (Bit 1 in
* Code 70), sodass die letzte Kante zurück zum ersten Punkt geschlossen wird.
*/
addPolyline(layer: string, pts: DxfPoint[], closed: boolean): void {
if (pts.length < 2) return;
const l = this.ensureLayer(layer);
this.entities.push(
tag(0, "LWPOLYLINE"),
tag(5, this.nextHandle()),
tag(100, "AcDbEntity"),
tag(8, l),
tag(100, "AcDbPolyline"),
tag(90, String(pts.length)),
tag(70, closed ? "1" : "0"),
);
for (const p of pts) {
this.entities.push(num(10, p.x), num(20, p.y));
}
}
addCircle(layer: string, center: DxfPoint, r: number): void {
const l = this.ensureLayer(layer);
this.entities.push(
tag(0, "CIRCLE"),
tag(5, this.nextHandle()),
tag(100, "AcDbEntity"),
tag(8, l),
tag(100, "AcDbCircle"),
num(10, center.x),
num(20, center.y),
num(30, 0),
num(40, r),
);
}
/**
* ARC — Bogen um `center` mit Radius `r`, von `startAngle` bis `endAngle`
* (Grad, gegen den Uhrzeigersinn, DXF-Konvention).
*/
addArc(
layer: string,
center: DxfPoint,
r: number,
startAngle: number,
endAngle: number,
): void {
const l = this.ensureLayer(layer);
this.entities.push(
tag(0, "ARC"),
tag(5, this.nextHandle()),
tag(100, "AcDbEntity"),
tag(8, l),
tag(100, "AcDbCircle"),
num(10, center.x),
num(20, center.y),
num(30, 0),
num(40, r),
tag(100, "AcDbArc"),
num(50, startAngle),
num(51, endAngle),
);
}
/** TEXT — einzeiliger Text, Höhe in Metern, an `at` (unten links). */
addText(layer: string, at: DxfPoint, height: number, value: string): void {
const l = this.ensureLayer(layer);
this.entities.push(
tag(0, "TEXT"),
tag(5, this.nextHandle()),
tag(100, "AcDbEntity"),
tag(8, l),
tag(100, "AcDbText"),
num(10, at.x),
num(20, at.y),
num(30, 0),
num(40, height),
tag(1, sanitizeText(value)),
tag(100, "AcDbText"),
);
}
/** Anzahl bislang gesammelter Entities (für Tests/Validierung). */
get entityCount(): number {
// Jede Entity beginnt mit einem "0"-Gruppencode + Typ-Zeile.
let n = 0;
for (let i = 0; i < this.entities.length; i += 2) {
if (this.entities[i] === " 0") n++;
}
return n;
}
/** Serialisiert die vollständige DXF-Datei als String (mit EOF). */
build(): string {
const out: string[] = [];
out.push(...this.headerSection());
out.push(...this.tablesSection());
out.push(...this.entitiesSection());
out.push(tag(0, "EOF"));
return out.join("\r\n") + "\r\n";
}
private headerSection(): string[] {
return [
tag(0, "SECTION"),
tag(2, "HEADER"),
tag(9, "$ACADVER"),
tag(1, "AC1015"), // AutoCAD 2000
tag(9, "$INSUNITS"),
tag(70, "6"), // 6 = Meter
tag(9, "$MEASUREMENT"),
tag(70, "1"), // 1 = metrisch
tag(0, "ENDSEC"),
];
}
private tablesSection(): string[] {
const out: string[] = [tag(0, "SECTION"), tag(2, "TABLES")];
// LTYPE-Tabelle mit dem obligatorischen Continuous-Linetype (Viewer erwarten ihn).
out.push(
tag(0, "TABLE"),
tag(2, "LTYPE"),
tag(70, "1"),
tag(0, "LTYPE"),
tag(2, "CONTINUOUS"),
tag(70, "0"),
tag(3, "Solid line"),
tag(72, "65"),
tag(73, "0"),
num(40, 0),
tag(0, "ENDTAB"),
);
// LAYER-Tabelle: Layer 0 (Pflicht) + alle Kategorie-Layer.
const layers = [...this.layers.values()];
out.push(tag(0, "TABLE"), tag(2, "LAYER"), tag(70, String(layers.length + 1)));
out.push(...this.layerRecord({ name: "0", color: 7, lineWeight: 25 }));
for (const layer of layers) out.push(...this.layerRecord(layer));
out.push(tag(0, "ENDTAB"));
out.push(tag(0, "ENDSEC"));
return out;
}
/** Ein LAYER-Datensatz (Name/Farbe/Linetype/Linienstärke, ggf. True-Color). */
private layerRecord(layer: DxfLayer): string[] {
const rec = [
tag(0, "LAYER"),
tag(5, this.nextHandle()),
tag(100, "AcDbSymbolTableRecord"),
tag(100, "AcDbLayerTableRecord"),
tag(2, layer.name),
tag(70, "0"), // Flags: nicht gefroren/gesperrt
tag(62, String(layer.color)), // ACI-Farbindex
tag(6, "CONTINUOUS"),
tag(370, String(layer.lineWeight)), // Linienstärke in 1/100 mm
];
// True-Color (Code 420, 24-Bit) für Viewer mit voller Farbtiefe.
if (layer.trueColor != null) {
rec.push(tag(420, String(layer.trueColor & 0xffffff)));
}
return rec;
}
private entitiesSection(): string[] {
return [tag(0, "SECTION"), tag(2, "ENTITIES"), ...this.entities, tag(0, "ENDSEC")];
}
}
/** Gruppen-Code-Zeile mit String-Wert (Code rechtsbündig auf 3 Stellen). */
function tag(code: number, value: string): string {
return `${String(code).padStart(3, " ")}\n${value}`;
}
/** Gruppen-Code-Zeile mit Zahl-Wert (feste, kompakte Dezimaldarstellung). */
function num(code: number, value: number): string {
return `${String(code).padStart(3, " ")}\n${fmt(value)}`;
}
/** Kompakte, verlustarme Zahlformatierung (bis 6 Nachkommastellen, ohne Nullen). */
function fmt(v: number): string {
if (!Number.isFinite(v)) return "0.0";
const s = v.toFixed(6);
// Nachlaufende Nullen entfernen, aber mindestens eine Nachkommastelle behalten.
return s.replace(/(\.\d*?)0+$/, "$1").replace(/\.$/, ".0");
}
/**
* Bereinigt einen Layer-Namen: DXF-verbotene Zeichen (< > / \ " : ; ? * | = `)
* und Leerzeichen werden zu „_". Leerer Name → "0".
*/
export function sanitizeLayerName(name: string): string {
const clean = name
.trim()
.replace(/[<>/\\":;?*|=`]/g, "_")
.replace(/\s+/g, "_");
return clean.length ? clean : "0";
}
/** Entfernt Zeilenumbrüche aus TEXT-Werten (einzeilige TEXT-Entity). */
function sanitizeText(value: string): string {
return value.replace(/[\r\n]+/g, " ");
}
+315
View File
@@ -0,0 +1,315 @@
// DXF-Export des Grundrisses. Nutzt DIESELBE Quelle wie der PDF-Export — den
// Vektor-Plan (generatePlan → Primitive) — schreibt aber MODELL-SPACE-Geometrie
// in ECHTEN Metern (kein Papier-mm, keine Y-Spiegelung). So können nachgelagerte
// CAD-Nutzer beim Plot beliebig skalieren; die Datei ist masshaltig 1:1 in Metern.
//
// Layer entstehen aus den Kategorie-Ebenen des Projekts (Code + Name, Farbe,
// Linienstärke). Jede Entity liegt auf dem Layer ihrer Kategorie; Primitive ohne
// direkten Kategorie-Bezug (Tür-Symbole, Kontext, Schraffuren) landen auf
// sprechenden Sammel-Layern. Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import type { Plan, Primitive } from "../plan/generatePlan";
import type { LayerCategory, Project, Vec2 } from "../model/types";
import { flattenCategories } from "../model/types";
import { DxfWriter } from "./dxfWriter";
export interface ExportDxfOptions {
/** Titel/Geschossname (nur als Kommentar-freundlicher Dateiname genutzt). */
title?: string;
/** Schraffurlinien mit ausgeben (true) oder nur Umrisse/Flächen (false). */
includeHatches?: boolean;
/** Dateiname des Downloads (mit/ohne .dxf). */
fileName?: string;
}
/** Sammel-Layer-Namen für Primitive ohne eigene Kategorie. */
const LAYER_HATCH = "HATCH";
const LAYER_SYMBOLS = "SYMBOLS";
const LAYER_CONTEXT = "CONTEXT";
const LAYER_DEFAULT = "PLAN";
/**
* Baut aus einem Plan + Projekt einen vollständigen DXF-String (Meter-Modell-Space).
* Reihenfolge der Primitive bleibt erhalten. Rückgabe ist reiner ASCII-Text.
*/
export function buildPlanDxf(
plan: Plan,
project: Project,
opts: ExportDxfOptions = {},
): string {
const includeHatches = opts.includeHatches ?? true;
const dxf = new DxfWriter();
// 1) Layer aus den Kategorie-Ebenen anlegen (Code+Name als Layer-Name, Farbe,
// Linienstärke). So trägt die DXF echte, benannte Layer statt eines Flachs.
const cats = flattenCategories(project.layers);
const layerNameByCode = new Map<string, string>();
for (const c of cats) {
const name = layerName(c);
layerNameByCode.set(c.code, name);
dxf.addLayer({
name,
color: aciFromHex(c.color),
trueColor: rgbFromHex(c.color),
lineWeight: mmToLwe(c.lw),
});
}
// Sammel-Layer für kategorielose Primitive.
dxf.addLayer({ name: LAYER_SYMBOLS, color: 7, trueColor: 0x111111, lineWeight: 18 });
dxf.addLayer({ name: LAYER_HATCH, color: 8, trueColor: 0x888888, lineWeight: 13 });
dxf.addLayer({ name: LAYER_CONTEXT, color: 9, trueColor: 0x9aa3ad, lineWeight: 10 });
dxf.addLayer({ name: LAYER_DEFAULT, color: 7, trueColor: 0x111111, lineWeight: 18 });
// 2) Rückverweise Primitiv → Kategorie: Wände/2D-Elemente tragen nur ihre ID im
// Plan; die Kategorie liegt am Projekt-Objekt. Damit landet jede Wand-/Drawing-
// Entity auf dem richtigen Layer.
const codeByWall = new Map<string, string>();
for (const w of project.walls) codeByWall.set(w.id, w.categoryCode);
const codeByDrawing = new Map<string, string>();
for (const d of project.drawings2d) codeByDrawing.set(d.id, d.categoryCode);
const layerFor = (p: Primitive): string => {
if (p.kind === "polygon") {
if (p.wallId) return layerNameByCode.get(codeByWall.get(p.wallId) ?? "") ?? LAYER_DEFAULT;
if (p.drawingId) return layerNameByCode.get(codeByDrawing.get(p.drawingId) ?? "") ?? LAYER_DEFAULT;
return LAYER_DEFAULT;
}
if (p.kind === "line") {
if (p.cls === "context-line") return LAYER_CONTEXT;
if (p.drawingId) return layerNameByCode.get(codeByDrawing.get(p.drawingId) ?? "") ?? LAYER_DEFAULT;
// Tür-Blatt/-Rahmen und Wand-Achsen: Symbol-Sammellayer.
return LAYER_SYMBOLS;
}
// Bögen: Tür-Schwenkbogen → Symbole.
return LAYER_SYMBOLS;
};
// 3) Jedes Primitiv in DXF-Entities übersetzen.
for (const p of plan.primitives) {
emitPrimitive(dxf, p, layerFor(p), includeHatches);
}
return dxf.build();
}
/** Layer-Name aus einer Kategorie: "Code_Name" (bereinigt der Writer selbst). */
function layerName(c: LayerCategory): string {
return `${c.code}_${c.name}`;
}
/** Schreibt ein Plan-Primitiv als passende DXF-Entity(en). */
function emitPrimitive(
dxf: DxfWriter,
p: Primitive,
layer: string,
includeHatches: boolean,
): void {
switch (p.kind) {
case "line":
dxf.addLine(layer, p.a, p.b);
break;
case "arc":
emitArc(dxf, p, layer);
break;
case "polygon": {
const pts = p.pts;
if (pts.length < 2) break;
// Kreis-Approximationen (viele Segmente) bleiben als geschlossene Polylinie —
// masshaltig und robust. Geschlossene Ringe bekommen das Ring-Flag.
dxf.addPolyline(layer, pts, true);
// Schraffur als geklippte Linien (optional). Vollfüllung/„none" tragen keine
// sichtbaren Musterlinien → wir geben nur echte Muster (nicht solid/none) aus.
if (
includeHatches &&
p.hatch.pattern !== "none" &&
p.hatch.pattern !== "solid"
) {
for (const seg of hatchSegments(pts, p.hatch)) {
dxf.addLine(LAYER_HATCH, seg.a, seg.b);
}
}
break;
}
}
}
/**
* Bogen-Primitiv → DXF-ARC. DXF-Bögen laufen gegen den Uhrzeigersinn von
* startAngle nach endAngle. Der Plan-Bogen ist über from/to definiert; die
* Drehrichtung folgt dem Kreuzprodukt (positiv = CCW). Bei CW werden Start/Ende
* getauscht, damit der kurze Bogen in der richtigen Richtung entsteht.
*/
function emitArc(
dxf: DxfWriter,
p: Extract<Primitive, { kind: "arc" }>,
layer: string,
): void {
const a1 = angleDeg(p.center, p.from);
const a2 = angleDeg(p.center, p.to);
const v1 = { x: p.from.x - p.center.x, y: p.from.y - p.center.y };
const v2 = { x: p.to.x - p.center.x, y: p.to.y - p.center.y };
const cross = v1.x * v2.y - v1.y * v2.x;
if (cross >= 0) {
dxf.addArc(layer, p.center, p.r, a1, a2);
} else {
dxf.addArc(layer, p.center, p.r, a2, a1);
}
}
/** Winkel (Grad, 0..360) von `c` nach `p`, gegen den Uhrzeigersinn. */
function angleDeg(c: Vec2, p: Vec2): number {
const d = (Math.atan2(p.y - c.y, p.x - c.x) * 180) / Math.PI;
return d < 0 ? d + 360 : d;
}
// ── Schraffur als geklippte Vektorlinien (Meter) ────────────────────────────
// Wie planToPrintSvg, aber in WELT-Metern statt Papier-mm. Die Kachelgrösse der
// Bildschirm-PlanView (8 bzw. 10 Einheiten × scale, 1 Einheit = 1/90 m) wird
// direkt in einen Meter-Linienabstand übersetzt.
interface Seg {
a: Vec2;
b: Vec2;
}
function hatchSegments(
poly: Vec2[],
hatch: Extract<Primitive, { kind: "polygon" }>["hatch"],
): Seg[] {
const unitM = 1 / 90;
const tileM = (hatch.pattern === "insulation" ? 10 : 8) * hatch.scale * unitM;
const spacing = Math.max(0.02, tileM); // Mindestabstand in Metern
const angle = (hatch.angle * Math.PI) / 180;
const segs: Seg[] = [];
fillParallel(poly, angle, spacing, segs);
if (hatch.pattern === "crosshatch") fillParallel(poly, angle + Math.PI / 2, spacing, segs);
if (hatch.pattern === "insulation") fillParallel(poly, angle - Math.PI / 4, spacing, segs);
return segs;
}
/**
* Füllt ein Polygon mit parallelen Linien (Winkel/Abstand) und klippt jede Linie
* per Scanline auf das Polygon. Robust für konvexe wie einfache konkave Polygone.
*/
function fillParallel(poly: Vec2[], angle: number, spacing: number, out: Seg[]): void {
if (poly.length < 3 || spacing <= 0) return;
const dir = { x: Math.cos(angle), y: Math.sin(angle) };
const nrm = { x: -dir.y, y: dir.x };
let tMin = Infinity;
let tMax = -Infinity;
for (const p of poly) {
const t = p.x * nrm.x + p.y * nrm.y;
if (t < tMin) tMin = t;
if (t > tMax) tMax = t;
}
const start = Math.ceil(tMin / spacing) * spacing;
for (let t = start; t <= tMax; t += spacing) {
const xs: number[] = [];
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
const a = poly[j];
const b = poly[i];
const ta = a.x * nrm.x + a.y * nrm.y;
const tb = b.x * nrm.x + b.y * nrm.y;
if (ta === tb) continue;
const f = (t - ta) / (tb - ta);
if (f < 0 || f > 1) continue;
const px = a.x + (b.x - a.x) * f;
const py = a.y + (b.y - a.y) * f;
xs.push(px * dir.x + py * dir.y);
}
if (xs.length < 2) continue;
xs.sort((u, v) => u - v);
for (let k = 0; k + 1 < xs.length; k += 2) {
const u0 = xs[k];
const u1 = xs[k + 1];
if (u1 - u0 < 1e-6) continue;
out.push({
a: { x: nrm.x * t + dir.x * u0, y: nrm.y * t + dir.y * u0 },
b: { x: nrm.x * t + dir.x * u1, y: nrm.y * t + dir.y * u1 },
});
}
}
}
// ── Farb-Umrechnung ─────────────────────────────────────────────────────────
/** "#rrggbb" → 0xRRGGBB (24-Bit True-Color). Fallback dunkelgrau. */
export function rgbFromHex(hex: string): number {
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
if (!m) return 0x111111;
return parseInt(m[1], 16);
}
/**
* Mappt eine Hex-Farbe auf den nächsten AutoCAD-Color-Index (ACI 1..255). Nutzt
* die feste ACI-Standardpalette (die ersten 9 Indizes sind die Grundfarben) und
* wählt per euklidischem RGB-Abstand den nächstliegenden. Robust genug, damit
* Viewer ohne True-Color-Support trotzdem sinnvolle Farben zeigen.
*/
export function aciFromHex(hex: string): number {
const rgb = rgbFromHex(hex);
const r = (rgb >> 16) & 0xff;
const g = (rgb >> 8) & 0xff;
const b = rgb & 0xff;
let best = 7;
let bestDist = Infinity;
for (const [idx, cr, cg, cb] of ACI_PALETTE) {
const d = (r - cr) ** 2 + (g - cg) ** 2 + (b - cb) ** 2;
if (d < bestDist) {
bestDist = d;
best = idx;
}
}
return best;
}
/** Millimeter → DXF-LWEIGHT (1/100 mm), gerundet auf gültige Stufen ≥ 0. */
export function mmToLwe(mm: number): number {
return Math.max(0, Math.round(mm * 100));
}
/**
* Verkürzte ACI-Standardpalette [index, r, g, b]. Deckt die geläufigen Töne ab;
* für die Layer-Farben (meist gedämpfte Grau-/Bautöne) genügt das Nächste-Farbe-
* Matching. Index 7 (weiss/schwarz) und die Grautöne 250..255 sind enthalten.
*/
const ACI_PALETTE: ReadonlyArray<readonly [number, number, number, number]> = [
[1, 255, 0, 0],
[2, 255, 255, 0],
[3, 0, 255, 0],
[4, 0, 255, 255],
[5, 0, 0, 255],
[6, 255, 0, 255],
[7, 255, 255, 255],
[8, 128, 128, 128],
[9, 192, 192, 192],
[30, 255, 128, 0],
[40, 255, 191, 127],
[250, 51, 51, 51],
[251, 91, 91, 91],
[252, 132, 132, 132],
[253, 173, 173, 173],
[254, 214, 214, 214],
[255, 255, 255, 255],
];
/** Baut das DXF und löst den Download aus (Browser). */
export function savePlanDxf(
plan: Plan,
project: Project,
opts: ExportDxfOptions = {},
): void {
const text = buildPlanDxf(plan, project, opts);
const name = (opts.fileName ?? "grundriss").replace(/\.dxf$/i, "");
const blob = new Blob([text], { type: "application/dxf" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${name}.dxf`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
+143
View File
@@ -0,0 +1,143 @@
// Vektor-PDF-Export des Grundrisses. Baut aus einem Plan (generatePlan →
// Primitive) ein massstäbliches Druck-SVG (planToPrintSvg) und rendert dieses
// per jsPDF + svg2pdf.js als ECHTES Vektor-PDF (Pfad-/Text-Operatoren, KEINE
// Rasterung). Papierformat (A4/A3) + Ausrichtung (Hoch/Quer) wählbar; der Plan
// wird zentriert aufs Blatt gesetzt, ein schlichtes Schriftfeld (Titel/Massstab)
// optional unten rechts.
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import { jsPDF } from "jspdf";
import "svg2pdf.js";
import type { Plan } from "../plan/generatePlan";
import { planToPrintSvg } from "./planToPrintSvg";
/** Unterstützte Papierformate (Blattmasse in mm, Hochformat-Basis). */
export type PaperFormat = "a4" | "a3";
/** Ausrichtung des Blatts. */
export type Orientation = "portrait" | "landscape";
/** Blattmasse je Format im Hochformat (mm). */
const PAGE_MM: Record<PaperFormat, { w: number; h: number }> = {
a4: { w: 210, h: 297 },
a3: { w: 297, h: 420 },
};
export interface ExportPdfOptions {
/** Massstab-Nenner N (1:N). */
scaleDenominator: number;
paper: PaperFormat;
orientation: Orientation;
/** Plantitel/Geschossname fürs Schriftfeld. */
title: string;
/** Optionaler Untertitel (z. B. Projektname). */
subtitle?: string;
/** Schriftfeld zeichnen (Titel/Massstab). Default true. */
titleBlock?: boolean;
/** Dateiname des Downloads (ohne/inkl. .pdf). */
fileName?: string;
}
/** Blattmasse (mm) nach Format + Ausrichtung. */
export function pageSizeMm(
paper: PaperFormat,
orientation: Orientation,
): { w: number; h: number } {
const base = PAGE_MM[paper];
return orientation === "landscape" ? { w: base.h, h: base.w } : base;
}
/**
* Erzeugt das Vektor-PDF des Plans und liefert das jsPDF-Dokument zurück (der
* Aufrufer speichert via {@link savePlanPdf} oder nutzt `doc.output(...)` zum
* Testen). Das Schriftfeld nutzt die eingebaute Helvetica (Vektor-Text).
*/
export async function buildPlanPdf(
plan: Plan,
opts: ExportPdfOptions,
): Promise<jsPDF> {
const { w: pageW, h: pageH } = pageSizeMm(opts.paper, opts.orientation);
// Druck-SVG (mm-Koordinaten) aufbauen. Etwas mehr unterer Rand, falls ein
// Schriftfeld gezeichnet wird (damit es nicht in den Plan ragt).
const margin = 10;
const print = planToPrintSvg(plan, {
scaleDenominator: opts.scaleDenominator,
pageWidthMm: pageW,
pageHeightMm: pageH,
marginMm: margin,
});
const doc = new jsPDF({
unit: "mm",
format: [pageW, pageH],
orientation: opts.orientation,
compress: true,
});
// SVG → PDF (Vektor). Die viewBox des SVG ist in mm (Papier-Benutzereinheiten)
// und deckungsgleich mit der Blattgröße; svg2pdf bildet sie 1:1 auf das mm-Ziel
// (Blattbreite/-höhe) ab.
await doc.svg(print.svg, { x: 0, y: 0, width: pageW, height: pageH });
if (opts.titleBlock ?? true) {
drawTitleBlock(doc, pageW, pageH, opts);
}
return doc;
}
/** Schlichtes Schriftfeld unten rechts: Titel, Untertitel, Massstab. */
function drawTitleBlock(
doc: jsPDF,
pageW: number,
pageH: number,
opts: ExportPdfOptions,
): void {
const boxW = 70;
const boxH = 22;
const pad = 6;
const x = pageW - boxW - pad;
const y = pageH - boxH - pad;
doc.setDrawColor(17, 17, 17);
doc.setLineWidth(0.25);
doc.rect(x, y, boxW, boxH);
// Trennlinie zwischen Titel-Block und Massstab-Zeile.
doc.line(x, y + boxH - 7, x + boxW, y + boxH - 7);
doc.setTextColor(17, 17, 17);
doc.setFont("helvetica", "bold");
doc.setFontSize(10);
doc.text(clip(opts.title, 30), x + 3, y + 6);
if (opts.subtitle) {
doc.setFont("helvetica", "normal");
doc.setFontSize(7);
doc.text(clip(opts.subtitle, 40), x + 3, y + 11);
}
doc.setFont("helvetica", "normal");
doc.setFontSize(9);
doc.text(`1:${opts.scaleDenominator}`, x + 3, y + boxH - 2.2);
const fmt = `${opts.paper.toUpperCase()} ${
opts.orientation === "landscape" ? "quer" : "hoch"
}`;
doc.text(fmt, x + boxW - 3, y + boxH - 2.2, { align: "right" });
}
/** Kürzt einen String auf maximal `max` Zeichen (mit Auslassung). */
function clip(s: string, max: number): string {
return s.length > max ? s.slice(0, max - 1) + "…" : s;
}
/** Baut das PDF und löst den Download aus (Browser). */
export async function savePlanPdf(
plan: Plan,
opts: ExportPdfOptions,
): Promise<void> {
const doc = await buildPlanPdf(plan, opts);
const name = (opts.fileName ?? "grundriss").replace(/\.pdf$/i, "");
doc.save(`${name}.pdf`);
}
+393
View File
@@ -0,0 +1,393 @@
// Druck-SVG aus einem Plan (generatePlan → Primitive). Eigenständiges,
// massstäbliches SVG für den VEKTOR-PDF-Export — NICHT vom Bildschirm-DOM
// abgeleitet, sondern direkt aus den Plan-Primitiven (Polygon/Linie/Bogen).
//
// Kern-Idee (vgl. CONVENTIONS.md, docs/design/plans-output.md §3): Der Plan IST
// Papier-Space. Welt-Meter werden im gewählten Massstab 1:N nach Papier-
// Millimeter abgebildet (1 m = 1000/N mm). Das SVG-Benutzerkoordinatensystem ist
// damit „mm": die viewBox ist in mm, Strichstärken (`stroke-width`) sind echte
// physische mm — KEIN non-scaling-stroke (anders als die Bildschirm-PlanView).
//
// Schraffuren werden NICHT als <pattern> geschrieben (PDF-Konverter übernehmen
// Pattern oft nicht sauber), sondern als echte, auf das Polygon geklippte
// Vektorlinien direkt ins SVG. So bleibt das PDF rein vektoriell und scharf.
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import type { HatchRender, Plan, Primitive } from "../plan/generatePlan";
import type { Vec2 } from "../model/types";
/** SVG-Namespace für die programmatisch erzeugten Knoten. */
const SVG_NS = "http://www.w3.org/2000/svg";
/**
* Standard-Stift-Stufen (mm). Beliebige Kategorie-Strichstärken werden auf die
* NÄCHSTHÖHERE dieser ISO-nahen Stufen gequantelt, damit der Plan saubere,
* unterscheidbare Linienstärken trägt (dünne Hilfslinien ≠ dicke Umrisse).
*/
const PEN_STEPS = [0.13, 0.18, 0.25, 0.35, 0.5, 0.7, 1.0] as const;
/** Untergrenze einer sichtbaren Druck-Strichstärke (mm) — vermeidet 0-Linien. */
const MIN_PEN_MM = 0.13;
/** Quantelt eine mm-Strichstärke auf die nächste Stift-Stufe (≥ MIN_PEN_MM). */
function quantizePen(mm: number): number {
const w = Math.max(MIN_PEN_MM, mm);
for (const step of PEN_STEPS) if (w <= step + 1e-6) return step;
return PEN_STEPS[PEN_STEPS.length - 1];
}
/** Ergebnis des Print-SVG-Aufbaus: das Element + Blattmasse (mm). */
export interface PrintSvg {
/** Das fertige, in mm bemaßte <svg>-Element (für svg2pdf). */
svg: SVGSVGElement;
/** Blattbreite in mm (= viewBox-Breite). */
widthMm: number;
/** Blatthöhe in mm (= viewBox-Höhe). */
heightMm: number;
/** Massstäblicher Inhalts-Rahmen (mm) innerhalb des Blattes (Plan-Bounds). */
content: { x: number; y: number; w: number; h: number };
}
export interface PrintSvgOptions {
/** Massstab-Nenner N (1:N). 1 m = 1000/N mm auf dem Papier. */
scaleDenominator: number;
/** Blattbreite in mm (Papierformat, schon Hoch/Quer berücksichtigt). */
pageWidthMm: number;
/** Blatthöhe in mm. */
pageHeightMm: number;
/** Rand um den Plan-Inhalt in mm (weisser Blattrand). */
marginMm?: number;
}
/** Modell-Meter → Papier-Millimeter im Massstab 1:N. */
function mmPerMeter(n: number): number {
return 1000 / n;
}
/**
* Bildet einen Welt-Punkt (Meter) auf Papier-Millimeter ab. Der Inhalt wird so
* verschoben, dass die Plan-Bounds (in mm) zentriert auf dem Blatt liegen.
* Plan-Y zeigt nach oben → SVG-Y nach unten (Spiegelung), daher das Minus.
*/
interface Mapper {
(p: Vec2): Vec2;
}
/**
* Erzeugt das Druck-SVG aus einem Plan. Reihenfolge der Primitive bleibt
* erhalten (Zeichenreihenfolge = Stapelung); Schraffuren werden als geklippte
* Linien zwischen Grundfüllung und Umriss eingefügt.
*/
export function planToPrintSvg(plan: Plan, opts: PrintSvgOptions): PrintSvg {
const n = opts.scaleDenominator;
const k = mmPerMeter(n); // mm je Meter
// `marginMm` ist Teil des Vertrags (Aufrufer kann einen Rand wünschen); da der
// Inhalt zentriert wird, liegt er ohnehin innerhalb des Blattes. Wir lesen ihn
// bewusst, ohne ihn aktuell für einen Versatz zu nutzen (zentrierte Lage).
void (opts.marginMm ?? 10);
const b = plan.bounds;
// Inhalts-Maße in mm (massstäblich), unverschoben.
const contentWmm = (b.maxX - b.minX) * k;
const contentHmm = (b.maxY - b.minY) * k;
// Inhalt mittig aufs Blatt setzen. Der nutzbare Bereich ist Blatt minus Rand;
// wir zentrieren den Plan-Block darin (clippen aber NICHT — bei zu grossem
// Inhalt läuft er über das Blatt hinaus, was der Aufrufer per Format/Massstab
// vermeidet; ein Warnhinweis liegt in der UI).
const offsetXmm = (opts.pageWidthMm - contentWmm) / 2;
// SVG-Y zeigt nach unten: der oberste Welt-Punkt (maxY) liegt oben auf dem
// Blatt. Wir mappen maxY → offsetYmm.
const offsetYmm = (opts.pageHeightMm - contentHmm) / 2;
const map: Mapper = (p) => ({
x: offsetXmm + (p.x - b.minX) * k,
y: offsetYmm + (b.maxY - p.y) * k,
});
const svg = document.createElementNS(SVG_NS, "svg") as SVGSVGElement;
svg.setAttribute("xmlns", SVG_NS);
// Breite/Höhe als REINE Zahlen (= viewBox-Einheiten), KEINE „mm"-Einheit: so
// bildet svg2pdf die viewBox (in mm-Benutzereinheiten) 1:1 auf das mm-Ziel
// (doc.svg {width,height}) ab. Eine „297mm"-Einheit würde svg2pdf als
// physische Größe lesen und zusätzlich pt-skalieren (Faktor 25.4/72) → falsch.
svg.setAttribute("width", String(opts.pageWidthMm));
svg.setAttribute("height", String(opts.pageHeightMm));
svg.setAttribute("viewBox", `0 0 ${opts.pageWidthMm} ${opts.pageHeightMm}`);
// Weisses Blatt (deckt die volle Seite).
const bg = document.createElementNS(SVG_NS, "rect");
bg.setAttribute("x", "0");
bg.setAttribute("y", "0");
bg.setAttribute("width", String(opts.pageWidthMm));
bg.setAttribute("height", String(opts.pageHeightMm));
bg.setAttribute("fill", "#ffffff");
svg.appendChild(bg);
// Jedes Primitiv in mm-Knoten übersetzen.
for (const p of plan.primitives) {
appendPrimitive(svg, p, map, k);
}
return {
svg,
widthMm: opts.pageWidthMm,
heightMm: opts.pageHeightMm,
content: { x: offsetXmm, y: offsetYmm, w: contentWmm, h: contentHmm },
};
}
/** Schreibt ein einzelnes Primitiv (mit ggf. Schraffur) in das SVG. */
function appendPrimitive(
svg: SVGSVGElement,
p: Primitive,
map: Mapper,
mmPerM: number,
): void {
const opacity = p.greyed ? "0.3" : undefined;
switch (p.kind) {
case "polygon":
appendPolygon(svg, p, map, mmPerM, opacity);
break;
case "line": {
const a = map(p.a);
const b = map(p.b);
const ln = lineNode(a, b, p.color ?? "#111111", quantizePen(p.weightMm), p.dash, mmPerM);
if (opacity) ln.setAttribute("opacity", opacity);
svg.appendChild(ln);
break;
}
case "arc": {
const node = arcNode(p, map, mmPerM);
if (opacity) node.setAttribute("opacity", opacity);
svg.appendChild(node);
break;
}
}
}
/**
* Polygon mit Füllung/Umriss/Schraffur. Reihenfolge wie in der PlanView:
* • pattern "none" → reine Füllung (oder „none") + Umriss.
* • pattern "solid" → Vollfüllung in der Hatch-Farbe + Umriss.
* • Linien/Kreuz/Dämmung → Grundfüllung, dann geklippte Schraffurlinien, dann Umriss.
*/
function appendPolygon(
svg: SVGSVGElement,
p: Extract<Primitive, { kind: "polygon" }>,
map: Mapper,
mmPerM: number,
opacity: string | undefined,
): void {
const ptsMm = p.pts.map(map);
const ptsAttr = ptsMm.map((s) => `${round(s.x)},${round(s.y)}`).join(" ");
const strokeMm = p.strokeWidthMm > 0 ? quantizePen(p.strokeWidthMm) : 0;
const g = document.createElementNS(SVG_NS, "g");
if (opacity) g.setAttribute("opacity", opacity);
const pattern = p.hatch.pattern;
if (pattern === "none") {
g.appendChild(polyNode(ptsAttr, p.fill, p.stroke, strokeMm));
} else if (pattern === "solid") {
g.appendChild(polyNode(ptsAttr, p.hatch.color, p.stroke, strokeMm));
} else {
// Grundfüllung (ohne Umriss), dann die geklippten Schraffurlinien, dann der
// Umriss obenauf (separat, damit er die Schraffur-Enden überdeckt).
if (p.fill !== "none") {
g.appendChild(polyNode(ptsAttr, p.fill, "none", 0));
}
for (const seg of hatchLines(ptsMm, p.hatch, mmPerM)) {
g.appendChild(
lineNode(
seg.a,
seg.b,
p.hatch.color,
quantizePen(p.hatch.lineWeight),
p.hatch.dash,
mmPerM,
),
);
}
if (strokeMm > 0) {
g.appendChild(polyNode(ptsAttr, "none", p.stroke, strokeMm));
}
}
svg.appendChild(g);
}
/** Erzeugt einen <polygon>-Knoten (mm-Koordinaten als Attribut-String). */
function polyNode(
ptsAttr: string,
fill: string,
stroke: string,
strokeMm: number,
): SVGElement {
const el = document.createElementNS(SVG_NS, "polygon");
el.setAttribute("points", ptsAttr);
el.setAttribute("fill", fill);
if (stroke !== "none" && strokeMm > 0) {
el.setAttribute("stroke", stroke);
el.setAttribute("stroke-width", String(strokeMm));
el.setAttribute("stroke-linejoin", "round");
} else {
el.setAttribute("stroke", "none");
}
return el;
}
/** Erzeugt einen <line>-Knoten in mm; Strichmuster (mm Welt → mm Papier). */
function lineNode(
a: Vec2,
b: Vec2,
color: string,
strokeMm: number,
dash: number[] | null | undefined,
_mmPerM: number,
): SVGElement {
const el = document.createElementNS(SVG_NS, "line");
el.setAttribute("x1", String(round(a.x)));
el.setAttribute("y1", String(round(a.y)));
el.setAttribute("x2", String(round(b.x)));
el.setAttribute("y2", String(round(b.y)));
el.setAttribute("stroke", color);
el.setAttribute("stroke-width", String(strokeMm));
el.setAttribute("stroke-linecap", "round");
if (dash && dash.length) {
// Strichmuster ist in mm Papier definiert (wie weightMm), daher direkt.
el.setAttribute("stroke-dasharray", dash.map((d) => round(d)).join(" "));
}
return el;
}
/**
* Erzeugt einen Bogen als <path> (A-Kommando). Der Bogen-Radius ist in Metern →
* Papier-mm. Drehrichtung aus dem Kreuzprodukt im Papier-Raum (Y nach unten).
*/
function arcNode(
p: Extract<Primitive, { kind: "arc" }>,
map: Mapper,
mmPerM: number,
): SVGElement {
const c = map(p.center);
const from = map(p.from);
const to = map(p.to);
const r = p.r * mmPerM;
const v1 = { x: from.x - c.x, y: from.y - c.y };
const v2 = { x: to.x - c.x, y: to.y - c.y };
const cross = v1.x * v2.y - v1.y * v2.x;
const sweep = cross > 0 ? 1 : 0;
const d = `M ${round(from.x)} ${round(from.y)} A ${round(r)} ${round(r)} 0 0 ${sweep} ${round(to.x)} ${round(to.y)}`;
const el = document.createElementNS(SVG_NS, "path");
el.setAttribute("d", d);
el.setAttribute("fill", "none");
el.setAttribute("stroke", "#111111");
el.setAttribute("stroke-width", String(quantizePen(p.weightMm)));
if (p.dash && p.dash.length) {
el.setAttribute("stroke-dasharray", p.dash.map((x) => round(x)).join(" "));
}
return el;
}
/** Auf 3 Nachkommastellen runden (kompaktes, exaktes SVG). */
function round(v: number): number {
return Math.round(v * 1000) / 1000;
}
// ── Schraffur als geklippte Vektorlinien ─────────────────────────────────────
interface Seg {
a: Vec2;
b: Vec2;
}
/**
* Erzeugt die Schraffurlinien eines Polygons (in Papier-mm), auf das Polygon
* geklippt. Die Kachelgrösse der Bildschirm-PlanView (8 px bzw. 14 px bei
* „insulation", × Maßstab) wird hier in einen mm-Linienabstand übersetzt, damit
* der Druck der Bildschirm-Dichte nahekommt:
* • diagonal/crosshatch: parallele Linienscharen im Winkel `angle`.
* • crosshatch: zusätzlich die um 90° gedrehte Schar.
* • insulation: vereinfacht als eine dichtere diagonale Schar (echte Wellen-
* linie wäre als Pfad möglich; eine Linienschar liest sich im Druck sauber
* und bleibt rein vektoriell — offener Punkt im Report).
*/
function hatchLines(polyMm: Vec2[], hatch: HatchRender, mmPerM: number): Seg[] {
// Kachel der PlanView: 8 viewBox-Einheiten (= px bei dpi-Bezug) × scale. In der
// PlanView ist 1 viewBox-Einheit = 1/PX_PER_M m = 1/90 m. Auf Papier sind das
// (1/90)·mmPerM mm. So bleibt die Strich-Dichte massstäblich konsistent.
const unitMm = (1 / 90) * mmPerM;
const tileMm = (hatch.pattern === "insulation" ? 10 : 8) * hatch.scale * unitMm;
const spacing = Math.max(0.5, tileMm); // Mindestabstand, sonst zu dicht
const angle = (hatch.angle * Math.PI) / 180;
const segs: Seg[] = [];
fillParallel(polyMm, angle, spacing, segs);
if (hatch.pattern === "crosshatch") {
fillParallel(polyMm, angle + Math.PI / 2, spacing, segs);
}
if (hatch.pattern === "insulation") {
// Zweite, gegenläufige Schar deutet die Dämmungs-Textur an (dichter Filz).
fillParallel(polyMm, angle - Math.PI / 4, spacing, segs);
}
return segs;
}
/**
* Füllt das Polygon mit parallelen Linien im gegebenen Winkel/Abstand und klippt
* jede Linie auf das Polygon (Scanline über die Polygon-Schnittpunkte). Robust
* für konvexe wie konkave (einfache) Polygone.
*/
function fillParallel(
poly: Vec2[],
angle: number,
spacing: number,
out: Seg[],
): void {
if (poly.length < 3 || spacing <= 0) return;
// Linienrichtung d und Normale nrm (Projektionsachse).
const dir = { x: Math.cos(angle), y: Math.sin(angle) };
const nrm = { x: -dir.y, y: dir.x };
// Projektion der Polygonpunkte auf die Normale → [tMin, tMax]-Band der Linien.
let tMin = Infinity;
let tMax = -Infinity;
for (const p of poly) {
const t = p.x * nrm.x + p.y * nrm.y;
if (t < tMin) tMin = t;
if (t > tMax) tMax = t;
}
// Linien an t = tMin + i·spacing; für jede die Schnitt-Intervalle ermitteln.
const start = Math.ceil(tMin / spacing) * spacing;
for (let t = start; t <= tMax; t += spacing) {
const xs: number[] = []; // Parameter entlang dir der Schnittpunkte
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
const a = poly[j];
const b = poly[i];
const ta = a.x * nrm.x + a.y * nrm.y;
const tb = b.x * nrm.x + b.y * nrm.y;
// Schneidet die Kante das Niveau t?
if (ta === tb) continue;
const f = (t - ta) / (tb - ta);
if (f < 0 || f > 1) continue;
const px = a.x + (b.x - a.x) * f;
const py = a.y + (b.y - a.y) * f;
xs.push(px * dir.x + py * dir.y); // Position entlang dir
}
if (xs.length < 2) continue;
xs.sort((u, v) => u - v);
// Paarweise Intervalle = Polygon-Inneres entlang der Linie.
for (let k = 0; k + 1 < xs.length; k += 2) {
const u0 = xs[k];
const u1 = xs[k + 1];
if (u1 - u0 < 1e-6) continue;
// Punkt = t·nrm + u·dir.
out.push({
a: { x: nrm.x * t + dir.x * u0, y: nrm.y * t + dir.y * u0 },
b: { x: nrm.x * t + dir.x * u1, y: nrm.y * t + dir.y * u1 },
});
}
}
}
+113
View File
@@ -0,0 +1,113 @@
// Geometrie-Helfer für Decken (Slabs) — reine Funktionen über `Vec2`, kein
// externer Kernel. Eine Decke ist ein GESCHLOSSENER Umriss (Polygon) plus eine
// Dicke; diese Helfer normalisieren den Umriss, berechnen Fläche/Umriss-Prüfung
// und liefern eine extrude-fertige Form. Baut auf kernel2d/model/geometry auf.
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import type { Vec2 } from "../model/types";
import { signedArea, isCCW, vecEqual } from "./kernel2d";
/** Kleinste sinnvolle Deckenfläche (m²) — darunter gilt der Umriss als entartet. */
export const MIN_CEILING_AREA = 1e-4;
/**
* Normalisiert einen Deckenumriss:
* • entfernt aufeinanderfolgende (nahezu) gleiche Punkte,
* • entfernt einen schließenden Duplikat-Endpunkt (pts[last] == pts[0]),
* • erzwingt CCW-Wicklung (positive Fläche) — konsistent für Plan-Füllung und
* 3D-Extrusion.
* Liefert `null`, wenn nach der Bereinigung < 3 Punkte übrig bleiben (kein
* gültiges Polygon).
*/
export function normalizeOutline(pts: Vec2[]): Vec2[] | null {
const clean: Vec2[] = [];
for (const p of pts) {
if (clean.length === 0 || !vecEqual(clean[clean.length - 1], p)) clean.push(p);
}
// Schließenden Duplikat-Endpunkt verwerfen.
if (clean.length > 1 && vecEqual(clean[0], clean[clean.length - 1])) clean.pop();
if (clean.length < 3) return null;
// CCW erzwingen.
return isCCW(clean) ? clean : clean.slice().reverse();
}
/** Ob ein Umriss ein gültiges (nicht entartetes) Deckenpolygon bildet. */
export function isValidOutline(pts: Vec2[]): boolean {
const norm = normalizeOutline(pts);
return norm != null && ceilingArea(norm) >= MIN_CEILING_AREA;
}
/** Fläche eines Deckenumrisses in m² (immer positiv). */
export function ceilingArea(pts: Vec2[]): number {
return Math.abs(signedArea(pts));
}
/**
* Achsparallele Bounding-Box eines Umrisses (Modell-Meter). Leerer/entarteter
* Umriss → Null-Box.
*/
export function outlineBBox(pts: Vec2[]): {
minX: number;
minY: number;
maxX: number;
maxY: number;
} {
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity;
for (const p of pts) {
minX = Math.min(minX, p.x);
minY = Math.min(minY, p.y);
maxX = Math.max(maxX, p.x);
maxY = Math.max(maxY, p.y);
}
if (!isFinite(minX)) return { minX: 0, minY: 0, maxX: 0, maxY: 0 };
return { minX, minY, maxX, maxY };
}
/** Schwerpunkt (Zentroid) des Umriss-Polygons (für HUD/Beschriftung). */
export function outlineCentroid(pts: Vec2[]): Vec2 {
const a = signedArea(pts);
if (Math.abs(a) < 1e-12) {
// Entartet: einfacher Mittelwert der Punkte.
let sx = 0,
sy = 0;
for (const p of pts) {
sx += p.x;
sy += p.y;
}
const n = pts.length || 1;
return { x: sx / n, y: sy / n };
}
let cx = 0,
cy = 0;
for (let i = 0; i < pts.length; i++) {
const p = pts[i];
const q = pts[(i + 1) % pts.length];
const cr = p.x * q.y - q.x * p.y;
cx += (p.x + q.x) * cr;
cy += (p.y + q.y) * cr;
}
const f = 1 / (6 * a);
return { x: cx * f, y: cy * f };
}
/**
* Punkt-in-Polygon-Test (Ray-Casting) für einen geschlossenen Umriss — dient dem
* Body-Select einer Decke im Grundriss/3D. Randpunkte gelten als innen genug.
*/
export function pointInOutline(p: Vec2, outline: Vec2[]): boolean {
let inside = false;
const n = outline.length;
for (let i = 0, j = n - 1; i < n; j = i++) {
const a = outline[i];
const b = outline[j];
const intersect =
a.y > p.y !== b.y > p.y &&
p.x < ((b.x - a.x) * (p.y - a.y)) / (b.y - a.y || 1e-12) + a.x;
if (intersect) inside = !inside;
}
return inside;
}
+151
View File
@@ -259,6 +259,157 @@ export function trimSegment(
return pieces.filter((_, i) => i !== best);
}
/**
* Quick-Trim einer ganzen Kurve (offen ODER geschlossen) an einem Klickpunkt.
* Die Kurve `pts`/`closed` wird an ALLEN Schnittpunkten mit den Cuttern in
* Bögen zerlegt; der Bogen, der `pick` am nächsten liegt (das angeklickte „weg-
* zuschneidende" Stück), wird ENTFERNT. Liefert die verbleibenden Ketten als
* `{pts, closed}`-Liste (0..n Stücke):
* • OFFEN: der getroffene Bogen fällt weg → 0, 1 oder 2 offene Reststücke.
* • GESCHLOSSEN: der Ring wird am Schnitt aufgetrennt; das angeklickte Stück
* entfällt → genau ein offenes Reststück (oder leer, wenn alles entfällt).
* Ohne Schnitt (oder nur randständige) bleibt die Kurve unverändert (eine Kette).
*/
export function trimPolyline(
pts: Vec2[],
closed: boolean,
cutters: { pts: Vec2[]; closed: boolean }[],
pick: Vec2,
): { pts: Vec2[]; closed: boolean }[] {
const edges = polylineEdges(pts, closed);
if (edges.length === 0) return [{ pts: pts.slice(), closed }];
// Schnittpunkte je Kante (innen, t ∈ (0,1)) als globale „Knoten" sammeln —
// beschrieben durch (edgeIndex, t) und in Kettenreihenfolge sortiert.
interface Cut {
edge: number;
t: number;
point: Vec2;
}
const cuts: Cut[] = [];
for (let ei = 0; ei < edges.length; ei++) {
const [a1, a2] = edges[ei];
for (const c of cutters) {
for (const h of segmentPolylineHits(a1, a2, c.pts, c.closed)) {
if (h.t > EPS && h.t < 1 - EPS) {
cuts.push({ edge: ei, t: h.t, point: h.point });
}
}
}
}
cuts.sort((p, q) => (p.edge - q.edge) || (p.t - q.t));
// Dedup koinzidenter Schnitte (gleiche Kante+t).
const cut: Cut[] = [];
for (const h of cuts) {
const prev = cut[cut.length - 1];
if (prev && prev.edge === h.edge && Math.abs(prev.t - h.t) < 1e-6) continue;
cut.push(h);
}
if (cut.length === 0) return [{ pts: pts.slice(), closed }];
// Hilfsfunktion: Kette von Knoten X (auf Kante eX, tX) zu Knoten Y entlang der
// Kurve (vorwärts in Kantenrichtung) als Punktliste materialisieren.
const span = (eX: number, pX: Vec2, eY: number, pY: Vec2): Vec2[] => {
const out: Vec2[] = [pX];
let e = eX;
// Über die Vertices NACH dem Startknoten bis zum Endknoten laufen.
// Anzahl Kanten (modular über die Kettenlänge), damit auch der Wrap bei
// geschlossenen Kurven funktioniert.
const total = edges.length;
let steps = 0;
while (steps <= total) {
if (e === eY) break;
// Endpunkt der aktuellen Kante hinzufügen, dann zur nächsten Kante.
out.push(edges[e][1]);
e = (e + 1) % total;
steps++;
}
out.push(pY);
return dedupeConsecutive(out);
};
if (!closed) {
// Offen: der Pick liegt zwischen zwei Knoten ODER zwischen einem Knoten und
// einem Element-Ende. Wir finden das Intervall [lo, hi] der Knoten, die den
// Pick einklammern (lo = letzter Knoten vor dem Pick, hi = erster danach),
// bzw. das Element-Ende, wenn einseitig kein Schnitt liegt.
const pickPos = nearestParamOnChain(edges, pick); // globaler Lauf-Param
const cutPos = cut.map((c) => c.edge + c.t);
let loIdx = -1;
let hiIdx = cut.length;
for (let i = 0; i < cut.length; i++) {
if (cutPos[i] <= pickPos) loIdx = i;
else { hiIdx = i; break; }
}
// Reststück A: Anfang … loIdx-Knoten. Reststück B: hiIdx-Knoten … Ende.
const result: { pts: Vec2[]; closed: boolean }[] = [];
if (loIdx >= 0) {
const c = cut[loIdx];
const head = pts.slice(0, c.edge + 1);
head.push(c.point);
const d = dedupeConsecutive(head);
if (d.length >= 2) result.push({ pts: d, closed: false });
}
if (hiIdx < cut.length) {
const c = cut[hiIdx];
const tail: Vec2[] = [c.point];
for (let k = c.edge + 1; k < pts.length; k++) tail.push(pts[k]);
const d = dedupeConsecutive(tail);
if (d.length >= 2) result.push({ pts: d, closed: false });
}
return result;
}
// Geschlossen: der Pick liegt in einem Bogen zwischen zwei aufeinanderfolgenden
// Knoten (zyklisch). Diesen Bogen entfernen → der Rest ist EINE offene Kette,
// die vom Knoten NACH dem Pick einmal herum bis zum Knoten VOR dem Pick läuft.
if (cut.length === 1) {
// Nur ein Schnitt am Ring: am Schnitt auftrennen, Pick liegt auf der ganzen
// (nun offenen) Kette → nichts Sinnvolles zu entfernen; Ring bleibt.
return [{ pts: pts.slice(), closed: true }];
}
const pickPos = nearestParamOnChain(edges, pick);
const cutPos = cut.map((c) => c.edge + c.t);
// Bogenindex i: zwischen cut[i] und cut[i+1] (mod), der den Pick enthält.
let seg = -1;
for (let i = 0; i < cut.length; i++) {
const a = cutPos[i];
const b = cutPos[(i + 1) % cut.length];
const inside =
i === cut.length - 1
? pickPos >= a || pickPos <= b // Wrap-Bogen über das Ketten-Ende
: pickPos >= a && pickPos <= b;
if (inside) { seg = i; break; }
}
if (seg < 0) seg = 0;
const from = cut[(seg + 1) % cut.length]; // Knoten NACH dem entfernten Bogen
const to = cut[seg]; // Knoten VOR dem entfernten Bogen
const chain = span(from.edge, from.point, to.edge, to.point);
if (chain.length < 2) return [];
return [{ pts: chain, closed: false }];
}
/**
* Globaler Lauf-Parameter (edgeIndex + t) des dem Punkt `p` nächstgelegenen
* Punktes auf der Kantenfolge — für Quick-Trim, um die Klick-Position entlang der
* Kurve zu lokalisieren.
*/
function nearestParamOnChain(edges: [Vec2, Vec2][], p: Vec2): number {
let best = 0;
let bestD = Infinity;
for (let ei = 0; ei < edges.length; ei++) {
const [a, b] = edges[ei];
const t = Math.max(0, Math.min(1, projectParam(p, a, b)));
const q = add(a, scale(sub(b, a), t));
const d = dist(p, q);
if (d < bestD) {
bestD = d;
best = ei + t;
}
}
return best;
}
// ── Extend ───────────────────────────────────────────────────────────────────
/**
+209
View File
@@ -0,0 +1,209 @@
// Geometrie-Helfer für Öffnungen (Fenster/Türen) — reine Funktionen, die eine
// `Opening` relativ zu ihrer Wirts-Wand in konkrete Welt-Geometrie auflösen:
// • die Öffnungs-Lücke entlang der Wandachse (from/to in Metern),
// • das Türblatt (Scharnier → offenes/geschlossenes Ende) + Schwenkbogen,
// • die Fenster-Rahmen-/Glaslinien quer über die Lücke,
// • die vertikale Lage (UK/OK der Öffnung, absolut in Metern).
//
// Da JEDE Größe aus der aktuellen Wandachse (`wall.start`/`wall.end`) abgeleitet
// wird, folgt eine Öffnung ihrer Wand automatisch, sobald die Wand bewegt wird —
// es gibt keine eingebackenen Weltkoordinaten.
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import type { Opening, Project, Vec2, Wall } from "../model/types";
import { getWallType, wallTypeThickness } from "../model/types";
import { add, along, leftNormal, normalize, scale, sub } from "../model/geometry";
import { wallReferenceOffset, wallVerticalExtent } from "../model/wall";
const DEG = Math.PI / 180;
/** Achslänge einer Wand (Meter). */
export function wallAxisLength(wall: Wall): number {
return Math.hypot(wall.end.x - wall.start.x, wall.end.y - wall.start.y);
}
/**
* Öffnungs-Intervall entlang der Wandachse (from..to in Metern), auf die
* Achslänge geklemmt. Liefert null, wenn das Intervall entartet (Breite ≤ 0 oder
* vollständig außerhalb der Achse).
*/
export function openingInterval(
wall: Wall,
o: Opening,
): { from: number; to: number } | null {
const axis = wallAxisLength(wall);
const from = Math.max(0, Math.min(o.position, axis));
const to = Math.max(from, Math.min(o.position + o.width, axis));
if (to - from < 1e-4) return null;
return { from, to };
}
/**
* Vertikale Lage einer Öffnung (absolute Z-Werte in Metern) relativ zur Wand-UK.
* Türen sitzen am Boden (sillHeight 0); Fenster ab Brüstung. Die OK wird auf die
* Wand-OK geklemmt, damit die Öffnung nie über den Wandkopf hinausragt.
*/
export function openingVerticalExtent(
project: Project,
wall: Wall,
o: Opening,
): { zBottom: number; zTop: number } {
const { zBottom, zTop } = wallVerticalExtent(project, wall);
const sill = Math.max(0, o.sillHeight);
const oBottom = zBottom + sill;
const oTop = Math.min(zTop, oBottom + o.height);
return { zBottom: oBottom, zTop: oTop };
}
/** Einheits-Laufrichtung (u) und linke Normale (n) der Wandachse. */
export function wallAxisFrame(wall: Wall): { u: Vec2; n: Vec2 } {
const u = normalize(sub(wall.end, wall.start));
return { u, n: leftNormal(u) };
}
/** Die beiden Pfosten-Punkte (auf der Achse) einer Öffnung im Grundriss. */
export function openingJambs(
wall: Wall,
o: Opening,
): { jambStart: Vec2; jambEnd: Vec2 } | null {
const iv = openingInterval(wall, o);
if (!iv) return null;
return {
jambStart: along(wall.start, wall.end, iv.from),
jambEnd: along(wall.start, wall.end, iv.to),
};
}
/**
* Die Lücken-Rechteck-Ecken (über die volle Wanddicke), z. B. um im Plan die
* Öffnung als Aussparung zu markieren (Auswahl-Highlight). Reihenfolge:
* jambStart(+n/2) → jambEnd(+n/2) → jambEnd(n/2) → jambStart(n/2).
*/
export function openingGapQuad(project: Project, wall: Wall, o: Opening): Vec2[] | null {
const jambs = openingJambs(wall, o);
if (!jambs) return null;
const { n } = wallAxisFrame(wall);
const total = wallTypeThickness(getWallType(project, wall));
const refOff = wallReferenceOffset(wall, total);
const outer = refOff + total / 2;
const inner = refOff - total / 2;
const { jambStart, jambEnd } = jambs;
return [
add(jambStart, scale(n, outer)),
add(jambEnd, scale(n, outer)),
add(jambEnd, scale(n, inner)),
add(jambStart, scale(n, inner)),
];
}
/** Mittelpunkt einer Öffnung im Grundriss (Achse) — für HUD/Auswahl-Anker. */
export function openingCenter(wall: Wall, o: Opening): Vec2 | null {
const iv = openingInterval(wall, o);
if (!iv) return null;
return along(wall.start, wall.end, (iv.from + iv.to) / 2);
}
// ── Tür-Symbol (Plan) ────────────────────────────────────────────────────────
/** Aufgelöste Tür-Symbol-Geometrie im Grundriss (Weltkoordinaten). */
export interface DoorSymbol {
/** Scharnierpunkt (auf der Achse). */
hinge: Vec2;
/** Ende des Blatts in GEÖFFNETER Stellung. */
openEnd: Vec2;
/** Ende des Blatts in GESCHLOSSENER Stellung (Bogen-Startpunkt). */
closedEnd: Vec2;
/** Radius (= Türbreite) des Schwenkbogens. */
radius: number;
/** Beide Pfosten-Punkte (für Rahmen-/Anschlagstriche). */
jambStart: Vec2;
jambEnd: Vec2;
/** Achs-Normale (für kurze Anschlagstriche quer zur Wand). */
normal: Vec2;
}
/**
* Löst die Tür-Symbolik auf. `swing` wählt die Aufschlagseite (linke/rechte
* Achs-Normale), `openingDir` ("out") spiegelt sie auf die andere Seite,
* `hinge` wählt den Scharnier-Pfosten, `swingAngle` (Grad, Default 90) bestimmt,
* wie weit das Blatt in der offenen Stellung aufsteht.
*/
export function doorSymbol(wall: Wall, o: Opening): DoorSymbol | null {
const jambs = openingJambs(wall, o);
if (!jambs) return null;
const { u, n } = wallAxisFrame(wall);
const { jambStart, jambEnd } = jambs;
const width = Math.hypot(jambEnd.x - jambStart.x, jambEnd.y - jambStart.y);
const swingSign = (o.swing ?? "left") === "left" ? 1 : -1;
const dirSign = (o.openingDir ?? "in") === "in" ? 1 : -1;
const swingDir = scale(n, swingSign * dirSign);
const hinge = (o.hinge ?? "start") === "start" ? jambStart : jambEnd;
// Geschlossene Richtung zeigt vom Scharnier zum GEGENüberliegenden Pfosten.
const closedDir = (o.hinge ?? "start") === "start" ? u : scale(u, -1);
const angle = (o.swingAngle ?? 90) * DEG;
// Offenes Blatt-Ende: um `angle` von der geschlossenen Richtung zur
// Schwenkrichtung gedreht (Interpolation über die Basis closedDir/swingDir).
const openDir = {
x: closedDir.x * Math.cos(angle) + swingDir.x * Math.sin(angle),
y: closedDir.y * Math.cos(angle) + swingDir.y * Math.sin(angle),
};
return {
hinge,
openEnd: add(hinge, scale(openDir, width)),
closedEnd: add(hinge, scale(closedDir, width)),
radius: width,
jambStart,
jambEnd,
normal: n,
};
}
// ── Fenster-Symbol (Plan) ────────────────────────────────────────────────────
/** Aufgelöste Fenster-Linien im Grundriss (Weltkoordinaten). */
export interface WindowSymbol {
/** Rahmen-Rechteck (volle Dicke) als Ecken. */
frame: Vec2[];
/** Glaslinien-Segmente quer über die Lücke (12 parallele Linien). */
glassLines: [Vec2, Vec2][];
}
/**
* Löst die Fenster-Symbolik auf: ein Rahmen-Rechteck über die Lücke (volle
* Wanddicke) plus 12 Glaslinien (dünne parallele Linien in der Wandmitte,
* `count` steuert die Anzahl je Detailgrad).
*/
export function windowSymbol(
project: Project,
wall: Wall,
o: Opening,
glassCount: number,
): WindowSymbol | null {
const jambs = openingJambs(wall, o);
if (!jambs) return null;
const { n } = wallAxisFrame(wall);
const total = wallTypeThickness(getWallType(project, wall));
const refOff = wallReferenceOffset(wall, total);
const outer = refOff + total / 2;
const inner = refOff - total / 2;
const { jambStart, jambEnd } = jambs;
const frame = [
add(jambStart, scale(n, outer)),
add(jambEnd, scale(n, outer)),
add(jambEnd, scale(n, inner)),
add(jambStart, scale(n, inner)),
];
const glassLines: [Vec2, Vec2][] = [];
const count = Math.max(1, glassCount);
for (let i = 0; i < count; i++) {
// Glaslinien symmetrisch um die Wandmitte verteilt (Abstand = Dicke/8).
const spread = total / 8;
const off = refOff + (count === 1 ? 0 : (i - (count - 1) / 2) * spread * 2);
glassLines.push([add(jambStart, scale(n, off)), add(jambEnd, scale(n, off))]);
}
return { frame, glassLines };
}
+389
View File
@@ -0,0 +1,389 @@
// Räume (Flächen) — Flächenberechnung und SIA-416-Klassifikation.
//
// Reiner Rechenkern für die Raum-Flächenbuchhaltung nach SIA 416 (Schweizer
// Norm „Flächen und Volumen von Gebäuden"). Keine UI, kein Datei-IO — nur
// Polygon-Geometrie, Kategorie-Hierarchie und Aggregation für die Bilanz.
//
// Bezeichner englisch, Kommentare/Labels deutsch (CONVENTIONS.md). Einheit: METER.
import type { Vec2 } from "../model/types";
// ── Polygon-Kennzahlen ───────────────────────────────────────────────────────
/**
* Vorzeichenbehaftete Polygonfläche (Gauss'sche Trapezformel / Shoelace).
* >0 = gegen den Uhrzeigersinn (CCW), <0 = im Uhrzeigersinn (CW). Der letzte
* Punkt darf NICHT dupliziert sein (die Schlusskante n-1 → 0 wird ergänzt).
*/
export function signedArea(pts: Vec2[]): number {
const n = pts.length;
if (n < 3) return 0;
let s = 0;
for (let i = 0; i < n; i++) {
const a = pts[i];
const b = pts[(i + 1) % n];
s += a.x * b.y - b.x * a.y;
}
return s / 2;
}
/** Absolute (immer positive) Polygonfläche in m². */
export function polygonArea(pts: Vec2[]): number {
return Math.abs(signedArea(pts));
}
/** Umfang eines geschlossenen Polygons (Summe aller Kantenlängen). */
export function perimeter(pts: Vec2[]): number {
const n = pts.length;
if (n < 2) return 0;
let p = 0;
for (let i = 0; i < n; i++) {
const a = pts[i];
const b = pts[(i + 1) % n];
p += Math.hypot(b.x - a.x, b.y - a.y);
}
return p;
}
/**
* Flächenschwerpunkt (Centroid) eines geschlossenen Polygons — Ankerpunkt für
* das Raum-Label. Nutzt die momenten-gewichtete Formel; degeneriert (Fläche ≈ 0)
* auf den Durchschnitt der Vertices.
*/
export function centroid(pts: Vec2[]): Vec2 {
const n = pts.length;
if (n === 0) return { x: 0, y: 0 };
if (n < 3) {
let sx = 0;
let sy = 0;
for (const p of pts) {
sx += p.x;
sy += p.y;
}
return { x: sx / n, y: sy / n };
}
let a = 0;
let cx = 0;
let cy = 0;
for (let i = 0; i < n; i++) {
const p = pts[i];
const q = pts[(i + 1) % n];
const cr = p.x * q.y - q.x * p.y;
a += cr;
cx += (p.x + q.x) * cr;
cy += (p.y + q.y) * cr;
}
a /= 2;
if (Math.abs(a) < 1e-12) {
let sx = 0;
let sy = 0;
for (const p of pts) {
sx += p.x;
sy += p.y;
}
return { x: sx / n, y: sy / n };
}
return { x: cx / (6 * a), y: cy / (6 * a) };
}
// ── SIA-416-Kategorie-Hierarchie ─────────────────────────────────────────────
//
// SIA 416 gliedert die Geschossfläche (GF) eines Gebäudes hierarchisch:
//
// GF Geschossfläche
// ├── KGF Konstruktionsfläche (Wände, Stützen, Schächte-Wandungen …)
// └── NGF Nettogeschossfläche
// ├── NF Nutzfläche
// │ ├── HNF Hauptnutzfläche (der eigentliche Nutzungszweck)
// │ └── NNF Nebennutzfläche (unterstützende Nutzung, Lager, WC …)
// ├── VF Verkehrsfläche (Flure, Treppen, Aufzüge, Rampen)
// └── FF Funktionsfläche (technische Anlagen, Haustechnik)
//
// Die „Blatt"-Kategorien, die einem konkreten Raum zugewiesen werden, sind
// HNF, NNF, VF, FF und KGF. HNF+NNF = NF; NF+VF+FF = NGF; NGF+KGF = GF.
/** Blatt-Kategorien nach SIA 416, die einem Raum direkt zugewiesen werden. */
export type SiaCategory = "HNF" | "NNF" | "VF" | "FF" | "KGF";
/** Aggregat-(Zwischensummen-)Ebenen der SIA-416-Hierarchie. */
export type SiaAggregate = "NF" | "NGF" | "GF";
/** Alle in der Bilanz vorkommenden Schlüssel (Blatt + Aggregat). */
export type SiaKey = SiaCategory | SiaAggregate;
/** Reihenfolge der Blatt-Kategorien für stabile Tabellen/CSV-Ausgabe. */
export const SIA_CATEGORIES: readonly SiaCategory[] = [
"HNF",
"NNF",
"VF",
"FF",
"KGF",
] as const;
/** Deutsche Bezeichnungen (Kurz + Lang) je SIA-416-Schlüssel. */
export interface SiaLabel {
/** Kürzel, z. B. „HNF". */
code: SiaKey;
/** Ausgeschriebene deutsche Bezeichnung. */
name: string;
}
const SIA_LABELS: Record<SiaKey, string> = {
HNF: "Hauptnutzfläche",
NNF: "Nebennutzfläche",
VF: "Verkehrsfläche",
FF: "Funktionsfläche",
KGF: "Konstruktionsfläche",
NF: "Nutzfläche",
NGF: "Nettogeschossfläche",
GF: "Geschossfläche",
};
/** Deutsche Bezeichnung zu einem SIA-Schlüssel. */
export function siaLabel(key: SiaKey): string {
return SIA_LABELS[key];
}
/** „HNF — Hauptnutzfläche" o. ä. für Legenden/Dropdowns. */
export function siaLabelFull(key: SiaKey): string {
return `${key}${SIA_LABELS[key]}`;
}
/**
* Zuordnung Blatt-Kategorie → übergeordnete Aggregate. Eine HNF trägt zu
* NF, NGF und GF bei; eine KGF nur zu GF; VF/FF zu NGF und GF; NNF wie HNF.
* Wird für den Bilanz-Roll-up genutzt.
*/
const CATEGORY_ROLLUP: Record<SiaCategory, SiaAggregate[]> = {
HNF: ["NF", "NGF", "GF"],
NNF: ["NF", "NGF", "GF"],
VF: ["NGF", "GF"],
FF: ["NGF", "GF"],
KGF: ["GF"],
};
// ── Raum-Flächenergebnis ─────────────────────────────────────────────────────
/**
* Ergebnis der Flächenbewertung eines einzelnen Raums.
*
* `grossArea` ist die geometrische Polygonfläche (Bruttofläche der Raum-Kontur).
* `netArea` ist die anrechenbare Netto-Nutzfläche: standardmässig gleich der
* Bruttofläche (die Raum-Kontur ist bereits die lichte Innenfläche zwischen den
* Wänden). Über einen optionalen Abzug (`deduction`, z. B. für nicht anrechen-
* bare Bereiche mit lichter Höhe < 1.0 m nach SIA 416) kann `netArea` reduziert
* werden. `category`/`label` tragen die SIA-Klassifikation.
*/
export interface RoomAreaResult {
grossArea: number;
netArea: number;
perimeter: number;
centroid: Vec2;
category: SiaCategory;
/** Deutsche Bezeichnung der Kategorie (z. B. „Hauptnutzfläche"). */
label: string;
}
/** Optionen für die Flächenbewertung eines Raums. */
export interface RoomAreaOptions {
/**
* Abzug von der Bruttofläche (m²), z. B. für Bereiche mit lichter Höhe unter
* dem SIA-416-Grenzwert, die nicht zur anrechenbaren Fläche zählen. Default 0.
*/
deduction?: number;
}
/**
* Bewertet ein Raum-Polygon: berechnet Brutto-/Nettofläche, Umfang, Schwerpunkt
* und hängt die SIA-416-Kategorie samt deutschem Label an.
*/
export function evaluateRoom(
polygon: Vec2[],
category: SiaCategory,
options: RoomAreaOptions = {},
): RoomAreaResult {
const gross = polygonArea(polygon);
const deduction = Math.max(0, options.deduction ?? 0);
const net = Math.max(0, gross - deduction);
return {
grossArea: gross,
netArea: net,
perimeter: perimeter(polygon),
centroid: centroid(polygon),
category,
label: SIA_LABELS[category],
};
}
// ── Bilanz (Flächenaufstellung) ──────────────────────────────────────────────
/** Ein Zeileneintrag der Bilanz: Schlüssel, Label, Fläche, Raumanzahl. */
export interface BalanceRow {
key: SiaKey;
label: string;
area: number;
/** Anzahl Räume, die zu dieser (Blatt-)Kategorie zählen. Für Aggregate: Summe. */
count: number;
/** true, wenn es sich um eine Zwischensumme (NF/NGF/GF) handelt. */
aggregate: boolean;
}
/** Vollständige SIA-416-Bilanz: geordnete Zeilen + Gesamtsumme (GF). */
export interface Balance {
rows: BalanceRow[];
/** Schneller Zugriff auf die Fläche je Schlüssel. */
byKey: Record<SiaKey, number>;
total: number;
}
/**
* Aggregiert Räume je SIA-416-Kategorie und rollt die Zwischensummen
* (NF, NGF, GF) korrekt auf. Erwartet je Raum die Blatt-Kategorie und die
* anzurechnende Fläche (i. d. R. `netArea` aus {@link evaluateRoom}).
*
* Die zurückgegebenen `rows` sind in kanonischer Reihenfolge:
* HNF, NNF, NF(=Σ), VF, FF, NGF(=Σ), KGF, GF(=Σ)
* — passend für die Bilanz-Tabelle und den CSV-Export.
*/
export function balance(
rooms: { category: SiaCategory; area: number }[],
): Balance {
const leaf: Record<SiaCategory, number> = { HNF: 0, NNF: 0, VF: 0, FF: 0, KGF: 0 };
const leafCount: Record<SiaCategory, number> = { HNF: 0, NNF: 0, VF: 0, FF: 0, KGF: 0 };
const agg: Record<SiaAggregate, number> = { NF: 0, NGF: 0, GF: 0 };
const aggCount: Record<SiaAggregate, number> = { NF: 0, NGF: 0, GF: 0 };
for (const r of rooms) {
const area = Number.isFinite(r.area) ? r.area : 0;
leaf[r.category] += area;
leafCount[r.category] += 1;
for (const a of CATEGORY_ROLLUP[r.category]) {
agg[a] += area;
aggCount[a] += 1;
}
}
const byKey: Record<SiaKey, number> = {
HNF: leaf.HNF,
NNF: leaf.NNF,
VF: leaf.VF,
FF: leaf.FF,
KGF: leaf.KGF,
NF: agg.NF,
NGF: agg.NGF,
GF: agg.GF,
};
const mkLeaf = (k: SiaCategory): BalanceRow => ({
key: k,
label: SIA_LABELS[k],
area: leaf[k],
count: leafCount[k],
aggregate: false,
});
const mkAgg = (k: SiaAggregate): BalanceRow => ({
key: k,
label: SIA_LABELS[k],
area: agg[k],
count: aggCount[k],
aggregate: true,
});
const rows: BalanceRow[] = [
mkLeaf("HNF"),
mkLeaf("NNF"),
mkAgg("NF"),
mkLeaf("VF"),
mkLeaf("FF"),
mkAgg("NGF"),
mkLeaf("KGF"),
mkAgg("GF"),
];
return { rows, byKey, total: agg.GF };
}
// ── CSV-Serialisierung ───────────────────────────────────────────────────────
/** Ein Raum, wie er in den CSV-Export einfliesst. */
export interface RoomCsvRecord {
/** Raum-Nummer/-Kennung (frei), z. B. „R.01". */
number?: string;
/** Raum-Name, z. B. „Wohnen". */
name?: string;
/** Geschoss/Ebene, optional. */
level?: string;
category: SiaCategory;
/** Anzurechnende Fläche (m²) — i. d. R. netArea. */
area: number;
}
/** Ein Feld CSV-sicher quoten (RFC 4180: „..." bei , " \n; " → ""). */
function csvField(value: string): string {
if (/[",\n\r;]/.test(value)) {
return `"${value.replace(/"/g, '""')}"`;
}
return value;
}
/** Fläche auf 2 Nachkommastellen, Punkt als Dezimaltrenner (m²). */
function fmtArea(area: number): string {
return (Math.round(area * 100) / 100).toFixed(2);
}
export interface RoomsToCsvOptions {
/** Feldtrenner. Default „;" (Excel-DE-freundlich). */
delimiter?: string;
/** true (Default): Bilanz-Zwischensummen unter die Raumliste anhängen. */
includeBalance?: boolean;
}
/**
* Serialisiert eine Raumliste als CSV (reiner String, kein Datei-IO) für den
* späteren Export. Kopfzeile deutsch; optional folgt die SIA-416-Bilanz als
* Summenblock. Nutzt `;` als Trenner (Standard im DE-Excel-Umfeld).
*/
export function roomsToCsv(
rooms: RoomCsvRecord[],
options: RoomsToCsvOptions = {},
): string {
const d = options.delimiter ?? ";";
const includeBalance = options.includeBalance ?? true;
const lines: string[] = [];
const header = ["Nummer", "Name", "Geschoss", "Kategorie", "Bezeichnung", "Fläche [m²]"];
lines.push(header.map(csvField).join(d));
for (const r of rooms) {
lines.push(
[
csvField(r.number ?? ""),
csvField(r.name ?? ""),
csvField(r.level ?? ""),
csvField(r.category),
csvField(SIA_LABELS[r.category]),
fmtArea(r.area),
].join(d),
);
}
if (includeBalance) {
const bal = balance(rooms.map((r) => ({ category: r.category, area: r.area })));
lines.push(""); // Leerzeile trennt Raumliste von Bilanz
lines.push([csvField("Bilanz (SIA 416)"), "", "", "", "", ""].join(d));
for (const row of bal.rows) {
lines.push(
[
"",
"",
"",
csvField(row.key),
csvField(row.label),
fmtArea(row.area),
].join(d),
);
}
}
return lines.join("\r\n");
}
+457
View File
@@ -0,0 +1,457 @@
// Raum-Erkennung — geschlossene Raum-Umrisse aus Wandsegmenten ableiten.
//
// Zwei komplementäre Ansätze:
//
// 1. detectRooms(walls) — baut die planare Anordnung (Arrangement) der Wand-
// MITTELLINIEN, schneidet alle Kanten an ihren Kreuzungen, snappt kleine
// Lücken zu und extrahiert die minimalen geschlossenen Maschen (Faces) des
// Graphen über das „next edge counter-clockwise"-Verfahren (half-edge face
// tracing). Die unbeschränkte Aussenmasche wird verworfen. Für jede innere
// Masche wird die lichte Innenkontur berechnet, indem die Masche um die
// halbe Wanddicke nach innen versetzt wird (Miter-Offset).
//
// 2. roomFromPointInside(point, wallFaces) — klick-in-Raum-Fallback: umläuft
// ausgehend von einem Saatpunkt die den Punkt einschliessende Masche direkt
// (gap-tolerant, wie die meisten CAD-Werkzeuge für „Raum durch Klick"). Er
// liefert die lichte Innenkontur der angeklickten Zelle.
//
// Grenzen (ehrlich): Der Arrangement-Ansatz behandelt Wände als 1D-Mittellinien;
// überlappende/kollineare Doppelwände oder gekrümmte Wände werden nicht speziell
// aufgelöst. Lücken unterhalb `gapTol` werden per Endpunkt-Snapping geschlossen;
// grössere Öffnungen (Türen) müssen als geschlossen modelliert oder vom Aufrufer
// überbrückt werden. Der Offset nach innen nutzt Miter-Joins ohne Selbstschnitt-
// Heilung — für orthogonale/konvexe Grundrisse (der Normalfall) exakt, bei sehr
// spitzen einspringenden Ecken approximativ.
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). Einheit: METER.
import type { Vec2 } from "../model/types";
import { signedArea, polygonArea } from "./roomArea";
/** Ein Wandsegment: Mittellinie a→b mit Dicke (beidseitig je thickness/2). */
export interface WallSegment {
a: Vec2;
b: Vec2;
thickness: number;
}
/** Parameter der Raum-Erkennung. */
export interface DetectRoomsOptions {
/** Lücken/Endpunkt-Abstände ≤ gapTol (m) werden zusammengeschnappt. Default 0.05. */
gapTol?: number;
/** Kleinste als Raum akzeptierte Fläche (m²). Default 0.05. */
minArea?: number;
/**
* true (Default): Maschen um halbe Wanddicke nach innen versetzen, sodass die
* zurückgegebenen Loops die LICHTE Innenkontur sind. false: Wand-Mittellinien-
* Maschen (roh) zurückgeben.
*/
offsetToInner?: boolean;
}
// ── Vektorhelfer (lokal, ohne Fremdabhängigkeit) ─────────────────────────────
const sub = (a: Vec2, b: Vec2): Vec2 => ({ x: a.x - b.x, y: a.y - b.y });
const add = (a: Vec2, b: Vec2): Vec2 => ({ x: a.x + b.x, y: a.y + b.y });
const scale = (a: Vec2, s: number): Vec2 => ({ x: a.x * s, y: a.y * s });
const cross = (a: Vec2, b: Vec2): number => a.x * b.y - a.y * b.x;
const len = (a: Vec2): number => Math.hypot(a.x, a.y);
const normalize = (a: Vec2): Vec2 => {
const l = len(a);
return l < 1e-12 ? { x: 0, y: 0 } : { x: a.x / l, y: a.y / l };
};
/** Linke Normale (90° gegen den Uhrzeigersinn). */
const leftNormal = (a: Vec2): Vec2 => ({ x: -a.y, y: a.x });
const dist = (a: Vec2, b: Vec2): number => Math.hypot(a.x - b.x, a.y - b.y);
const EPS = 1e-9;
// ── Planarer Graph (Knoten + ungerichtete Kanten) ────────────────────────────
interface Node {
p: Vec2;
}
interface UEdge {
u: number; // Knotenindex
v: number; // Knotenindex
}
/**
* Baut aus den Wand-Mittellinien einen planaren Graphen: Endpunkte werden mit
* Toleranz `snap` verschmolzen, jedes Segment an allen echten Kreuzungen mit
* anderen Segmenten unterteilt, kollineare Duplikate entfernt.
*/
function buildPlanarGraph(
segments: { a: Vec2; b: Vec2 }[],
snap: number,
): { nodes: Node[]; edges: UEdge[] } {
const nodes: Node[] = [];
// Knoten mit Snapping-Toleranz einfügen (linearer Scan; für Grundriss-Grössen
// ausreichend).
const addNode = (p: Vec2): number => {
for (let i = 0; i < nodes.length; i++) {
if (dist(nodes[i].p, p) <= snap) return i;
}
nodes.push({ p: { x: p.x, y: p.y } });
return nodes.length - 1;
};
// Für jedes Segment: alle Split-Parameter t sammeln (Endpunkte + Kreuzungen).
const raw = segments
.map((s) => ({ a: s.a, b: s.b }))
.filter((s) => dist(s.a, s.b) > snap);
interface Split {
t: number;
p: Vec2;
}
const perSegment: Split[][] = raw.map((s) => [
{ t: 0, p: s.a },
{ t: 1, p: s.b },
]);
for (let i = 0; i < raw.length; i++) {
for (let j = i + 1; j < raw.length; j++) {
const A = raw[i];
const B = raw[j];
const da = sub(A.b, A.a);
const db = sub(B.b, B.a);
const denom = cross(da, db);
if (Math.abs(denom) < EPS) continue; // parallel/kollinear
const t = cross(sub(B.a, A.a), db) / denom;
const s = cross(sub(B.a, A.a), da) / denom;
// Echter innerer Schnitt (Endpunkt-Berührungen sind bereits Knoten).
const tolT = snap / Math.max(len(da), 1e-9);
const tolS = snap / Math.max(len(db), 1e-9);
if (t < -tolT || t > 1 + tolT || s < -tolS || s > 1 + tolS) continue;
const p = add(A.a, scale(da, t));
perSegment[i].push({ t, p });
perSegment[j].push({ t: s, p });
}
}
const edgeSet = new Set<string>();
const edges: UEdge[] = [];
const pushEdge = (u: number, v: number) => {
if (u === v) return;
const key = u < v ? `${u}_${v}` : `${v}_${u}`;
if (edgeSet.has(key)) return;
edgeSet.add(key);
edges.push({ u, v });
};
for (let i = 0; i < raw.length; i++) {
const splits = perSegment[i].sort((p, q) => p.t - q.t);
let prev = -1;
let prevT = -Infinity;
for (const sp of splits) {
if (sp.t - prevT < 1e-9 && prev >= 0) continue;
const idx = addNode(sp.p);
if (prev >= 0 && idx !== prev) pushEdge(prev, idx);
prev = idx;
prevT = sp.t;
}
}
return { nodes, edges };
}
// ── Face-Extraktion über gerichtete Halbkanten ───────────────────────────────
interface HalfEdge {
from: number;
to: number;
/** Ausgangswinkel from→to (für die CCW-Sortierung an jedem Knoten). */
angle: number;
used: boolean;
}
/**
* Extrahiert die minimalen geschlossenen Maschen (Faces) des planaren Graphen.
* Verfahren: jede ungerichtete Kante wird zu zwei Halbkanten. An jedem Knoten
* werden die abgehenden Halbkanten nach Winkel sortiert. Beim Umlaufen einer
* Masche wählt man an jedem Zielknoten stets die Halbkante, die im Uhrzeigersinn
* unmittelbar VOR der eingehenden (umgekehrten) liegt — das „next edge"-Kriterium
* für konsistente Face-Umläufe. Jede Halbkante gehört zu genau einer Masche.
* Die (einzige) Masche mit positiver Fläche und maximaler |Fläche|, deren Umlauf
* die anderen umschliesst, ist die Aussenmasche und wird verworfen.
*/
function extractFaces(nodes: Node[], edges: UEdge[]): number[][] {
// Adjazenz: je Knoten die Indizes seiner abgehenden Halbkanten.
const halfEdges: HalfEdge[] = [];
const outgoing: number[][] = nodes.map(() => []);
const angleOf = (from: number, to: number): number => {
const d = sub(nodes[to].p, nodes[from].p);
return Math.atan2(d.y, d.x);
};
for (const e of edges) {
const h1 = halfEdges.length;
halfEdges.push({ from: e.u, to: e.v, angle: angleOf(e.u, e.v), used: false });
outgoing[e.u].push(h1);
const h2 = halfEdges.length;
halfEdges.push({ from: e.v, to: e.u, angle: angleOf(e.v, e.u), used: false });
outgoing[e.v].push(h2);
}
// Abgehende Halbkanten je Knoten nach Winkel sortieren.
for (const list of outgoing) {
list.sort((h1, h2) => halfEdges[h1].angle - halfEdges[h2].angle);
}
// Positionsindex je Halbkante in der sortierten Liste seines from-Knotens.
const posInList = new Map<number, number>();
for (const list of outgoing) {
for (let k = 0; k < list.length; k++) posInList.set(list[k], k);
}
// Zwilling (umgekehrte Halbkante): Paare liegen benachbart (2i, 2i+1).
const twin = (h: number): number => (h % 2 === 0 ? h + 1 : h - 1);
const faces: number[][] = [];
for (let start = 0; start < halfEdges.length; start++) {
if (halfEdges[start].used) continue;
const faceNodes: number[] = [];
let h = start;
let guard = 0;
const maxSteps = halfEdges.length + 2;
do {
halfEdges[h].used = true;
faceNodes.push(halfEdges[h].from);
// An to = halfEdges[h].to die nächste Halbkante wählen: rund um den Knoten
// die im Uhrzeigersinn nächste NACH dem Zwilling der eingehenden Kante.
const tw = twin(h);
const list = outgoing[halfEdges[h].to];
const pos = posInList.get(tw)!;
const nextPos = (pos - 1 + list.length) % list.length; // im Uhrzeigersinn davor
h = list[nextPos];
guard++;
} while (h !== start && guard < maxSteps);
if (faceNodes.length >= 3) faces.push(faceNodes);
}
// Aussenmasche(n) verwerfen: eine Masche ist „innen", wenn sie CCW orientiert
// ist (positive Shoelace-Fläche). Die Aussenmasche läuft CW (negative Fläche).
const inner: number[][] = [];
for (const f of faces) {
const poly = f.map((n) => nodes[n].p);
if (signedArea(poly) > EPS) inner.push(f);
}
return inner;
}
// ── Innen-Offset einer Masche (halbe Wanddicke) ──────────────────────────────
/**
* Versetzt ein CCW-Polygon um `d` nach innen (jede Kante um d nach rechts, da
* CCW → Innenseite rechts der Laufrichtung? Nein: bei CCW liegt das Innere LINKS.
* Wir verschieben daher jede Kante um d in Richtung Inneres = linke Normale) via
* Miter-Join. Bei ungültigem Schnitt (fast parallel) wird der verschobene Eck-
* punkt direkt genommen.
*/
function offsetInward(poly: Vec2[], d: number): Vec2[] {
const n = poly.length;
if (n < 3 || d <= 0) return poly.slice();
// Sicherstellen: CCW (Inneres links). Falls CW, umdrehen.
const pts = signedArea(poly) > 0 ? poly : poly.slice().reverse();
// Verschobene Kanten (Inneres liegt links → linke Normale zeigt nach innen).
const shifted: { p: Vec2; dir: Vec2 }[] = [];
for (let i = 0; i < n; i++) {
const a = pts[i];
const b = pts[(i + 1) % n];
const dir = normalize(sub(b, a));
const nrm = leftNormal(dir); // zeigt ins Innere bei CCW
shifted.push({ p: add(a, scale(nrm, d)), dir });
}
const out: Vec2[] = [];
for (let i = 0; i < n; i++) {
const prev = shifted[(i - 1 + n) % n];
const curr = shifted[i];
// Schnitt der beiden verschobenen (unendlichen) Geraden.
const denom = cross(prev.dir, curr.dir);
if (Math.abs(denom) < 1e-9) {
out.push(curr.p); // (nahezu) kollinear → Startpunkt der aktuellen Kante
continue;
}
const t = cross(sub(curr.p, prev.p), curr.dir) / denom;
out.push(add(prev.p, scale(prev.dir, t)));
}
return out;
}
/** Dedupliziert aufeinanderfolgende (nahezu) gleiche Punkte im Ring. */
function dedupeRing(pts: Vec2[], tol = 1e-7): Vec2[] {
const out: Vec2[] = [];
for (const p of pts) {
const last = out[out.length - 1];
if (!last || dist(last, p) > tol) out.push(p);
}
if (out.length > 1 && dist(out[0], out[out.length - 1]) <= tol) out.pop();
return out;
}
/** Durchschnittliche Wanddicke der Segmente, die eine Kante bilden (Fallback 0). */
function averageThickness(walls: WallSegment[]): number {
if (walls.length === 0) return 0;
let s = 0;
for (const w of walls) s += w.thickness;
return s / walls.length;
}
// ── Öffentliche API ──────────────────────────────────────────────────────────
/**
* Erkennt geschlossene Räume aus einer Menge von Wandsegmenten (Mittellinien
* mit Dicke). Liefert die lichten Innen-Umrisse (Vec2[] je Raum), CCW orientiert.
*
* Ablauf: planarer Graph aus Mittellinien → Face-Extraktion → Aussenmasche
* verwerfen → jede innere Masche um halbe (mittlere) Wanddicke nach innen
* versetzen. Kleine Lücken werden über `gapTol` zusammengeschnappt.
*/
export function detectRooms(
walls: WallSegment[],
options: DetectRoomsOptions = {},
): Vec2[][] {
const gapTol = options.gapTol ?? 0.05;
const minArea = options.minArea ?? 0.05;
const offsetToInner = options.offsetToInner ?? true;
if (walls.length < 3) return [];
const { nodes, edges } = buildPlanarGraph(
walls.map((w) => ({ a: w.a, b: w.b })),
gapTol,
);
if (edges.length < 3) return [];
const faces = extractFaces(nodes, edges);
const half = averageThickness(walls) / 2;
const rooms: Vec2[][] = [];
for (const f of faces) {
let poly = f.map((n) => nodes[n].p);
poly = dedupeRing(poly);
if (poly.length < 3) continue;
if (offsetToInner && half > 0) {
poly = dedupeRing(offsetInward(poly, half));
if (poly.length < 3) continue;
}
if (polygonArea(poly) < minArea) continue;
// Kanonisch CCW zurückgeben.
if (signedArea(poly) < 0) poly = poly.reverse();
rooms.push(poly);
}
return rooms;
}
// ── Klick-in-Raum: Umlauf-Tracer um einen Saatpunkt ──────────────────────────
/** Eine Wand-Innenfläche (Face) als gerichtete/ungerichtete Strecke. */
export interface WallFace {
a: Vec2;
b: Vec2;
}
/**
* Prüft, ob ein Punkt in einem Polygon liegt (Ray-Casting, ungerade Kreuzungen).
*/
export function pointInPolygon(p: Vec2, poly: Vec2[]): boolean {
let inside = false;
const n = poly.length;
for (let i = 0, j = n - 1; i < n; j = i++) {
const a = poly[i];
const b = poly[j];
const intersects =
a.y > p.y !== b.y > p.y &&
p.x < ((b.x - a.x) * (p.y - a.y)) / (b.y - a.y + 0) + a.x;
if (intersects) inside = !inside;
}
return inside;
}
/**
* Klick-in-Raum-Fallback: findet die Masche, die den Saatpunkt einschliesst, und
* gibt ihren lichten Innen-Umriss zurück (oder null, wenn der Punkt in keiner
* geschlossenen Zelle liegt). Robust gegen T-Stösse; kleine Lücken werden über
* `gapTol` geschlossen. Nutzt intern dieselbe Arrangement-/Face-Extraktion und
* wählt die Masche mit kleinster Fläche, die den Punkt enthält (die unmittelbar
* umgebende Zelle statt einer grösseren umschliessenden).
*/
export function roomFromPointInside(
point: Vec2,
walls: WallSegment[],
options: DetectRoomsOptions = {},
): Vec2[] | null {
const gapTol = options.gapTol ?? 0.05;
const offsetToInner = options.offsetToInner ?? true;
if (walls.length < 3) return null;
const { nodes, edges } = buildPlanarGraph(
walls.map((w) => ({ a: w.a, b: w.b })),
gapTol,
);
if (edges.length < 3) return null;
const faces = extractFaces(nodes, edges);
const half = averageThickness(walls) / 2;
let best: Vec2[] | null = null;
let bestArea = Infinity;
for (const f of faces) {
let raw = dedupeRing(f.map((n) => nodes[n].p));
if (raw.length < 3) continue;
// Test gegen die ROHE Mittellinien-Masche (schliesst den Klickpunkt weiter ein).
if (!pointInPolygon(point, raw)) continue;
const area = polygonArea(raw);
if (area >= bestArea) continue;
let inner = raw;
if (offsetToInner && half > 0) {
inner = dedupeRing(offsetInward(raw, half));
if (inner.length < 3) inner = raw;
}
if (signedArea(inner) < 0) inner = inner.reverse();
best = inner;
bestArea = area;
}
return best;
}
/**
* Variante des Klick-Tracers, die direkt auf bereits berechneten Wand-INNEN-
* FLÄCHEN (Faces) arbeitet: die Strecken bilden bereits die lichten Wandseiten.
* Kein Innen-Offset (die Faces sind schon die Innenkontur). Für Aufrufer, die
* die Wandflächen ohnehin vorhalten (z. B. aus dem Wand-Rendering).
*/
export function roomFromPointInsideFaces(
point: Vec2,
wallFaces: WallFace[],
gapTol = 0.05,
): Vec2[] | null {
if (wallFaces.length < 3) return null;
const { nodes, edges } = buildPlanarGraph(
wallFaces.map((f) => ({ a: f.a, b: f.b })),
gapTol,
);
if (edges.length < 3) return null;
const faces = extractFaces(nodes, edges);
let best: Vec2[] | null = null;
let bestArea = Infinity;
for (const f of faces) {
const poly = dedupeRing(f.map((n) => nodes[n].p));
if (poly.length < 3) continue;
if (!pointInPolygon(point, poly)) continue;
const area = polygonArea(poly);
if (area < bestArea) {
best = signedArea(poly) < 0 ? poly.reverse() : poly;
bestArea = area;
}
}
return best;
}
+411
View File
@@ -0,0 +1,411 @@
// Geometrie-Helfer für Treppen (Stairs) — reine Funktionen über `Vec2`, kein
// externer Kernel. Aus einer `Stair` (gerade/L/Wendel) werden abgeleitet:
// • die Tritt-Rechtecke im Grundriss (jeweils mit ihrer Z-Höhe der Setzstufe),
// • das Zwischenpodest-Polygon (L / Wendel-Auge),
// • die Lauflinie (Polyline) + der Auf-/Abpfeil,
// • die Schnittposition (welche Tritte unter/über der Grundriss-Schnitthöhe
// liegen → im Plan durchgezogen vs. gestrichelt) samt SIA-Bruchlinie.
//
// Die 3D-Darstellung baut aus denselben Tritt-Rechtecken + Setzstufen-Höhen die
// Stufenboxen (siehe Viewport3D). So bleiben Plan und 3D konsistent aus EINER
// Quelle abgeleitet.
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import type { Stair, Vec2 } from "../model/types";
const DEG = Math.PI / 180;
const sub = (a: Vec2, b: Vec2): Vec2 => ({ x: a.x - b.x, y: a.y - b.y });
const add = (a: Vec2, b: Vec2): Vec2 => ({ x: a.x + b.x, y: a.y + b.y });
const scale = (a: Vec2, s: number): Vec2 => ({ x: a.x * s, y: a.y * s });
const lenOf = (a: Vec2): number => Math.hypot(a.x, a.y);
const norm = (a: Vec2): Vec2 => {
const l = lenOf(a) || 1e-9;
return { x: a.x / l, y: a.y / l };
};
/** Linke Normale (n = (u.y, u.x)) — quer zur Laufrichtung. */
const leftN = (u: Vec2): Vec2 => ({ x: -u.y, y: u.x });
/** SIA-nahe Schrittregel: gute Steigungshöhe ≈ 0.17 m, Auftritt ≈ 0.29 m. */
export const IDEAL_RISER = 0.17;
export const IDEAL_TREAD = 0.29;
export const MIN_STEPS = 2;
/**
* Sinnvolle Default-Stufenzahl aus Steighöhe + verfügbarer Lauflänge: möglichst
* nahe an der idealen Steigungshöhe, gedeckelt durch die Lauflänge (min.
* Auftritt ~0.24 m). Immer ≥ MIN_STEPS.
*/
export function defaultStepCount(totalRise: number, runLength: number): number {
const byRise = Math.max(MIN_STEPS, Math.round(totalRise / IDEAL_RISER));
// Auftrittstiefe = runLength / (steps 1); mind. 0.24 m halten.
const maxByRun = runLength > 0 ? Math.max(MIN_STEPS, Math.floor(runLength / 0.24) + 1) : byRise;
return Math.max(MIN_STEPS, Math.min(byRise, maxByRun));
}
/** Ein Tritt im Grundriss (Rechteck-Polygon) samt Setzstufen-Z (Oberkante). */
export interface TreadRect {
/** Vier Eckpunkte (CCW), Grundriss-Meter. */
pts: Vec2[];
/** Index der Stufe (0 = unterste). */
index: number;
/** Oberkante dieses Tritts relativ zur Treppen-UK (Meter). */
topRise: number;
/** Unterkante-Höhe (Setzstufe darunter) relativ zur UK (Meter). */
baseRise: number;
}
/** Vollständige, abgeleitete Treppengeometrie (Plan + 3D + Schnitt). */
export interface StairGeometry {
/** Alle Tritt-Rechtecke (untere → obere Stufe). */
treads: TreadRect[];
/** Zwischenpodest-Polygon (L/Wendel) oder null (gerade). */
landing: Vec2[] | null;
/** Setzstufen-Höhe (Meter). */
riserHeight: number;
/** Auftrittstiefe (Meter) des geraden Laufs. */
treadDepth: number;
/** Lauflinie als Polyline (Meter) — Mitte des Laufs, Start → Austritt. */
runLine: Vec2[];
/** Auf-/Abpfeil-Geometrie (Schaft + zwei Spitzenflügel). */
arrow: { shaft: [Vec2, Vec2]; head: [Vec2, Vec2, Vec2] };
/** Gesamt-Steighöhe (Meter). */
totalRise: number;
}
/** Rechteck-Tritt zwischen Achsparametern a0..a1 entlang `dir`, Breite `width`. */
function treadRect(
origin: Vec2,
dir: Vec2,
n: Vec2,
a0: number,
a1: number,
halfW: number,
): Vec2[] {
const p0 = add(origin, scale(dir, a0));
const p1 = add(origin, scale(dir, a1));
return [
add(p0, scale(n, halfW)),
add(p1, scale(n, halfW)),
add(p1, scale(n, -halfW)),
add(p0, scale(n, -halfW)),
];
}
/**
* Leitet die vollständige Geometrie einer Treppe ab. `totalRise` MUSS vom
* Aufrufer aufgelöst übergeben werden (Geschosshöhe-Default liegt in
* `stairVerticalExtent`), damit dieses Modul three-/projektfrei bleibt.
*/
export function stairGeometry(stair: Stair, totalRise: number): StairGeometry {
const steps = Math.max(MIN_STEPS, Math.round(stair.stepCount));
const riserHeight = totalRise / steps;
const halfW = Math.max(0.05, stair.width / 2);
const u = norm(stair.dir);
const n = leftN(u);
if (stair.shape === "spiral") {
return spiralGeometry(stair, totalRise, steps, riserHeight, halfW);
}
if (stair.shape === "L") {
return lGeometry(stair, totalRise, steps, riserHeight, halfW, u, n);
}
return straightGeometry(stair, totalRise, steps, riserHeight, halfW, u, n);
}
/** Gerade Treppe: gleich breite Tritte über die Lauflänge. */
function straightGeometry(
stair: Stair,
totalRise: number,
steps: number,
riserHeight: number,
halfW: number,
u: Vec2,
n: Vec2,
): StairGeometry {
const runLength = Math.max(0.1, stair.runLength);
const treadDepth = runLength / steps;
const treads: TreadRect[] = [];
for (let i = 0; i < steps; i++) {
treads.push({
pts: treadRect(stair.start, u, n, i * treadDepth, (i + 1) * treadDepth, halfW),
index: i,
baseRise: i * riserHeight,
topRise: (i + 1) * riserHeight,
});
}
const runLine: Vec2[] = [
add(stair.start, scale(u, treadDepth * 0.5)),
add(stair.start, scale(u, runLength - treadDepth * 0.15)),
];
return {
treads,
landing: null,
riserHeight,
treadDepth,
runLine,
arrow: arrowFor(runLine, stair.up !== false),
totalRise,
};
}
/** L-Treppe: zwei Läufe + quadratisches Zwischenpodest an der Ecke. */
function lGeometry(
stair: Stair,
totalRise: number,
steps: number,
riserHeight: number,
halfW: number,
u: Vec2,
n: Vec2,
): StairGeometry {
const run1 = Math.max(0.1, stair.runLength);
const run2 = Math.max(0.1, stair.run2Length ?? stair.runLength);
const turn = stair.turn ?? 1;
// Podest quadratisch (Kantenlänge = Laufbreite) am Ende des ersten Laufs.
const podest = 2 * halfW;
// Stufen auf beide Läufe verteilen (grob proportional zur Lauflänge), ein Tritt
// fällt aufs Podest.
const bodySteps = steps - 1; // ein Tritt = Podest
const s1 = Math.max(1, Math.round((bodySteps * run1) / (run1 + run2)));
const s2 = Math.max(1, bodySteps - s1);
const td1 = run1 / s1;
const td2 = run2 / s2;
const treads: TreadRect[] = [];
let rise = 0;
for (let i = 0; i < s1; i++) {
treads.push({
pts: treadRect(stair.start, u, n, i * td1, (i + 1) * td1, halfW),
index: i,
baseRise: rise,
topRise: rise + riserHeight,
});
rise += riserHeight;
}
// Zweiter Lauf beginnt am Ende des Podests. Richtung um 90° gedreht (turn).
const cornerCenter = add(stair.start, scale(u, run1 + halfW));
const u2: Vec2 = turn > 0 ? n : scale(n, -1); // links/rechts abbiegen
const n2 = leftN(u2);
// Podest-Tritt (quadratisch um cornerCenter, an u/u2 ausgerichtet).
const landing: Vec2[] = [
add(add(cornerCenter, scale(u, -halfW)), scale(n, halfW)),
add(add(cornerCenter, scale(u, halfW)), scale(n, halfW)),
add(add(cornerCenter, scale(u, halfW)), scale(n, -halfW)),
add(add(cornerCenter, scale(u, -halfW)), scale(n, -halfW)),
];
const podestTopRise = rise + riserHeight;
rise += riserHeight;
// Startpunkt des zweiten Laufs: Podest-Kante in Richtung u2.
const run2Start = add(cornerCenter, scale(u2, halfW));
for (let i = 0; i < s2; i++) {
treads.push({
pts: treadRect(run2Start, u2, n2, i * td2, (i + 1) * td2, halfW),
index: s1 + 1 + i,
baseRise: rise,
topRise: rise + riserHeight,
});
rise += riserHeight;
}
void podest;
void podestTopRise;
const runLine: Vec2[] = [
add(stair.start, scale(u, td1 * 0.5)),
cornerCenter,
add(run2Start, scale(u2, run2 - td2 * 0.15)),
];
return {
treads,
landing,
riserHeight,
treadDepth: td1,
runLine,
arrow: arrowFor(runLine, stair.up !== false),
totalRise,
};
}
/** Wendeltreppe: keilförmige Tritte um ein Zentrum (vereinfachtes Modell). */
function spiralGeometry(
stair: Stair,
totalRise: number,
steps: number,
riserHeight: number,
halfW: number,
): StairGeometry {
const center = stair.center ?? stair.start;
const radius = Math.max(halfW + 0.1, stair.radius ?? stair.width);
const sweep = (stair.sweep ?? 270) * DEG;
// Startwinkel aus start→center (Lauflinie beginnt am Startpunkt).
const startVec = sub(stair.start, center);
const a0 = Math.atan2(startVec.y, startVec.x);
const dA = sweep / steps;
const rIn = Math.max(0.02, radius - halfW);
const rOut = radius + halfW;
const treads: TreadRect[] = [];
for (let i = 0; i < steps; i++) {
const t0 = a0 + i * dA;
const t1 = a0 + (i + 1) * dA;
// Keil-Viereck: innen(t0) → außen(t0) → außen(t1) → innen(t1).
const pts: Vec2[] = [
{ x: center.x + rIn * Math.cos(t0), y: center.y + rIn * Math.sin(t0) },
{ x: center.x + rOut * Math.cos(t0), y: center.y + rOut * Math.sin(t0) },
{ x: center.x + rOut * Math.cos(t1), y: center.y + rOut * Math.sin(t1) },
{ x: center.x + rIn * Math.cos(t1), y: center.y + rIn * Math.sin(t1) },
];
treads.push({
pts,
index: i,
baseRise: i * riserHeight,
topRise: (i + 1) * riserHeight,
});
}
// Lauflinie = Bogen auf Radius `radius` (mittlere Lauflinie).
const runLine: Vec2[] = [];
const N = Math.max(2, steps);
for (let i = 0; i <= N; i++) {
const tt = a0 + (sweep * i) / N;
runLine.push({ x: center.x + radius * Math.cos(tt), y: center.y + radius * Math.sin(tt) });
}
return {
treads,
// Wendel-Auge (Innenkreis) als „Podest"-Polygon.
landing: eyePolygon(center, rIn),
riserHeight,
treadDepth: (2 * Math.PI * radius * (sweep / (2 * Math.PI))) / steps,
runLine,
arrow: arrowFor(runLine, stair.up !== false),
totalRise,
};
}
/** Achteck-Approximation des Wendel-Auges (Innenkreis). */
function eyePolygon(center: Vec2, r: number): Vec2[] {
const pts: Vec2[] = [];
for (let i = 0; i < 12; i++) {
const t = (i / 12) * Math.PI * 2;
pts.push({ x: center.x + r * Math.cos(t), y: center.y + r * Math.sin(t) });
}
return pts;
}
/**
* Auf-/Abpfeil aus der Lauflinie: Schaft = erstes→letztes Segment, Spitze am
* oberen (bzw. bei `up=false` am unteren) Ende. `up` kehrt die Richtung um.
*/
function arrowFor(
runLine: Vec2[],
up: boolean,
): { shaft: [Vec2, Vec2]; head: [Vec2, Vec2, Vec2] } {
const a = runLine[0];
const b = runLine[runLine.length - 1];
const tip = up ? b : a;
const from = up ? runLine[runLine.length - 2] : runLine[1];
const dir = norm(sub(tip, from));
const n = leftN(dir);
const s = 0.22; // Flügellänge (Meter)
const back = add(tip, scale(dir, -s));
const w1 = add(back, scale(n, s * 0.55));
const w2 = add(back, scale(n, -s * 0.55));
return { shaft: [a, b], head: [w1, tip, w2] };
}
/**
* Schnitt-Aufteilung im Grundriss: DOSSIER schneidet die Treppe auf `cutRise`
* (Schnitthöhe über OKFF). Tritte mit Oberkante ≤ cutRise werden durchgezogen
* gezeichnet (unterhalb der Schnittebene), darüberliegende gestrichelt (oberhalb,
* verdeckt). Der ERSTE Tritt oberhalb markiert die SIA-Bruchlinie (diagonaler
* Doppelstrich über die Laufbreite).
*/
export interface StairCut {
/** Indizes der Tritte UNTER der Schnittebene (durchgezogen). */
belowIndices: number[];
/** Indizes der Tritte ÜBER der Schnittebene (gestrichelt/verdeckt). */
aboveIndices: number[];
/** Bruchlinie (SIA): zwei parallele Diagonalstriche über die Laufbreite. */
breakLine: [Vec2, Vec2][] | null;
}
/**
* Bestimmt die Schnittaufteilung. `cutRise` = Schnitthöhe über der Treppen-UK.
* Findet den ersten Tritt, dessen Oberkante die Schnittebene überschreitet, und
* legt dort die Bruchlinie quer über den Lauf.
*/
export function stairCut(geo: StairGeometry, cutRise: number): StairCut {
const below: number[] = [];
const above: number[] = [];
let breakIdx = -1;
for (const tr of geo.treads) {
if (tr.topRise <= cutRise + 1e-6) {
below.push(tr.index);
} else {
if (breakIdx < 0) breakIdx = tr.index;
above.push(tr.index);
}
}
let breakLine: [Vec2, Vec2][] | null = null;
if (breakIdx >= 0) {
const tr = geo.treads.find((t) => t.index === breakIdx);
if (tr) {
// Diagonale Bruchlinie über den Tritt: von einer Ecke zur gegenüberliegenden,
// als Doppelstrich (leicht versetzt) — SIA-Treppen-Schnittsymbol.
const [c0, c1, c2, c3] = tr.pts;
const mid01 = scale(add(c0, c1), 0.5);
const mid23 = scale(add(c2, c3), 0.5);
const off = scale(norm(sub(c1, c0)), 0.06);
breakLine = [
[add(mid01, off), add(mid23, off)],
[sub(mid01, off), sub(mid23, off)],
];
}
}
return { belowIndices: below, aboveIndices: above, breakLine };
}
/** Achsparallele Bounding-Box aller Tritte (+ Podest) einer Treppengeometrie. */
export function stairBBox(geo: StairGeometry): {
minX: number;
minY: number;
maxX: number;
maxY: number;
} {
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity;
const acc = (p: Vec2) => {
minX = Math.min(minX, p.x);
minY = Math.min(minY, p.y);
maxX = Math.max(maxX, p.x);
maxY = Math.max(maxY, p.y);
};
for (const tr of geo.treads) for (const p of tr.pts) acc(p);
if (geo.landing) for (const p of geo.landing) acc(p);
if (!isFinite(minX)) return { minX: 0, minY: 0, maxX: 0, maxY: 0 };
return { minX, minY, maxX, maxY };
}
/** Punkt-in-Polygon (Ray-Casting) — für die Body-Auswahl im Grundriss/3D. */
export function pointInPolygon(p: Vec2, poly: Vec2[]): boolean {
let inside = false;
const nn = poly.length;
for (let i = 0, j = nn - 1; i < nn; j = i++) {
const a = poly[i];
const b = poly[j];
const intersect =
a.y > p.y !== b.y > p.y &&
p.x < ((b.x - a.x) * (p.y - a.y)) / (b.y - a.y || 1e-12) + a.x;
if (intersect) inside = !inside;
}
return inside;
}
/** Ob ein Modellpunkt IRGENDEINEN Tritt (oder das Podest) einer Treppe trifft. */
export function pointHitsStair(p: Vec2, geo: StairGeometry): boolean {
for (const tr of geo.treads) if (pointInPolygon(p, tr.pts)) return true;
if (geo.landing && pointInPolygon(p, geo.landing)) return true;
return false;
}
+268
View File
@@ -49,6 +49,10 @@ export const de = {
"topbar.fit.hint": "Auf Plan einpassen",
"topbar.fitSelection": "Auswahl",
"topbar.fitSelection.hint": "Auf Auswahl einpassen (sonst auf Plan)",
"topbar.exportPdf": "PDF",
"topbar.exportPdf.hint": "Grundriss als Vektor-PDF exportieren",
"topbar.exportDxf": "DXF",
"topbar.exportDxf.hint": "Grundriss als DXF (Modell-Space in Metern) exportieren",
"topbar.zoom.current": "Aktueller Zoom",
"topbar.renderGroup": "Render-Modus",
@@ -68,6 +72,7 @@ export const de = {
"display.style.mono": "Schwarz-Weiss",
"display.style.shaded": "Schattiert",
"display.style.white": "Weiss",
"display.style.textured": "Texturiert",
"display.style.wireframe": "Drahtgitter",
"display.style.hidden": "Kanten",
@@ -103,12 +108,23 @@ export const de = {
"tool.group": "Werkzeuge",
"tool.select": "Auswahl",
"tool.wall": "Wand",
"tool.ceiling": "Decke",
"tool.window": "Fenster",
"tool.door": "Tür",
"tool.stair": "Treppe",
"tool.line": "Linie",
"tool.polyline": "Polylinie",
"tool.rect": "Rechteck",
"tool.select.hint": "Auswahl — Elemente klicken/aufziehen",
"tool.window.hint": "Fenster: Wand wählen, dann Position setzen",
"tool.door.hint": "Tür: Wand wählen, dann Position setzen",
"tool.stair.hint": "Treppe: Startpunkt, dann Laufrichtung/Ende setzen",
"tool.room": "Raum",
"tool.room.hint": "Raum: in umschlossenen Bereich klicken (oder Umriss zeichnen)",
"tool.wall.firstPoint": "Wand: ersten Achspunkt setzen",
"tool.wall.nextPoint": "Wand: nächsten Punkt — Doppelklick/Rechtsklick beendet",
"tool.ceiling.firstPoint": "Decke: ersten Umrisspunkt setzen",
"tool.ceiling.nextPoint": "Decke: nächster Punkt — Klick auf Start schließt, Enter beendet",
"tool.line.firstPoint": "Linie: Startpunkt setzen",
"tool.line.secondPoint": "Linie: Endpunkt setzen",
"tool.polyline.firstPoint": "Polylinie: ersten Punkt setzen",
@@ -116,6 +132,7 @@ export const de = {
"tool.rect.firstCorner": "Rechteck: erste Ecke setzen",
"tool.rect.secondCorner": "Rechteck: gegenüberliegende Ecke setzen",
"tool.wallType": "Wandtyp",
"tool.ceilingType": "Deckentyp",
"tool.layer": "Ebene",
"tool.layer.hint": "Aktive Ebene — alles Gezeichnete kommt auf diese Kategorie",
"tool.floorOnlyDisabled": "Wand nur auf einem Geschoss zeichenbar",
@@ -209,8 +226,12 @@ export const de = {
"objinfo.height": "Höhe",
"objinfo.type": "Typ",
"objinfo.kind.wall": "Wand",
"objinfo.kind.ceiling": "Decke",
"objinfo.kind.drawing2d": "2D-Objekt",
"objinfo.kind.door": "Tür",
"objinfo.kind.opening": "Öffnung",
"objinfo.kind.stair": "Treppe",
"objinfo.kind.room": "Raum",
// ── Wand-Attribute (Object-Info-Panel) ──────────────────────────────────
"objinfo.wall.section": "Wand",
"objinfo.wall.refLine": "Referenzlinie",
@@ -230,6 +251,65 @@ export const de = {
"objinfo.wall.anchorFloor": "An Geschoss gebunden",
"objinfo.wall.anchorCustom": "Eigene Höhe",
"objinfo.wall.height": "Höhe",
// ── Decken-Attribute (Object-Info-Panel) ────────────────────────────────
"objinfo.ceiling.section": "Decke",
"objinfo.ceiling.buildup": "Aufbau",
"objinfo.ceiling.thickness": "Dicke",
"objinfo.ceiling.preset": "Deckenaufbau",
"objinfo.ceiling.refFloor": "Referenzgeschoss",
"objinfo.ceiling.top": "Oberkante (OK)",
"objinfo.ceiling.bottom": "Unterkante (UK)",
"objinfo.ceiling.height": "Dicke (OKUK)",
"objinfo.ceiling.area": "Fläche",
"objinfo.ceiling.anchorFloor": "An Geschoss gebunden",
"objinfo.ceiling.anchorCustom": "Eigene Höhe",
// ── Öffnungs-Attribute (Object-Info-Panel) ──────────────────────────────
"objinfo.opening.section": "Öffnung",
"objinfo.opening.kind": "Art",
"objinfo.opening.kind.window": "Fenster",
"objinfo.opening.kind.door": "Tür",
"objinfo.opening.hostWall": "Wirts-Wand",
"objinfo.opening.position": "Position (ab Wandanfang)",
"objinfo.opening.width": "Breite",
"objinfo.opening.height": "Höhe",
"objinfo.opening.sill": "Brüstung",
"objinfo.opening.swingAngle": "Öffnungswinkel",
"objinfo.opening.hinge": "Anschlag",
"objinfo.opening.hinge.start": "Anfang",
"objinfo.opening.hinge.end": "Ende",
"objinfo.opening.swing": "Aufschlagseite",
"objinfo.opening.swing.left": "Links",
"objinfo.opening.swing.right": "Rechts",
"objinfo.opening.dir": "Richtung",
"objinfo.opening.dir.in": "Innen",
"objinfo.opening.dir.out": "Außen",
"objinfo.opening.bottom": "Unterkante (UK)",
"objinfo.opening.top": "Oberkante (OK)",
// ── Treppen-Attribute (Object-Info-Panel) ───────────────────────────────
"objinfo.stair.section": "Treppe",
"objinfo.stair.shape": "Grundform",
"objinfo.stair.shape.straight": "Gerade",
"objinfo.stair.shape.L": "L-Treppe",
"objinfo.stair.shape.spiral": "Wendel",
"objinfo.stair.width": "Laufbreite",
"objinfo.stair.steps": "Stufenanzahl",
"objinfo.stair.rise": "Steighöhe (gesamt)",
"objinfo.stair.riser": "Steigungshöhe",
"objinfo.stair.tread": "Auftrittstiefe",
"objinfo.stair.direction": "Laufrichtung",
"objinfo.stair.direction.up": "Aufwärts",
"objinfo.stair.direction.down": "Abwärts",
"objinfo.stair.refFloor": "Referenzgeschoss",
"objinfo.stair.bottom": "Unterkante (UK)",
"objinfo.stair.top": "Oberkante (OK)",
// ── Raum-Attribute (Object-Info-Panel) ──────────────────────────────────
"objinfo.room.section": "Raum",
"objinfo.room.name": "Name",
"objinfo.room.sia": "SIA-Kategorie",
"objinfo.room.area": "Fläche",
"objinfo.room.perimeter": "Umfang",
"objinfo.room.refFloor": "Referenzgeschoss",
"objinfo.room.editStamp": "Stempel bearbeiten …",
"objinfo.wall.floorAboveNone": "Kein oberes Geschoss",
"levels.addFloor": "+ Geschoss",
"levels.addDrawing": "+ Zeichnung",
@@ -280,6 +360,11 @@ export const de = {
"ctx.fitSelection": "Auf Auswahl einpassen",
"ctx.clearSelection": "Auswahl aufheben",
"ctx.fit": "Ansicht einpassen",
"ctx.union": "Vereinigen",
"ctx.subtract": "Subtrahieren",
"ctx.intersect": "Schneiden",
"ctx.boolean.disabled":
"Mindestens zwei geschlossene Flächen auswählen (Rechteck oder geschlossene Polylinie).",
"ctx.noWallHit": "Keine Wand getroffen",
"ctx.noWallHit.hint": "Rechtsklick auf eine Wand wählt sie und zeigt Wand-Aktionen.",
@@ -358,8 +443,47 @@ export const de = {
"resources.col.prio": "Prio",
"resources.col.prio.hint": "Höhere Priorität läuft am Stoß durch",
"resources.col.texture": "Textur",
"resources.col.material": "Material",
"resources.delete.component": "Bauteil „{name}\" löschen",
// 3D-Material (PBR) — Bibliotheks-Auswahl + Upload.
"material.assign": "Material …",
"material.none": "— ohne —",
"material.dialog.title": "3D-Material zuweisen",
"material.dialog.hint": "Wähle ein eingebautes Material oder lade eigene Texturen hoch. Wirkt im 3D-Modus „Texturiert\".",
"material.library": "Bibliothek (ambientCG, CC0)",
"material.upload": "Eigene Texturen",
"material.upload.hint": "Lade Bilder als Karten hoch (mindestens Farbe). Größe = physische Kachelgröße in Metern.",
"material.upload.color": "Farbe (Albedo)",
"material.upload.normal": "Normal",
"material.upload.roughness": "Rauheit",
"material.upload.displacement": "Höhe",
"material.upload.ao": "AO",
"material.upload.choose": "Bild …",
"material.upload.apply": "Zuweisen",
"material.size": "Kachelgröße (m)",
"material.current": "Aktuell",
"material.clear": "Material entfernen",
"material.close": "Schließen",
"material.uploaded": "Hochgeladen",
// ── ambientCG-Bibliothek durchsuchen (Live) ──────────────────────────────
"material.browse": "Bibliothek durchsuchen",
"material.browse.starter": "Schnellauswahl",
"material.browse.hint":
"Durchsuche die komplette CC0-Bibliothek von ambientCG (~2000+ Materialien). Ein Klick lädt die Texturen zur Laufzeit herunter und weist sie zu.",
"material.browse.search": "Suchen …",
"material.browse.searchPlaceholder": "z. B. Holz, Beton, Fliesen …",
"material.browse.category.all": "Alle Kategorien",
"material.browse.resolution": "Auflösung",
"material.browse.more": "Mehr laden",
"material.browse.loading": "Lädt …",
"material.browse.downloading": "Textur wird geladen …",
"material.browse.empty": "Keine Materialien gefunden.",
"material.browse.count": "{shown} von {total}",
"material.browse.error":
"Bibliothek nicht erreichbar. Zur Laufzeit ist ein Proxy für ambientCG nötig (Dev: Vite-Proxy aktiv; Prod: VITE_AMBIENTCG_PROXY setzen).",
"material.browse.downloadError": "Download fehlgeschlagen (CORS/Proxy prüfen).",
// Schraffuren.
"resources.hatches.hint": "Muster, Maßstab und Winkel steuern die Schnitt-Schraffur; der Linienstil bestimmt die Stärke der Musterlinien.",
"resources.hatches.empty": "Noch keine Schraffuren.",
@@ -397,6 +521,30 @@ export const de = {
"cmd.aria": "Befehlszeile",
"cmd.idle.prompt": "Befehl:",
"cmd.idle.placeholder": "Befehl eingeben … (Tab)",
"cmd.wall.label": "Wand",
"cmd.wall.start": "Startpunkt der Wand:",
"cmd.wall.next": "Nächster Punkt:",
"cmd.ceiling.label": "Decke",
"cmd.ceiling.start": "Erster Umrisspunkt der Decke:",
"cmd.ceiling.next": "Nächster Punkt ( Schliessen ):",
"cmd.opening.label": "Öffnung",
"cmd.opening.windowLabel": "Fenster",
"cmd.opening.doorLabel": "Tür",
"cmd.opening.wall": "Wand für die Öffnung wählen:",
"cmd.opening.pos": "Position entlang der Wand ( Abstand tippen ):",
"cmd.opening.kind": "Art",
"cmd.opening.hinge": "Anschlag",
"cmd.opening.side": "Seite",
"cmd.opening.dir": "Richtung",
"cmd.opening.sillField": "Brüstung",
"cmd.opening.swingField": "Winkel",
"cmd.stair.label": "Treppe",
"cmd.stair.start": "Startpunkt der Treppe:",
"cmd.stair.run": "Laufrichtung / Ende ( Grundform · Auf/Ab ):",
"cmd.stair.shape": "Grundform",
"cmd.stair.updown": "Auf/Ab",
"cmd.stair.stepsField": "Stufen",
"cmd.stair.riseField": "Steighöhe",
"cmd.line.label": "Linie",
"cmd.line.start": "Startpunkt der Linie:",
"cmd.line.end": "Endpunkt der Linie:",
@@ -405,9 +553,15 @@ export const de = {
"cmd.polyline.next": "Nächster Punkt:",
"cmd.polyline.close": "Schliessen",
"cmd.polyline.undo": "Zurück",
"cmd.polyline.closedOpt": "Geschlossen",
"cmd.rect.label": "Rechteck",
"cmd.rect.first": "Erste Ecke:",
"cmd.rect.second": "Gegenüberliegende Ecke:",
"cmd.rect.method": "Methode",
"cmd.rect.center": "Mittelpunkt:",
"cmd.rect.corner": "Ecke:",
"cmd.rect.baseEnd": "Zweite Ecke (Basiskante):",
"cmd.rect.rise": "Höhe:",
"cmd.circle.label": "Kreis",
"cmd.circle.center": "Mittelpunkt:",
"cmd.circle.radius": "Radius:",
@@ -416,6 +570,11 @@ export const de = {
"cmd.move.base": "Basispunkt:",
"cmd.move.target": "Zielpunkt:",
"cmd.move.empty": "Nichts gewählt (erst Objekte wählen).",
"cmd.mirror.label": "Spiegeln",
"cmd.mirror.first": "Erster Punkt der Spiegelachse:",
"cmd.mirror.second": "Zweiter Punkt der Spiegelachse:",
"cmd.mirror.empty": "Nichts gewählt (erst Objekte wählen).",
"cmd.join.prompt": "Verbinde gewählte Elemente …",
"cmd.copy.label": "Kopieren",
"cmd.copy.base": "Basispunkt:",
"cmd.copy.target": "Zielpunkt (Enter beendet):",
@@ -423,6 +582,9 @@ export const de = {
"cmd.offset.label": "Versatz",
"cmd.offset.pick": "Kurve wählen:",
"cmd.offset.side": "Seite oder Distanz:",
// Trim (Quick-Trim): auf den wegzuschneidenden Abschnitt klicken; wiederholend.
"cmd.trim.label": "Stutzen",
"cmd.trim.pick": "Abschnitt zum Wegschneiden anklicken (Esc beendet):",
// Editier-Werkzeuge Split / Join / Segment-Löschen (Tastatur: Ctrl+S / Ctrl+J /
// Alt+Klick). Labels für Befehlsleiste/Menüs.
"edit.split.label": "Teilen",
@@ -435,6 +597,8 @@ export const de = {
"cmd.field.height": "Höhe",
"cmd.field.radius": "Radius",
"cmd.field.distance": "Distanz",
"cmd.field.thickness": "Dicke",
"cmd.edit.point": "Griff ziehen (Tab: Länge/Winkel):",
// ── Befehle: Import / Gelände ────────────────────────────────────────────
"cmd.import.label": "Importieren",
@@ -442,6 +606,17 @@ export const de = {
"cmd.terrain.label": "Gelände",
"cmd.terrain.prompt": "Gelände aus Konturen erzeugen …",
// ── Flächenbilanz (SIA 416) ──────────────────────────────────────────────
"nav.roomBalance": "Flächenbilanz",
"balance.title": "Flächenbilanz (SIA 416)",
"balance.rooms": "Räume",
"balance.summary": "Bilanz",
"balance.export": "CSV exportieren",
"balance.empty": "Keine Räume vorhanden. Mit dem Raum-Werkzeug erstellen.",
// ── Raum-Stempel-Editor ──────────────────────────────────────────────────
"room.stamp.editTitle": "Raum-Stempel bearbeiten",
"room.stamp.close": "Schliessen",
// ── Site-/Kontext-Panel (SitePanel) ──────────────────────────────────────
"nav.site": "Gelände",
"site.title": "Gelände & Kontext",
@@ -487,6 +662,28 @@ export const de = {
"import.badge.elevation": "Ansicht",
"import.badge.drawing": "Zeichnung",
// ── PDF-Export ───────────────────────────────────────────────────────────
"export.title": "PDF exportieren",
"export.close": "Schließen",
"export.plan": "Plan",
"export.scale": "Massstab",
"export.paper": "Papierformat",
"export.orientation": "Ausrichtung",
"export.orientation.portrait": "Hochformat",
"export.orientation.landscape": "Querformat",
"export.warnFit":
"Der Plan passt im gewählten Massstab nicht ganz auf das Blatt — größeres Format oder kleineren Massstab wählen.",
"export.confirm": "Exportieren",
"export.cancel": "Abbrechen",
// ── DXF-Export ───────────────────────────────────────────────────────────
"exportDxf.title": "DXF exportieren",
"exportDxf.hatches": "Schraffuren",
"exportDxf.hatches.yes": "Mitexportieren",
"exportDxf.hatches.no": "Nur Umrisse",
"exportDxf.unitsNote":
"Modell-Space in Metern (1:1). Layer entsprechen den Ebenen des Projekts; beim Plot beliebig skalierbar.",
// ── Drag & Drop (App-weiter Datei-Drop) ──────────────────────────────────
"drop.hint": "DXF/DWG-Datei hier ablegen",
@@ -495,6 +692,77 @@ export const de = {
"error.title": "Etwas ist schiefgelaufen",
"error.body": "Ein unerwarteter Fehler ist aufgetreten. Details in der Konsole.",
"error.reload": "Neu laden",
// ── Standort-Import (geo.admin / OSM) ─────────────────────────────────────
"site.importLocation": "Standort importieren",
"ctxImport.title": "Standort importieren",
"ctxImport.close": "Schließen",
"ctxImport.location": "Ort / Adresse",
"ctxImport.searchPlaceholder": "z. B. Bundesplatz Bern",
"ctxImport.search": "Suchen",
"ctxImport.searching": "Suche…",
"ctxImport.noResults": "Keine Treffer.",
"ctxImport.searchError": "Suche fehlgeschlagen.",
"ctxImport.radius": "Radius",
"ctxImport.sources": "Quellen",
"ctxImport.src.swissBuildings": "Swisstopo-Gebäude",
"ctxImport.src.osmBuildings": "OSM-Gebäude",
"ctxImport.src.osmRoads": "OSM-Strassen",
"ctxImport.src.osmWater": "OSM-Wasser",
"ctxImport.src.osmGreen": "OSM-Grün",
"ctxImport.cancel": "Abbrechen",
"ctxImport.confirm": "Importieren",
"ctxImport.importing": "Importiere…",
"ctxImport.working": "Lade Geodaten…",
"ctxImport.empty": "Keine Geometrie im gewählten Bereich gefunden.",
"ctxImport.importError": "Import fehlgeschlagen.",
"ctxImport.imported": "{count} Objekte in {sets} Sätzen importiert.",
// ── Raum-Befehl ─────────────────────────────────────────────────────────────
"cmd.room.label": "Raum",
"cmd.room.mode": "Modus",
"cmd.room.inside": "Klick in Raum setzen:",
"cmd.room.first": "Ersten Raumpunkt setzen:",
"cmd.room.next": "Nächsten Punkt setzen ( Enter/Start schließt ):",
// ── Text-Gruppe (Oberleiste) ─────────────────────────────────────────────
"text.group": "Text",
"text.style": "Stil",
"text.style.none": "— Stil —",
"text.font": "Schrift",
"text.size": "Grösse",
"text.size.custom": "Eigene …",
"text.size.customPrompt": "Schriftgrösse in pt:",
"text.bold": "Fett",
"text.italic": "Kursiv",
"text.underline": "Unterstrichen",
"text.align.left": "Linksbündig",
"text.align.center": "Zentriert",
"text.align.right": "Rechtsbündig",
"text.add": "Text",
"text.add.hint": "Text-Werkzeug — neuen Text platzieren (bald verfügbar)",
"text.editTitle": "Text bearbeiten",
"text.apply": "Übernehmen",
"text.cancel": "Abbrechen",
"roomStamp.title": "Raumstempel bearbeiten",
"roomStamp.number": "Raumnummer",
"roomStamp.name": "Raumname",
"roomStamp.nameLine2": "Raumname Zeile 2",
"roomStamp.showFloorArea": "Bodenfläche anzeigen",
"roomStamp.floorAreaPrefix": "Präfix Bodenfläche",
"roomStamp.showUsage": "Nutzung anzeigen",
"text.selectedHint": "Formatierung wirkt auf den ausgewählten Text",
// ── Rich-Text-Editor ────────────────────────────────────────────────────────
"rt.bold": "Fett",
"rt.italic": "Kursiv",
"rt.underline": "Unterstrichen",
"rt.strike": "Durchgestrichen",
"rt.strikethrough": "Durchgestrichen",
"rt.fontSize": "Schriftgrösse",
"rt.color": "Farbe",
"rt.preset": "Vorlage",
"rt.preset.none": "— Vorlage —",
} as const;
export type TranslationKey = keyof typeof de;
+267
View File
@@ -48,6 +48,10 @@ export const en: Record<TranslationKey, string> = {
"topbar.fit.hint": "Fit to plan",
"topbar.fitSelection": "Selection",
"topbar.fitSelection.hint": "Fit to selection (otherwise to plan)",
"topbar.exportPdf": "PDF",
"topbar.exportPdf.hint": "Export the floor plan as a vector PDF",
"topbar.exportDxf": "DXF",
"topbar.exportDxf.hint": "Export the floor plan as DXF (model space in meters)",
"topbar.zoom.current": "Current zoom",
"topbar.renderGroup": "Render mode",
@@ -67,6 +71,7 @@ export const en: Record<TranslationKey, string> = {
"display.style.mono": "Black & white",
"display.style.shaded": "Shaded",
"display.style.white": "White",
"display.style.textured": "Textured",
"display.style.wireframe": "Wireframe",
"display.style.hidden": "Hidden line",
@@ -102,12 +107,23 @@ export const en: Record<TranslationKey, string> = {
"tool.group": "Tools",
"tool.select": "Select",
"tool.wall": "Wall",
"tool.ceiling": "Ceiling",
"tool.window": "Window",
"tool.door": "Door",
"tool.stair": "Stair",
"tool.line": "Line",
"tool.polyline": "Polyline",
"tool.rect": "Rectangle",
"tool.select.hint": "Select — click/drag elements",
"tool.window.hint": "Window: pick a wall, then set the position",
"tool.door.hint": "Door: pick a wall, then set the position",
"tool.stair.hint": "Stair: set start point, then run direction/end",
"tool.room": "Room",
"tool.room.hint": "Room: click inside an enclosed area (or draw an outline)",
"tool.wall.firstPoint": "Wall: set first axis point",
"tool.wall.nextPoint": "Wall: next point — double/right-click to finish",
"tool.ceiling.firstPoint": "Ceiling: set first outline point",
"tool.ceiling.nextPoint": "Ceiling: next point — click start to close, Enter finishes",
"tool.line.firstPoint": "Line: set start point",
"tool.line.secondPoint": "Line: set end point",
"tool.polyline.firstPoint": "Polyline: set first point",
@@ -115,6 +131,7 @@ export const en: Record<TranslationKey, string> = {
"tool.rect.firstCorner": "Rectangle: set first corner",
"tool.rect.secondCorner": "Rectangle: set opposite corner",
"tool.wallType": "Wall type",
"tool.ceilingType": "Ceiling type",
"tool.layer": "Layer",
"tool.layer.hint": "Active layer — everything drawn goes onto this category",
"tool.floorOnlyDisabled": "Walls can only be drawn on a floor",
@@ -208,8 +225,12 @@ export const en: Record<TranslationKey, string> = {
"objinfo.height": "Height",
"objinfo.type": "Type",
"objinfo.kind.wall": "Wall",
"objinfo.kind.ceiling": "Ceiling",
"objinfo.kind.drawing2d": "2D object",
"objinfo.kind.door": "Door",
"objinfo.kind.opening": "Opening",
"objinfo.kind.stair": "Stair",
"objinfo.kind.room": "Room",
// ── Wall attributes (object info panel) ──────────────────────────────────
"objinfo.wall.section": "Wall",
"objinfo.wall.refLine": "Reference line",
@@ -230,6 +251,64 @@ export const en: Record<TranslationKey, string> = {
"objinfo.wall.anchorCustom": "Custom height",
"objinfo.wall.height": "Height",
"objinfo.wall.floorAboveNone": "No floor above",
// ── Ceiling attributes (object info panel) ───────────────────────────────
"objinfo.ceiling.section": "Ceiling",
"objinfo.ceiling.buildup": "Build-up",
"objinfo.ceiling.thickness": "Thickness",
"objinfo.ceiling.preset": "Ceiling style",
"objinfo.ceiling.refFloor": "Reference floor",
"objinfo.ceiling.top": "Top (OK)",
"objinfo.ceiling.bottom": "Bottom (UK)",
"objinfo.ceiling.height": "Thickness (OKUK)",
"objinfo.ceiling.area": "Area",
"objinfo.ceiling.anchorFloor": "Bound to floor",
"objinfo.ceiling.anchorCustom": "Custom height",
// ── Opening attributes (object info panel) ──────────────────────────────
"objinfo.opening.section": "Opening",
"objinfo.opening.kind": "Type",
"objinfo.opening.kind.window": "Window",
"objinfo.opening.kind.door": "Door",
"objinfo.opening.hostWall": "Host wall",
"objinfo.opening.position": "Position (from wall start)",
"objinfo.opening.width": "Width",
"objinfo.opening.height": "Height",
"objinfo.opening.sill": "Sill height",
"objinfo.opening.swingAngle": "Swing angle",
"objinfo.opening.hinge": "Hinge",
"objinfo.opening.hinge.start": "Start",
"objinfo.opening.hinge.end": "End",
"objinfo.opening.swing": "Swing side",
"objinfo.opening.swing.left": "Left",
"objinfo.opening.swing.right": "Right",
"objinfo.opening.dir": "Direction",
"objinfo.opening.dir.in": "In",
"objinfo.opening.dir.out": "Out",
"objinfo.opening.bottom": "Bottom (UK)",
"objinfo.opening.top": "Top (OK)",
"objinfo.stair.section": "Stair",
"objinfo.stair.shape": "Shape",
"objinfo.stair.shape.straight": "Straight",
"objinfo.stair.shape.L": "L-shaped",
"objinfo.stair.shape.spiral": "Spiral",
"objinfo.stair.width": "Run width",
"objinfo.stair.steps": "Step count",
"objinfo.stair.rise": "Total rise",
"objinfo.stair.riser": "Riser height",
"objinfo.stair.tread": "Tread depth",
"objinfo.stair.direction": "Run direction",
"objinfo.stair.direction.up": "Upwards",
"objinfo.stair.direction.down": "Downwards",
"objinfo.stair.refFloor": "Reference floor",
"objinfo.stair.bottom": "Bottom (UK)",
"objinfo.stair.top": "Top (OK)",
// ── Room attributes (object info panel) ──────────────────────────────────
"objinfo.room.section": "Room",
"objinfo.room.name": "Name",
"objinfo.room.sia": "SIA category",
"objinfo.room.area": "Area",
"objinfo.room.perimeter": "Perimeter",
"objinfo.room.refFloor": "Reference floor",
"objinfo.room.editStamp": "Edit stamp …",
"levels.addFloor": "+ Floor",
"levels.addDrawing": "+ Drawing",
"layers.addCategory": "+ Layer",
@@ -279,6 +358,11 @@ export const en: Record<TranslationKey, string> = {
"ctx.fitSelection": "Fit to selection",
"ctx.clearSelection": "Clear selection",
"ctx.fit": "Fit view",
"ctx.union": "Union",
"ctx.subtract": "Subtract",
"ctx.intersect": "Intersect",
"ctx.boolean.disabled":
"Select at least two closed areas (rectangle or closed polyline).",
"ctx.noWallHit": "No wall hit",
"ctx.noWallHit.hint": "Right-clicking a wall selects it and shows wall actions.",
@@ -355,8 +439,47 @@ export const en: Record<TranslationKey, string> = {
"resources.col.prio": "Prio",
"resources.col.prio.hint": "Higher priority runs through at the join",
"resources.col.texture": "Texture",
"resources.col.material": "Material",
"resources.delete.component": "Delete component “{name}”",
// 3D material (PBR) — built-in library + upload.
"material.assign": "Material …",
"material.none": "— none —",
"material.dialog.title": "Assign 3D material",
"material.dialog.hint": "Pick a built-in material or upload your own textures. Applies in the 3D “Textured” mode.",
"material.library": "Library (ambientCG, CC0)",
"material.upload": "Own textures",
"material.upload.hint": "Upload images as maps (at least color). Size = physical tile size in meters.",
"material.upload.color": "Color (albedo)",
"material.upload.normal": "Normal",
"material.upload.roughness": "Roughness",
"material.upload.displacement": "Height",
"material.upload.ao": "AO",
"material.upload.choose": "Image …",
"material.upload.apply": "Assign",
"material.size": "Tile size (m)",
"material.current": "Current",
"material.clear": "Remove material",
"material.close": "Close",
"material.uploaded": "Uploaded",
// ── Browse ambientCG library (live) ──────────────────────────────────────
"material.browse": "Browse library",
"material.browse.starter": "Quick pick",
"material.browse.hint":
"Search the full CC0 ambientCG library (~2000+ materials). One click downloads the textures at runtime and assigns them.",
"material.browse.search": "Search …",
"material.browse.searchPlaceholder": "e.g. wood, concrete, tiles …",
"material.browse.category.all": "All categories",
"material.browse.resolution": "Resolution",
"material.browse.more": "Load more",
"material.browse.loading": "Loading …",
"material.browse.downloading": "Downloading texture …",
"material.browse.empty": "No materials found.",
"material.browse.count": "{shown} of {total}",
"material.browse.error":
"Library unreachable. A proxy for ambientCG is required at runtime (dev: Vite proxy active; prod: set VITE_AMBIENTCG_PROXY).",
"material.browse.downloadError": "Download failed (check CORS/proxy).",
"resources.hatches.hint": "Pattern, scale and angle drive the section hatch; the line style sets the weight of the pattern lines.",
"resources.hatches.empty": "No hatches yet.",
"resources.hatches.placeholder": "Hatch",
@@ -390,6 +513,30 @@ export const en: Record<TranslationKey, string> = {
"cmd.aria": "Command line",
"cmd.idle.prompt": "Command:",
"cmd.idle.placeholder": "Type a command … (Tab)",
"cmd.wall.label": "Wall",
"cmd.wall.start": "Start of wall:",
"cmd.wall.next": "Next point:",
"cmd.ceiling.label": "Ceiling",
"cmd.ceiling.start": "First outline point of ceiling:",
"cmd.ceiling.next": "Next point ( Close ):",
"cmd.opening.label": "Opening",
"cmd.opening.windowLabel": "Window",
"cmd.opening.doorLabel": "Door",
"cmd.opening.wall": "Pick a wall for the opening:",
"cmd.opening.pos": "Position along the wall ( type distance ):",
"cmd.opening.kind": "Type",
"cmd.opening.hinge": "Hinge",
"cmd.opening.side": "Side",
"cmd.opening.dir": "Direction",
"cmd.opening.sillField": "Sill",
"cmd.opening.swingField": "Angle",
"cmd.stair.label": "Stair",
"cmd.stair.start": "Start point of the stair:",
"cmd.stair.run": "Run direction / end ( Shape · Up/Down ):",
"cmd.stair.shape": "Shape",
"cmd.stair.updown": "Up/Down",
"cmd.stair.stepsField": "Steps",
"cmd.stair.riseField": "Rise",
"cmd.line.label": "Line",
"cmd.line.start": "Start of line:",
"cmd.line.end": "End of line:",
@@ -398,9 +545,15 @@ export const en: Record<TranslationKey, string> = {
"cmd.polyline.next": "Next point:",
"cmd.polyline.close": "Close",
"cmd.polyline.undo": "Undo",
"cmd.polyline.closedOpt": "Closed",
"cmd.rect.label": "Rectangle",
"cmd.rect.first": "First corner:",
"cmd.rect.second": "Opposite corner:",
"cmd.rect.method": "Method",
"cmd.rect.center": "Center:",
"cmd.rect.corner": "Corner:",
"cmd.rect.baseEnd": "Second corner (base edge):",
"cmd.rect.rise": "Height:",
"cmd.circle.label": "Circle",
"cmd.circle.center": "Center:",
"cmd.circle.radius": "Radius:",
@@ -408,6 +561,11 @@ export const en: Record<TranslationKey, string> = {
"cmd.move.base": "Base point:",
"cmd.move.target": "Target point:",
"cmd.move.empty": "Nothing selected (select objects first).",
"cmd.mirror.label": "Mirror",
"cmd.mirror.first": "First point of mirror axis:",
"cmd.mirror.second": "Second point of mirror axis:",
"cmd.mirror.empty": "Nothing selected (select objects first).",
"cmd.join.prompt": "Joining selected elements …",
"cmd.copy.label": "Copy",
"cmd.copy.base": "Base point:",
"cmd.copy.target": "Target point (Enter to finish):",
@@ -415,6 +573,9 @@ export const en: Record<TranslationKey, string> = {
"cmd.offset.label": "Offset",
"cmd.offset.pick": "Select curve:",
"cmd.offset.side": "Side or distance:",
// Trim (quick-trim): click the part to cut away; repeats until Esc.
"cmd.trim.label": "Trim",
"cmd.trim.pick": "Click the part to trim away (Esc to finish):",
// Edit tools Split / Join / Delete segment (keyboard: Ctrl+S / Ctrl+J /
// Alt+Click). Labels for the command line / menus.
"edit.split.label": "Split",
@@ -427,6 +588,8 @@ export const en: Record<TranslationKey, string> = {
"cmd.field.height": "Height",
"cmd.field.radius": "Radius",
"cmd.field.distance": "Distance",
"cmd.field.thickness": "Thickness",
"cmd.edit.point": "Drag grip (Tab: length/angle):",
// Commands: import / terrain.
"cmd.import.label": "Import",
@@ -434,6 +597,17 @@ export const en: Record<TranslationKey, string> = {
"cmd.terrain.label": "Terrain",
"cmd.terrain.prompt": "Generate terrain from contours …",
// Area balance (SIA 416).
"nav.roomBalance": "Area balance",
"balance.title": "Area balance (SIA 416)",
"balance.rooms": "Rooms",
"balance.summary": "Balance",
"balance.export": "Export CSV",
"balance.empty": "No rooms yet. Create one with the room tool.",
// Room stamp editor.
"room.stamp.editTitle": "Edit room stamp",
"room.stamp.close": "Close",
// Site / context panel (SitePanel).
"nav.site": "Terrain",
"site.title": "Terrain & Context",
@@ -479,6 +653,28 @@ export const en: Record<TranslationKey, string> = {
"import.badge.elevation": "Elevation",
"import.badge.drawing": "Drawing",
// PDF export.
"export.title": "Export PDF",
"export.close": "Close",
"export.plan": "Plan",
"export.scale": "Scale",
"export.paper": "Paper size",
"export.orientation": "Orientation",
"export.orientation.portrait": "Portrait",
"export.orientation.landscape": "Landscape",
"export.warnFit":
"At the chosen scale the plan does not fully fit the sheet — pick a larger format or a smaller scale.",
"export.confirm": "Export",
"export.cancel": "Cancel",
// DXF export.
"exportDxf.title": "Export DXF",
"exportDxf.hatches": "Hatches",
"exportDxf.hatches.yes": "Include",
"exportDxf.hatches.no": "Outlines only",
"exportDxf.unitsNote":
"Model space in meters (1:1). Layers mirror the project's categories; scale freely on plot.",
// Drag & drop (app-wide file drop).
"drop.hint": "Drop a DXF/DWG file here",
@@ -487,4 +683,75 @@ export const en: Record<TranslationKey, string> = {
"error.title": "Something went wrong",
"error.body": "An unexpected error occurred. Details in the console.",
"error.reload": "Reload",
// Location import (geo.admin / OSM).
"site.importLocation": "Import location",
"ctxImport.title": "Import location",
"ctxImport.close": "Close",
"ctxImport.location": "Place / address",
"ctxImport.searchPlaceholder": "e.g. Bundesplatz Bern",
"ctxImport.search": "Search",
"ctxImport.searching": "Searching…",
"ctxImport.noResults": "No matches.",
"ctxImport.searchError": "Search failed.",
"ctxImport.radius": "Radius",
"ctxImport.sources": "Sources",
"ctxImport.src.swissBuildings": "Swisstopo buildings",
"ctxImport.src.osmBuildings": "OSM buildings",
"ctxImport.src.osmRoads": "OSM roads",
"ctxImport.src.osmWater": "OSM water",
"ctxImport.src.osmGreen": "OSM greenery",
"ctxImport.cancel": "Cancel",
"ctxImport.confirm": "Import",
"ctxImport.importing": "Importing…",
"ctxImport.working": "Loading geodata…",
"ctxImport.empty": "No geometry found in the selected area.",
"ctxImport.importError": "Import failed.",
"ctxImport.imported": "Imported {count} objects in {sets} sets.",
// Room command.
"cmd.room.label": "Room",
"cmd.room.mode": "Mode",
"cmd.room.inside": "Click inside room:",
"cmd.room.first": "Set first room point:",
"cmd.room.next": "Set next point ( Enter/Start closes ):",
// Rich-text editor.
// Text group (top bar).
"text.group": "Text",
"text.style": "Style",
"text.style.none": "— Style —",
"text.font": "Font",
"text.size": "Size",
"text.size.custom": "Custom …",
"text.size.customPrompt": "Font size in pt:",
"text.bold": "Bold",
"text.italic": "Italic",
"text.underline": "Underline",
"text.align.left": "Align left",
"text.align.center": "Center",
"text.align.right": "Align right",
"text.add": "Text",
"text.add.hint": "Text tool — place new text (coming soon)",
"text.editTitle": "Edit text",
"text.apply": "Apply",
"text.cancel": "Cancel",
"roomStamp.title": "Edit room stamp",
"roomStamp.number": "Room number",
"roomStamp.name": "Room name",
"roomStamp.nameLine2": "Room name line 2",
"roomStamp.showFloorArea": "Show floor area",
"roomStamp.floorAreaPrefix": "Floor area prefix",
"roomStamp.showUsage": "Show usage",
"text.selectedHint": "Formatting applies to the selected text",
"rt.bold": "Bold",
"rt.italic": "Italic",
"rt.underline": "Underline",
"rt.strike": "Strikethrough",
"rt.strikethrough": "Strikethrough",
"rt.fontSize": "Font size",
"rt.color": "Color",
"rt.preset": "Preset",
"rt.preset.none": "— Preset —",
};
+513 -75
View File
@@ -11,23 +11,34 @@
// dwg.dwg_read_data(buffer, Dwg_File_Type.DWG) → Dwg_Data-Pointer
// dwg.convert(ptr) → DwgDatabase (stark typisiert)
// dwg.dwg_free(ptr) → Speicher freigeben
// db.entities → Modellraum-Entities (DwgEntity[])
// db.tables.BLOCK_RECORD.entries → Block-Definitionen
// (name → entities[]) für INSERT.
// Der WASM-Build dieser Lib hat DXF-Schreiben/-Lesen DEAKTIVIERT (disable-dxf),
// daher gehen wir bewusst NICHT über DWG→DXF-Text, sondern mappen die geparste
// DwgDatabase direkt (ein Mapping, gespiegelt aus dxfParser).
//
// Unterstützte Entities (Modellraum):
// Unterstützte Entities (Modellraum + expandierte Blöcke):
// • 3DFACE → Dreiecks-Mesh (3 bzw. 4 Ecken → 12 Tri).
// • POLYLINE2D (Polyface, Flag 64) → Dreiecks-Mesh (Polyface-Faces).
// • LWPOLYLINE / POLYLINE2D / 3D → Kontur (z aus elevation/Vertex-Z).
// • LINE → Kontur (zwei-Punkt-Linienzug).
// • ARC → Kontur (Bogen, ~6° je Segment, offen).
// • CIRCLE → Kontur (geschlossen, 48 Segmente).
// • ELLIPSE → Kontur (geschlossen bei Vollellipse).
// • SPLINE → Kontur (fitPoints, sonst controlPoints).
// • INSERT → Block-Expansion mit Transformation
// (Translation/Skalierung/Rotation),
// rekursiv (Tiefen-Limit gegen Zyklen).
// • POINT → bewusst ignoriert (kein Polylinien-Bezug).
//
// Lazy: Das Paket wird per dynamischem import() geladen (kein Bloat im Initial-
// Bundle); WASM startet erst beim ersten DWG-Import.
//
// Bezeichner englisch, Kommentare deutsch (CLAUDE.md).
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import type { Contour, ContourSet, ImportedMesh, Vec2 } from "../model/types";
import type { DxfImportResult } from "./dxfParser";
import type { DxfImportResult, ImportDiagnostics } from "./dxfParser";
// Lose getippte Sicht auf die libredwg-web-Entities (wir greifen tolerant auf die
// Felder zu, die wir brauchen; alle Punkte sind {x,y,z}).
@@ -56,13 +67,42 @@ interface DwgEntityLike {
corner3?: DwgPoint;
corner4?: DwgPoint;
vertices?: DwgVertexLike[];
// ── ARC / CIRCLE ──
center?: DwgPoint;
radius?: number;
startAngle?: number;
endAngle?: number;
// ── ELLIPSE ──
majorAxisEndPoint?: DwgPoint;
axisRatio?: number;
// ── SPLINE ──
fitPoints?: DwgPoint[];
controlPoints?: DwgPoint[];
// ── INSERT ──
name?: string;
insertionPoint?: DwgPoint;
xScale?: number;
yScale?: number;
zScale?: number;
rotation?: number;
[k: string]: unknown;
}
// Block-Record-Tabelleneintrag: Block-Name → Entities (siehe blockRecord.d.ts).
interface DwgBlockRecordLike {
name?: string;
entities?: DwgEntityLike[];
}
// Schmale Sicht auf die LibreDWG-Instanz (nur die Methoden, die wir nutzen).
interface LibreDwgApi {
dwg_read_data(data: ArrayBuffer, fileType: number): number | undefined;
convert(ptr: number): { entities?: unknown[] };
convert(ptr: number): {
entities?: unknown[];
tables?: {
BLOCK_RECORD?: { entries?: DwgBlockRecordLike[] };
};
};
dwg_free(ptr: number): void;
}
@@ -111,10 +151,118 @@ const POLYFACE_FLAG = 64;
// POLYLINE-Flag 1 = geschlossen.
const CLOSED_FLAG = 1;
// Tessellierungs-Parameter (Bögen/Kreise/Ellipsen/Splines).
const ARC_SEG_RAD = (8 * Math.PI) / 180; // ~8° je Bogen-Segment …
const ARC_MIN_SEGMENTS = 6; // … aber mindestens 6 Segmente.
const CIRCLE_SEGMENTS = 48; // Vollkreis-Auflösung.
const ELLIPSE_SEGMENTS = 64; // Vollellipse-Auflösung.
const SPLINE_SEGMENTS = 64; // Spline-Auflösung (controlPoints-Fall).
// Block-Expansions-Tiefenlimit (gegen zyklische INSERTs / Blöcke in Blöcken).
const MAX_BLOCK_DEPTH = 10;
/**
* 2D-Affin-Transform (für INSERT-Expansion). Bildet einen Block-Modellraum auf
* den Welt-Modellraum ab: `[x', y'] = M·[x, y] + t`. Z wird separat (additiv +
* zScale) behandelt, da unsere Konturen 2D-Punkte + eine z-Höhe führen.
*/
interface Transform2D {
// Lineare 2×2-Matrix (Skalierung · Rotation), spaltenweise.
m00: number;
m01: number;
m10: number;
m11: number;
// Translation.
tx: number;
ty: number;
// Z-Anteil (additiver Versatz + Skalierung).
zOffset: number;
zScale: number;
}
const IDENTITY: Transform2D = {
m00: 1,
m01: 0,
m10: 0,
m11: 1,
tx: 0,
ty: 0,
zOffset: 0,
zScale: 1,
};
/** Wendet eine Transform2D auf einen 2D-Punkt an. */
function applyXY(t: Transform2D, x: number, y: number): Vec2 {
return {
x: t.m00 * x + t.m01 * y + t.tx,
y: t.m10 * x + t.m11 * y + t.ty,
};
}
/** Wendet die Z-Komponente einer Transform2D auf einen z-Wert an. */
function applyZ(t: Transform2D, z: number): number {
return z * t.zScale + t.zOffset;
}
/**
* Verkettet zwei Transforms: `outer ∘ inner` (inner wird zuerst angewandt, dann
* outer). Für rekursive Block-Expansion (Block in Block).
*/
function compose(outer: Transform2D, inner: Transform2D): Transform2D {
return {
m00: outer.m00 * inner.m00 + outer.m01 * inner.m10,
m01: outer.m00 * inner.m01 + outer.m01 * inner.m11,
m10: outer.m10 * inner.m00 + outer.m11 * inner.m10,
m11: outer.m10 * inner.m01 + outer.m11 * inner.m11,
tx: outer.m00 * inner.tx + outer.m01 * inner.ty + outer.tx,
ty: outer.m10 * inner.tx + outer.m11 * inner.ty + outer.ty,
zOffset: outer.zScale * inner.zOffset + outer.zOffset,
zScale: outer.zScale * inner.zScale,
};
}
/**
* Baut die Einfüge-Transform eines INSERT: Skalierung (xScale/yScale/zScale) →
* Rotation (um Z) → Translation (insertionPoint).
*/
function insertTransform(e: DwgEntityLike): Transform2D {
const ip = e.insertionPoint ?? { x: 0, y: 0, z: 0 };
const sx = Number.isFinite(e.xScale) ? (e.xScale as number) : 1;
const sy = Number.isFinite(e.yScale) ? (e.yScale as number) : 1;
const sz = Number.isFinite(e.zScale) ? (e.zScale as number) : 1;
const rot = Number.isFinite(e.rotation) ? (e.rotation as number) : 0;
const cos = Math.cos(rot);
const sin = Math.sin(rot);
// M = R · S (erst skalieren, dann rotieren).
return {
m00: cos * sx,
m01: -sin * sy,
m10: sin * sx,
m11: cos * sy,
tx: ip.x,
ty: ip.y,
zOffset: ip.z ?? 0,
zScale: sz,
};
}
// Aufnahme-Kontext für ein Mapping-Durchlauf (Meshes + Konturen + Histogramm).
interface Accum {
meshTriangles: number[];
meshIndices: number[];
contours: Contour[];
entityCounts: Record<string, number>;
total: number;
}
/**
* Parst eine DWG-Datei (als ArrayBuffer) in dasselbe Kontext-Geometrie-Format
* wie der DXF-Parser. Lädt LibreDWG-WASM lazy. Wirft bei fatalen Fehlern (der
* Aufrufer fängt das ab und zeigt den ODA-Fallback) — pro-Entity-Fehler nicht.
*
* Reale DWGs stecken Geometrie oft in BLÖCKEN, die nur per INSERT referenziert
* werden; wir lesen die Block-Definitionen aus `db.tables.BLOCK_RECORD` und
* expandieren jedes INSERT mit Transformation (rekursiv, Tiefen-Limit).
*/
export async function parseDwg(data: ArrayBuffer): Promise<DxfImportResult> {
const libredwg = await createLibreDwg();
@@ -131,63 +279,71 @@ export async function parseDwg(data: ArrayBuffer): Promise<DxfImportResult> {
const db = libredwg.convert(dwgPtr);
const entities = (db?.entities ?? []) as unknown as DwgEntityLike[];
const meshTriangles: number[] = [];
const meshIndices: number[] = [];
const contours: Contour[] = [];
for (const e of entities) {
const type = (e.type ?? "").toUpperCase();
switch (type) {
case "3DFACE":
addFace(meshTriangles, meshIndices, e);
break;
case "POLYLINE2D":
case "POLYLINE3D":
if (isPolyfaceMesh(e)) {
addPolyfaceMesh(meshTriangles, meshIndices, e);
} else {
const ct = polylineContour(e);
if (ct) contours.push(ct);
}
break;
case "LWPOLYLINE": {
const ct = polylineContour(e);
if (ct) contours.push(ct);
break;
}
case "LINE": {
const ct = lineContour(e);
if (ct) contours.push(ct);
break;
}
default:
// Unbekannte/irrelevante Entity → ignorieren (tolerant).
break;
// Block-Definitionen (name → entities) für die INSERT-Expansion.
const blockEntries = db?.tables?.BLOCK_RECORD?.entries ?? [];
const blocks = new Map<string, DwgEntityLike[]>();
for (const b of blockEntries) {
if (b?.name && Array.isArray(b.entities)) {
blocks.set(b.name, b.entities);
}
}
const acc: Accum = {
meshTriangles: [],
meshIndices: [],
contours: [],
entityCounts: {},
total: 0,
};
// Modellraum-Entities mappen (INSERTs expandieren Blöcke rekursiv).
mapEntities(entities, IDENTITY, blocks, 0, acc);
const meshes: ImportedMesh[] = [];
if (meshIndices.length > 0) {
if (acc.meshIndices.length > 0) {
meshes.push({
id: nextId("imported"),
type: "importedMesh",
name: "DWG-Mesh",
positions: meshTriangles,
indices: meshIndices,
positions: acc.meshTriangles,
indices: acc.meshIndices,
});
}
const contourSets: ContourSet[] = [];
if (contours.length > 0) {
if (acc.contours.length > 0) {
contourSets.push({
id: nextId("contours"),
type: "contourSet",
name: "DWG-Konturen",
contours,
contours: acc.contours,
});
}
return { meshes, contours: contourSets };
const diagnostics: ImportDiagnostics = {
entityCounts: acc.entityCounts,
total: acc.total,
};
// Diagnose immer loggen; im „nichts gemappt"-Fall hervorheben (Histogramm
// macht die Ursache sichtbar: 0 Entities = Encoding/Version vs. Abdeckungs-
// lücke = z. B. „500 ARC, 200 INSERT").
const mapped = meshes.length + contourSets.length;
if (mapped === 0) {
console.info(
"[dwgParser] Keine Geometrie gemappt. Entity-Histogramm:",
acc.entityCounts,
`(${acc.total} Entities)`,
);
} else {
console.info(
"[dwgParser] Entity-Histogramm:",
acc.entityCounts,
`(${acc.total} Entities)`,
);
}
return { meshes, contours: contourSets, diagnostics };
} finally {
// Speicher der WASM-Instanz freigeben (auch im Fehlerfall).
if (dwgPtr != null) {
@@ -200,8 +356,106 @@ export async function parseDwg(data: ArrayBuffer): Promise<DxfImportResult> {
}
}
/**
* Mappt eine Entity-Liste unter einer Transform in den Aufnahme-Kontext. INSERTs
* werden über `blocks` aufgelöst und rekursiv (mit verketteter Transform) erneut
* gemappt; `depth` begrenzt die Rekursion gegen zyklische Blöcke.
*/
function mapEntities(
entities: DwgEntityLike[],
xform: Transform2D,
blocks: Map<string, DwgEntityLike[]>,
depth: number,
acc: Accum,
): void {
for (const e of entities) {
const type = (e.type ?? "").toUpperCase();
// Histogramm zählt JEDE betrachtete Entity (auch ignorierte/expandierte).
acc.entityCounts[type] = (acc.entityCounts[type] ?? 0) + 1;
acc.total += 1;
switch (type) {
case "3DFACE":
addFace(acc, e, xform);
break;
case "POLYLINE2D":
case "POLYLINE3D":
if (isPolyfaceMesh(e)) {
addPolyfaceMesh(acc, e, xform);
} else {
const ct = polylineContour(e, xform);
if (ct) acc.contours.push(ct);
}
break;
case "LWPOLYLINE": {
const ct = polylineContour(e, xform);
if (ct) acc.contours.push(ct);
break;
}
case "LINE": {
const ct = lineContour(e, xform);
if (ct) acc.contours.push(ct);
break;
}
case "ARC": {
const ct = arcContour(e, xform);
if (ct) acc.contours.push(ct);
break;
}
case "CIRCLE": {
const ct = circleContour(e, xform);
if (ct) acc.contours.push(ct);
break;
}
case "ELLIPSE": {
const ct = ellipseContour(e, xform);
if (ct) acc.contours.push(ct);
break;
}
case "SPLINE": {
const ct = splineContour(e, xform);
if (ct) acc.contours.push(ct);
break;
}
case "INSERT": {
if (depth >= MAX_BLOCK_DEPTH) break; // Zyklen-/Tiefenschutz.
const blockEntities = e.name ? blocks.get(e.name) : undefined;
if (!blockEntities || blockEntities.length === 0) break;
const local = compose(xform, insertTransform(e));
mapEntities(blockEntities, local, blocks, depth + 1, acc);
break;
}
case "POINT":
// POINT bewusst ignorieren (kein Polylinien-/Flächenbezug).
break;
default:
// Unbekannte/irrelevante Entity → ignorieren (tolerant).
break;
}
}
}
// ── Mesh-Entities ────────────────────────────────────────────────────────────
/**
* 3DFACE: 3 oder 4 Eckpunkte (corner1..4). Bei 4 ≠ 3 Punkten zwei Dreiecke (Fan).
* Punkte werden mit der aktiven Transform in den Modellraum gebracht.
*/
function addFace(acc: Accum, e: DwgEntityLike, xform: Transform2D): void {
const positions = acc.meshTriangles;
const indices = acc.meshIndices;
const corners = [e.corner1, e.corner2, e.corner3, e.corner4].filter(
(c): c is DwgPoint => !!c && Number.isFinite(c.x) && Number.isFinite(c.y),
);
if (corners.length < 3) return;
const idx = corners.map((c) => {
const p = applyXY(xform, c.x, c.y);
return pushVertex(positions, p.x, p.y, applyZ(xform, c.z ?? 0));
});
pushTri(indices, positions, idx[0], idx[1], idx[2]);
if (idx.length >= 4) pushTri(indices, positions, idx[0], idx[2], idx[3]);
}
/** Fügt einen Vertex zu positions hinzu und liefert seinen Index. */
function pushVertex(
positions: number[],
@@ -214,25 +468,6 @@ function pushVertex(
return idx;
}
/**
* 3DFACE: 3 oder 4 Eckpunkte (corner1..4). Bei 4 ≠ 3 Punkten zwei Dreiecke (Fan).
*/
function addFace(
positions: number[],
indices: number[],
e: DwgEntityLike,
): void {
const corners = [e.corner1, e.corner2, e.corner3, e.corner4].filter(
(c): c is DwgPoint => !!c && Number.isFinite(c.x) && Number.isFinite(c.y),
);
if (corners.length < 3) return;
const idx = corners.map((c) =>
pushVertex(positions, c.x, c.y, c.z ?? 0),
);
pushTri(indices, positions, idx[0], idx[1], idx[2]);
if (idx.length >= 4) pushTri(indices, positions, idx[0], idx[2], idx[3]);
}
/** Ob eine POLYLINE eine Polyface-Mesh-Variante ist (Flag 64 oder Polyface-Indizes). */
function isPolyfaceMesh(e: DwgEntityLike): boolean {
if (((e.flag ?? 0) & POLYFACE_FLAG) !== 0) return true;
@@ -251,10 +486,12 @@ function isPolyfaceMesh(e: DwgEntityLike): boolean {
* bare Kante Polyface-Indizes). Wir trennen beide und fan-triangulieren jede Face.
*/
function addPolyfaceMesh(
positions: number[],
indices: number[],
acc: Accum,
e: DwgEntityLike,
xform: Transform2D,
): void {
const positions = acc.meshTriangles;
const indices = acc.meshIndices;
const all = e.vertices ?? [];
const hasFaceIdx = (v: DwgVertexLike): boolean =>
!!(
@@ -267,7 +504,10 @@ function addPolyfaceMesh(
const faceRecs = all.filter((v) => hasFaceIdx(v));
if (geom.length < 3) return;
const base = positions.length / 3;
for (const v of geom) positions.push(v.x, v.y, v.z ?? 0);
for (const v of geom) {
const p = applyXY(xform, v.x, v.y);
positions.push(p.x, p.y, applyZ(xform, v.z ?? 0));
}
for (const fr of faceRecs) {
// 1-basierte Indizes; Vorzeichen markiert (un)sichtbare Kanten → abs().
const ring = [
@@ -308,26 +548,27 @@ function pushTri(
/**
* LWPOLYLINE / POLYLINE2D / POLYLINE3D → Kontur. Z-Höhe: erster gültiger
* Vertex-Z, sonst `elevation`, sonst 0. `closed` aus Flag 1. < 2 Punkte → null.
* Punkte werden mit der aktiven Transform abgebildet.
*/
function polylineContour(e: DwgEntityLike): Contour | null {
function polylineContour(e: DwgEntityLike, xform: Transform2D): Contour | null {
const vs = e.vertices ?? [];
const pts: Vec2[] = [];
let zFromVertex: number | undefined;
for (const v of vs) {
if (!Number.isFinite(v.x) || !Number.isFinite(v.y)) continue;
pts.push({ x: v.x, y: v.y });
pts.push(applyXY(xform, v.x, v.y));
if (zFromVertex === undefined && Number.isFinite(v.z)) zFromVertex = v.z;
}
if (pts.length < 2) return null;
const z =
const z0 =
zFromVertex ??
(Number.isFinite(e.elevation) ? (e.elevation as number) : 0);
const closed = ((e.flag ?? 0) & CLOSED_FLAG) !== 0;
return { z, pts, closed, layer: e.layer };
return { z: applyZ(xform, z0), pts, closed, layer: e.layer };
}
/** LINE → zweipunktige (offene) Kontur. Z aus dem Startpunkt (sonst 0). */
function lineContour(e: DwgEntityLike): Contour | null {
function lineContour(e: DwgEntityLike, xform: Transform2D): Contour | null {
const a = e.startPoint;
const b = e.endPoint;
if (
@@ -340,14 +581,211 @@ function lineContour(e: DwgEntityLike): Contour | null {
) {
return null;
}
const z = Number.isFinite(a.z) ? (a.z as number) : 0;
const z0 = Number.isFinite(a.z) ? (a.z as number) : 0;
return {
z,
pts: [
{ x: a.x, y: a.y },
{ x: b.x, y: b.y },
],
z: applyZ(xform, z0),
pts: [applyXY(xform, a.x, a.y), applyXY(xform, b.x, b.y)],
closed: false,
layer: e.layer,
};
}
/**
* ARC → offene Polylinien-Kontur. Wir tessellieren von startAngle nach endAngle
* (CCW, normalisiert) in ~ARC_SEG_RAD-Schritten (min. ARC_MIN_SEGMENTS). Winkel
* sind im Bogenmaß (libredwg liefert ARC-Winkel in Radiant).
*/
function arcContour(e: DwgEntityLike, xform: Transform2D): Contour | null {
const c = e.center;
const r = e.radius;
if (
!c ||
!Number.isFinite(c.x) ||
!Number.isFinite(c.y) ||
!Number.isFinite(r) ||
(r as number) <= 0
) {
return null;
}
let a0 = Number.isFinite(e.startAngle) ? (e.startAngle as number) : 0;
let a1 = Number.isFinite(e.endAngle) ? (e.endAngle as number) : 2 * Math.PI;
// Sweep CCW normalisieren (a1 stets ≥ a0).
while (a1 < a0) a1 += 2 * Math.PI;
const sweep = a1 - a0;
const segments = Math.max(
ARC_MIN_SEGMENTS,
Math.ceil(sweep / ARC_SEG_RAD),
);
const pts: Vec2[] = [];
const radius = r as number;
for (let i = 0; i <= segments; i++) {
const ang = a0 + (sweep * i) / segments;
const x = c.x + radius * Math.cos(ang);
const y = c.y + radius * Math.sin(ang);
pts.push(applyXY(xform, x, y));
}
const z0 = Number.isFinite(c.z) ? (c.z as number) : 0;
return { z: applyZ(xform, z0), pts, closed: false, layer: e.layer };
}
/** CIRCLE → geschlossene Polylinien-Kontur (CIRCLE_SEGMENTS Segmente). */
function circleContour(e: DwgEntityLike, xform: Transform2D): Contour | null {
const c = e.center;
const r = e.radius;
if (
!c ||
!Number.isFinite(c.x) ||
!Number.isFinite(c.y) ||
!Number.isFinite(r) ||
(r as number) <= 0
) {
return null;
}
const radius = r as number;
const pts: Vec2[] = [];
for (let i = 0; i < CIRCLE_SEGMENTS; i++) {
const ang = (2 * Math.PI * i) / CIRCLE_SEGMENTS;
const x = c.x + radius * Math.cos(ang);
const y = c.y + radius * Math.sin(ang);
pts.push(applyXY(xform, x, y));
}
const z0 = Number.isFinite(c.z) ? (c.z as number) : 0;
return { z: applyZ(xform, z0), pts, closed: true, layer: e.layer };
}
/**
* ELLIPSE → Polylinien-Kontur. `majorAxisEndPoint` ist der Endpunkt der Haupt-
* achse RELATIV zum Zentrum (WCS); `axisRatio` = Nebenachse/Hauptachse. start/
* endAngle sind Parameter (0 … 2π bei Vollellipse). Geschlossen, wenn der Sweep
* (nahezu) eine volle Umdrehung ist.
*/
function ellipseContour(e: DwgEntityLike, xform: Transform2D): Contour | null {
const c = e.center;
const maj = e.majorAxisEndPoint;
if (
!c ||
!maj ||
!Number.isFinite(c.x) ||
!Number.isFinite(c.y) ||
!Number.isFinite(maj.x) ||
!Number.isFinite(maj.y)
) {
return null;
}
const ratio = Number.isFinite(e.axisRatio) ? (e.axisRatio as number) : 1;
// Hauptachsen-Vektor (relativ) + dazu senkrechter Nebenachsen-Vektor.
const ux = maj.x;
const uy = maj.y;
const vx = -uy * ratio;
const vy = ux * ratio;
let p0 = Number.isFinite(e.startAngle) ? (e.startAngle as number) : 0;
let p1 = Number.isFinite(e.endAngle) ? (e.endAngle as number) : 2 * Math.PI;
while (p1 < p0) p1 += 2 * Math.PI;
const sweep = p1 - p0;
const full = sweep >= 2 * Math.PI - 1e-6;
const segments = Math.max(
ARC_MIN_SEGMENTS,
Math.ceil((ELLIPSE_SEGMENTS * sweep) / (2 * Math.PI)),
);
const pts: Vec2[] = [];
// Bei Vollellipse Endpunkt = Startpunkt → letztes Segment auslassen (closed).
const count = full ? segments : segments + 1;
for (let i = 0; i < count; i++) {
const param = p0 + (sweep * i) / segments;
const ct = Math.cos(param);
const st = Math.sin(param);
const x = c.x + ux * ct + vx * st;
const y = c.y + uy * ct + vy * st;
pts.push(applyXY(xform, x, y));
}
if (pts.length < 2) return null;
const z0 = Number.isFinite(c.z) ? (c.z as number) : 0;
return { z: applyZ(xform, z0), pts, closed: full, layer: e.layer };
}
/**
* SPLINE → Polylinien-Kontur. Bevorzugt `fitPoints` (durch die der Spline läuft);
* fehlen sie, tessellieren wir eine offene uniforme B-Spline über die
* `controlPoints` (De-Boor mit Standard-Clamped-Knoten, Grad aus `degree`).
* `closed` aus Flag 1 (geschlossener Spline). Z aus dem ersten Punkt.
*/
function splineContour(e: DwgEntityLike, xform: Transform2D): Contour | null {
const closed = ((e.flag ?? 0) & CLOSED_FLAG) !== 0;
const fit = (e.fitPoints ?? []).filter(
(p) => !!p && Number.isFinite(p.x) && Number.isFinite(p.y),
);
let raw: Vec2[];
let z0 = 0;
if (fit.length >= 2) {
raw = fit.map((p) => ({ x: p.x, y: p.y }));
z0 = Number.isFinite(fit[0].z) ? (fit[0].z as number) : 0;
} else {
const ctrl = (e.controlPoints ?? []).filter(
(p) => !!p && Number.isFinite(p.x) && Number.isFinite(p.y),
);
if (ctrl.length < 2) return null;
z0 = Number.isFinite(ctrl[0].z) ? (ctrl[0].z as number) : 0;
const degree = Number.isFinite(e.degree)
? Math.max(1, Math.min(ctrl.length - 1, e.degree as number))
: Math.min(3, ctrl.length - 1);
raw = tessellateBSpline(
ctrl.map((p) => ({ x: p.x, y: p.y })),
degree,
SPLINE_SEGMENTS,
);
}
if (raw.length < 2) return null;
const pts = raw.map((p) => applyXY(xform, p.x, p.y));
return { z: applyZ(xform, z0), pts, closed, layer: e.layer };
}
/**
* Uniforme Clamped-B-Spline über `ctrl` (Grad `degree`) in `samples` gleichmäßige
* Auswertungspunkte. Standard-Knotenvektor (clamped: Endknoten degree+1-fach).
* Bewusst einfach; ausreichend für Kontext-/Snap-Geometrie. Liefert bei < degree+1
* Kontrollpunkten einfach den Kontrollpolygonzug zurück.
*/
function tessellateBSpline(ctrl: Vec2[], degree: number, samples: number): Vec2[] {
const n = ctrl.length - 1;
if (n < degree) return ctrl.slice();
// Clamped uniform knots: 0…0 (degree+1×), 1,2,…, m-degree-1, dann max (degree+1×).
const knotCount = n + degree + 2;
const knots: number[] = [];
const interior = knotCount - 2 * (degree + 1);
for (let i = 0; i < degree + 1; i++) knots.push(0);
for (let i = 1; i <= interior; i++) knots.push(i / (interior + 1));
for (let i = 0; i < degree + 1; i++) knots.push(1);
const out: Vec2[] = [];
for (let s = 0; s <= samples; s++) {
// u im halboffenen [0,1); letztes Sample exakt 1.
const u = s === samples ? 1 - 1e-9 : s / samples;
out.push(deBoor(ctrl, knots, degree, u));
}
return out;
}
/** De-Boor-Auswertung einer B-Spline an Parameter u (0…1). */
function deBoor(ctrl: Vec2[], knots: number[], degree: number, u: number): Vec2 {
// Knoten-Span k finden mit knots[k] <= u < knots[k+1].
const n = ctrl.length - 1;
let k = degree;
while (k < n && knots[k + 1] <= u) k++;
// Lokale Kontrollpunkte kopieren.
const d: Vec2[] = [];
for (let j = 0; j <= degree; j++) {
const cp = ctrl[k - degree + j] ?? ctrl[ctrl.length - 1];
d.push({ x: cp.x, y: cp.y });
}
for (let r = 1; r <= degree; r++) {
for (let j = degree; j >= r; j--) {
const i = k - degree + j;
const denom = knots[i + degree - r + 1] - knots[i];
const alpha = denom === 0 ? 0 : (u - knots[i]) / denom;
d[j] = {
x: (1 - alpha) * d[j - 1].x + alpha * d[j].x,
y: (1 - alpha) * d[j - 1].y + alpha * d[j].y,
};
}
}
return d[degree];
}
+15
View File
@@ -21,10 +21,25 @@
import DxfParser from "dxf-parser";
import type { Contour, ContourSet, ImportedMesh, Vec2 } from "../model/types";
/**
* Optionale Import-Diagnose: Histogramm der gelesenen Entity-Typen (Typ → Anzahl)
* und die Gesamtzahl. Wird vor allem vom DWG-Parser gefüllt, damit beim Fall
* „keine Geometrie gefunden" sichtbar wird, WORAN es liegt (z. B. „0 Entities" =
* Encoding-/Versions-Problem vs. „500 ARC, 200 INSERT" = Abdeckungslücke).
*/
export interface ImportDiagnostics {
/** Anzahl je (Modellraum-)Entity-Typ, inkl. expandierter Block-Entities. */
entityCounts: Record<string, number>;
/** Gesamtzahl betrachteter Entities. */
total: number;
}
/** Ergebnis eines DXF-Imports: rohe Meshes + Konturen-Sätze. */
export interface DxfImportResult {
meshes: ImportedMesh[];
contours: ContourSet[];
/** Optionale Diagnose (Entity-Typ-Histogramm); vom DWG-Parser gefüllt. */
diagnostics?: ImportDiagnostics;
}
// Lose getippte Sicht auf die dxf-parser-Ausgabe (das Paket liefert keine engen
+1 -1
View File
@@ -13,7 +13,7 @@
// `layerToCode` liefert (deterministisch) einen Code je
// Layer-Name; unbekannte/leere Layer fallen auf `fallbackCode`.
//
// Bezeichner englisch, Kommentare deutsch (CLAUDE.md).
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import type { Contour, ContourSet, Drawing2D, Drawing2DGeom } from "../model/types";
+110
View File
@@ -0,0 +1,110 @@
// Gemeinsame Typen + Helfer für den Standort-Import (swisstopo & OSM).
//
// Beide Quellen liefern am Ende dasselbe: geschlossene/offene Polylinien in
// LOKALEN Modell-Metern (bereits um die Origin verschoben, siehe lv95.ts),
// kategorisiert nach Art (Gebäude/Strasse/Wasser/Grün). Die UI wandelt diese in
// `ContourSet`-Kontext-Objekte pro Kategorie um und legt sie in `project.context`.
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import type { GeoOrigin } from "./lv95";
import type { ContextObject, Contour } from "../model/types";
/** Fachliche Kategorie einer importierten Kontext-Linie. */
export type GeoCategory = "building" | "road" | "water" | "green";
/** Ein importiertes Polygon/Polylinie in lokalen Modell-Metern. */
export interface GeoFeature {
category: GeoCategory;
/** Stützpunkte in lokalen Metern (x=Ost, y=Nord). */
pts: { x: number; y: number }[];
/** Geschlossener Ring (Gebäude/Wasser/Grün) vs. offene Linie (Strasse). */
closed: boolean;
}
/** Ergebnis eines Import-Laufs: Features + verwendete Origin. */
export interface GeoImportResult {
origin: GeoOrigin;
features: GeoFeature[];
}
/** Menschlicher Layer-/Objektname je Kategorie (deutsch, für die Kontext-Liste). */
const CATEGORY_LABEL: Record<GeoCategory, string> = {
building: "Gebäude",
road: "Strassen",
water: "Gewässer",
green: "Grünflächen",
};
/** Layer-Kennung (englischer Identifier) je Kategorie — für spätere Zuordnung. */
const CATEGORY_LAYER: Record<GeoCategory, string> = {
building: "context-buildings",
road: "context-roads",
water: "context-water",
green: "context-green",
};
/** Erzeugt eine (kollisionsarme) ID für ein neues Kontext-Objekt. */
function contextId(prefix: string): string {
return `${prefix}-${Date.now().toString(36)}-${Math.random()
.toString(36)
.slice(2, 8)}`;
}
/**
* Wandelt importierte Features in `ContextObject`s (je Kategorie EIN
* ContourSet). Die Konturen liegen auf z=0 (Grundriss-Kontext); geschlossene
* Ringe (Gebäude/Wasser/Grün) werden als geschlossene Konturen markiert,
* Strassen als offene Linienzüge. Der Aufrufer legt das Ergebnis über
* `onAddContextObjects` in `project.context` ab.
*/
export function featuresToContextObjects(
features: GeoFeature[],
sourceLabel: string,
): ContextObject[] {
const byCat = new Map<GeoCategory, Contour[]>();
for (const f of features) {
if (f.pts.length < 2) continue;
const list = byCat.get(f.category) ?? [];
list.push({
z: 0,
pts: f.pts.map((p) => ({ x: p.x, y: p.y })),
closed: f.closed,
layer: CATEGORY_LAYER[f.category],
});
byCat.set(f.category, list);
}
const objs: ContextObject[] = [];
for (const [cat, contours] of byCat) {
if (contours.length === 0) continue;
objs.push({
id: contextId(`geo-${cat}`),
type: "contourSet",
name: `${CATEGORY_LABEL[cat]} (${sourceLabel})`,
layer: CATEGORY_LAYER[cat],
contours,
});
}
return objs;
}
/**
* Basis-URL des optionalen Geo-Proxys (openbureau-core `/geoproxy`). Wird für
* Overpass zwingend benutzt (CORS unzuverlässig); für geo.admin nur als
* Fallback. Konfiguration über `VITE_GEO_PROXY` (z. B. "/geoproxy" bei
* gleichem Origin oder eine absolute URL auf den API-Server). Leer = kein Proxy.
*/
export function geoProxyBase(): string {
const env = (import.meta as unknown as { env?: Record<string, string> }).env;
return (env?.VITE_GEO_PROXY ?? "").replace(/\/$/, "");
}
/**
* Verpackt eine absolute Upstream-URL für den Geo-Proxy. Ohne konfigurierten
* Proxy wird die URL unverändert zurückgegeben (Direktabruf).
*/
export function viaProxy(url: string): string {
const base = geoProxyBase();
return base ? `${base}?u=${encodeURIComponent(url)}` : url;
}
+182
View File
@@ -0,0 +1,182 @@
// LV95 ↔ WGS84 Koordinaten-Transformation + Ursprungs-Verschiebung.
//
// Schweizer Landeskoordinaten (LV95 / EPSG:2056) liegen im Millionenbereich
// (Ost ~2'600'000 m, Nord ~1'200'000 m). Für die numerische Genauigkeit im
// CAD (float32 in three.js!) verschieben wir importierte Geometrie in die Nähe
// des Modell-Ursprungs (0,0). Der einmal berechnete Offset wird mitgeführt, so
// dass ALLE Objekte eines Imports dieselbe Verschiebung teilen und korrekt
// zueinander liegen.
//
// Die Umrechnung LV95↔WGS84 verwendet die von swisstopo publizierten
// Näherungsformeln („Bezugsrahmenwechsel und Kartenprojektionen", genauigkeit
// im Meter-Bereich, für Kontext-Geometrie völlig ausreichend). Referenz-
// Rundlauf (siehe Unit-Check im Report) < 1 m.
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). Einheiten: Meter
// bzw. Dezimalgrad.
/** Geographische Koordinate (WGS84), Dezimalgrad. */
export interface LatLon {
lat: number;
lon: number;
}
/** Schweizer Landeskoordinate LV95 (EPSG:2056), Meter. */
export interface LV95 {
/** Ostwert (E), ~2'600'000. */
e: number;
/** Nordwert (N), ~1'200'000. */
n: number;
}
/**
* WGS84 → LV95 (approximativ, swisstopo-Näherungsformeln).
* Eingabe Dezimalgrad, Ausgabe Meter (E/N).
*/
export function wgs84ToLv95(lat: number, lon: number): LV95 {
// Breite/Länge in Sexagesimalsekunden.
const phi = lat * 3600;
const lam = lon * 3600;
// Hilfsgrößen relativ zum Bezugspunkt Bern (46°57'08.66" N, 7°26'22.50" E).
const p = (phi - 169028.66) / 10000;
const l = (lam - 26782.5) / 10000;
const p2 = p * p;
const l2 = l * l;
// Ostwert (Y in LV03 + 2'000'000 für LV95).
const y =
2600072.37 +
211455.93 * l -
10938.51 * l * p -
0.36 * l * p2 -
44.54 * l * l2;
// Nordwert (X in LV03 + 1'000'000 für LV95).
const x =
1200147.07 +
308807.95 * p +
3745.25 * l2 +
76.63 * p2 -
194.56 * l2 * p +
119.79 * p * p2;
return { e: y, n: x };
}
/**
* LV95 → WGS84 (approximativ, swisstopo-Näherungsformeln).
* Eingabe Meter (E/N), Ausgabe Dezimalgrad.
*/
export function lv95ToWgs84(e: number, n: number): LatLon {
// Relativ zum Projektions-Ursprung, in 1'000'000-Einheiten.
const y = (e - 2600000) / 1000000;
const x = (n - 1200000) / 1000000;
const y2 = y * y;
const x2 = x * x;
// Breite/Länge in 10'000"-Einheiten (Näherungspolynome).
const lam =
2.6779094 +
4.728982 * y +
0.791484 * y * x +
0.1306 * y * x2 -
0.0436 * y * y2;
const phi =
16.9023892 +
3.238272 * x -
0.270978 * y2 -
0.002528 * x2 -
0.0447 * y2 * x -
0.014 * x * x2;
// Zurück in Dezimalgrad (Faktor 100/36).
return {
lat: (phi * 100) / 36,
lon: (lam * 100) / 36,
};
}
/**
* Eine Ursprungs-Verschiebung: der LV95-Punkt, der im Modell auf (0,0)
* abgebildet wird. Alle importierten Objekte eines Imports teilen dieselbe
* Origin, damit sie zueinander lagerichtig bleiben.
*/
export interface GeoOrigin {
e: number;
n: number;
}
/** Erzeugt eine Origin aus einem LV95-Zentrum. */
export function makeOrigin(center: LV95): GeoOrigin {
return { e: center.e, n: center.n };
}
/**
* LV95 → lokale Modell-Meter (Ost→X, Nord→Y) relativ zur Origin. LV95 ist bereits
* metrisch und (nahezu) nordorientiert, daher genügt eine reine Translation.
*/
export function lv95ToLocal(pt: LV95, origin: GeoOrigin): { x: number; y: number } {
return { x: pt.e - origin.e, y: pt.n - origin.n };
}
/** Lokale Modell-Meter → LV95 (Umkehrung von {@link lv95ToLocal}). */
export function localToLv95(
x: number,
y: number,
origin: GeoOrigin,
): LV95 {
return { e: x + origin.e, n: y + origin.n };
}
/**
* WGS84 → lokale Modell-Meter (über LV95 und die Origin). Praktisch für
* OSM-Punkte (lon/lat), die zuerst nach LV95 projiziert und dann verschoben
* werden — so teilen OSM- und swisstopo-Import exakt dasselbe Bezugssystem.
*/
export function wgs84ToLocal(
lat: number,
lon: number,
origin: GeoOrigin,
): { x: number; y: number } {
return lv95ToLocal(wgs84ToLv95(lat, lon), origin);
}
/**
* Baut eine LV95-Bounding-Box (quadratisch) um ein Zentrum mit gegebenem Radius
* (Meter). Reihenfolge [eMin, nMin, eMax, nMax].
*/
export function bboxAround(
center: LV95,
radius: number,
): [number, number, number, number] {
return [
center.e - radius,
center.n - radius,
center.e + radius,
center.n + radius,
];
}
/**
* Wandelt eine LV95-Bounding-Box in eine WGS84-Box (für Overpass:
* [south, west, north, east]). Nutzt die vier Ecken und nimmt die Extremwerte,
* damit die leichte Nord-Rotation der LV95-Projektion abgedeckt ist.
*/
export function lv95BboxToWgs84(
bbox: [number, number, number, number],
): { south: number; west: number; north: number; east: number } {
const [eMin, nMin, eMax, nMax] = bbox;
const corners: LatLon[] = [
lv95ToWgs84(eMin, nMin),
lv95ToWgs84(eMax, nMin),
lv95ToWgs84(eMin, nMax),
lv95ToWgs84(eMax, nMax),
];
return {
south: Math.min(...corners.map((c) => c.lat)),
west: Math.min(...corners.map((c) => c.lon)),
north: Math.max(...corners.map((c) => c.lat)),
east: Math.max(...corners.map((c) => c.lon)),
};
}
+139
View File
@@ -0,0 +1,139 @@
// OpenStreetMap-Anbindung (Overpass API) für den Standort-Import.
//
// Holt für eine LV95-Box Gebäude / Strassen / Wasser / Grünflächen und wandelt
// sie in Polylinien in LOKALEN Modell-Metern (über LV95 + Origin, siehe
// lv95.ts). Overpass sendet je nach Mirror unzuverlässige CORS-Header, daher
// läuft der Abruf über den openbureau-core Geo-Proxy (`viaProxy`), sofern
// `VITE_GEO_PROXY` gesetzt ist — sonst Direktabruf gegen den CORS-fähigen
// Mirror.
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). Einheiten: Meter.
import { lv95BboxToWgs84, makeOrigin, wgs84ToLocal } from "./lv95";
import type { GeoOrigin, LV95 } from "./lv95";
import { bboxAround } from "./lv95";
import type { GeoCategory, GeoFeature } from "./geoContext";
import { viaProxy } from "./geoContext";
// Direkter Mirror mit CORS `*` (Fallback, falls kein Proxy konfiguriert ist).
const OVERPASS = "https://maps.mail.ru/osm/tools/overpass/api/interpreter";
/** Welche OSM-Kategorien der Aufrufer möchte. */
export interface OsmSelection {
buildings: boolean;
roads: boolean;
water: boolean;
green: boolean;
}
/** Ein Overpass-Element mit inline-Geometrie (`out geom`). */
interface OverpassElement {
type: "node" | "way" | "relation";
id: number;
tags?: Record<string, string>;
geometry?: { lat: number; lon: number }[];
members?: {
type: string;
role: string;
geometry?: { lat: number; lon: number }[];
}[];
}
/** Baut die Overpass-QL-Abfrage aus der WGS84-Box + Auswahl. */
function buildQuery(
bbox: { south: number; west: number; north: number; east: number },
sel: OsmSelection,
): string {
const b = `${bbox.south},${bbox.west},${bbox.north},${bbox.east}`;
const parts: string[] = [];
if (sel.buildings) parts.push(`way["building"](${b});`);
if (sel.roads) parts.push(`way["highway"](${b});`);
if (sel.water) {
parts.push(`way["natural"="water"](${b});`);
parts.push(`way["waterway"](${b});`);
parts.push(`relation["natural"="water"](${b});`);
}
if (sel.green) {
parts.push(`way["leisure"="park"](${b});`);
parts.push(`way["landuse"~"grass|forest|meadow|recreation_ground"](${b});`);
parts.push(`way["natural"="wood"](${b});`);
}
return `[out:json][timeout:40];(${parts.join("")});out geom;`;
}
/** Ordnet ein Element anhand seiner Tags einer Kategorie zu (oder null). */
function categorize(
tags: Record<string, string> | undefined,
sel: OsmSelection,
): GeoCategory | null {
if (!tags) return null;
if (sel.buildings && tags.building) return "building";
if (sel.water && (tags.natural === "water" || tags.waterway)) return "water";
if (
sel.green &&
(tags.leisure === "park" ||
tags.natural === "wood" ||
/grass|forest|meadow|recreation_ground/.test(tags.landuse ?? ""))
)
return "green";
if (sel.roads && tags.highway) return "road";
return null;
}
/** Geschlossene Kategorien (Flächen) vs. offene (Strassen). */
function isClosedCategory(cat: GeoCategory): boolean {
return cat !== "road";
}
/**
* OSM-Features für Zentrum + Radius (Meter) laden. `originIn` erlaubt es, exakt
* dieselbe Origin wie ein vorheriger swisstopo-Import zu verwenden.
*/
export async function fetchOsm(
center: LV95,
radius: number,
sel: OsmSelection,
originIn?: GeoOrigin,
): Promise<{ origin: GeoOrigin; features: GeoFeature[] }> {
const origin = originIn ?? makeOrigin(center);
if (!sel.buildings && !sel.roads && !sel.water && !sel.green) {
return { origin, features: [] };
}
const bbox = lv95BboxToWgs84(bboxAround(center, radius));
const query = buildQuery(bbox, sel);
const res = await fetch(viaProxy(OVERPASS), {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: "data=" + encodeURIComponent(query),
});
if (!res.ok) throw new Error(`OSM-Abruf fehlgeschlagen (${res.status})`);
const data = (await res.json()) as { elements?: OverpassElement[] };
const features: GeoFeature[] = [];
const push = (
geometry: { lat: number; lon: number }[] | undefined,
cat: GeoCategory,
) => {
if (!geometry || geometry.length < 2) return;
const pts = geometry.map((g) => wgs84ToLocal(g.lat, g.lon, origin));
const closed = isClosedCategory(cat);
if (closed && pts.length < 3) return;
features.push({ category: cat, pts, closed });
};
for (const el of data.elements ?? []) {
const cat = categorize(el.tags, sel);
if (!cat) continue;
if (el.type === "way") {
push(el.geometry, cat);
} else if (el.type === "relation") {
// Multipolygon: outer-Ringe als einzelne geschlossene Linien übernehmen.
for (const m of el.members ?? []) {
if (m.role === "outer" || m.role === "") push(m.geometry, cat);
}
}
}
return { origin, features };
}
+136
View File
@@ -0,0 +1,136 @@
// swisstopo / geo.admin-Anbindung für den Standort-Import.
//
// • geocode(query) — Ortssuche/Adresse → Kandidaten mit LV95-Koordinaten.
// Nutzt api3.geo.admin.ch SearchServer (sendet CORS `*`,
// daher Direktabruf; Proxy nur als Fallback).
// • fetchBuildings() — Gebäude-GRUNDRISSE für eine LV95-Box → Ringe in
// lokalen Modell-Metern. Nutzt den geo.admin
// MapServer/identify auf `ch.swisstopo.vec25-gebaeude`
// (liefert Polygon-`rings` in LV95, aus dem Browser
// abrufbar). swissBUILDINGS3D-Kachel-Downloads (STAC)
// sind für einen Live-Box-Query im Browser zu schwer und
// werden bewusst NICHT verwendet.
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). Einheiten: Meter.
import { bboxAround, makeOrigin, wgs84ToLv95 } from "./lv95";
import type { GeoOrigin, LV95 } from "./lv95";
import type { GeoFeature } from "./geoContext";
import { viaProxy } from "./geoContext";
const GEOADMIN = "https://api3.geo.admin.ch";
/** Ein Geocode-Treffer: Anzeigename + LV95-Position. */
export interface GeocodeCandidate {
label: string;
lv95: LV95;
}
/** Entfernt HTML-Auszeichnung aus den SearchServer-Labels. */
function stripHtml(s: string): string {
return s.replace(/<[^>]*>/g, "").replace(/\s+/g, " ").trim();
}
/**
* Ortssuche (Adresse oder Ortsname) → bis zu `limit` Kandidaten mit
* LV95-Koordinaten. Wirft bei Netzfehler.
*/
export async function geocode(
query: string,
limit = 8,
): Promise<GeocodeCandidate[]> {
const q = query.trim();
if (!q) return [];
const url =
`${GEOADMIN}/rest/services/api/SearchServer` +
`?searchText=${encodeURIComponent(q)}` +
`&type=locations&sr=2056&limit=${limit}`;
const res = await fetch(viaProxy(url));
if (!res.ok) throw new Error(`Geocode fehlgeschlagen (${res.status})`);
const data = (await res.json()) as {
results?: {
attrs?: {
label?: string;
y?: number; // Ostwert (E) in sr=2056
x?: number; // Nordwert (N) in sr=2056
lat?: number;
lon?: number;
};
}[];
};
const out: GeocodeCandidate[] = [];
for (const r of data.results ?? []) {
const a = r.attrs;
if (!a) continue;
// Bei sr=2056 liefert der SearchServer y=E, x=N (LV95). Fallback: aus lat/lon.
let e = a.y;
let n = a.x;
if (
e == null ||
n == null ||
!Number.isFinite(e) ||
!Number.isFinite(n) ||
e < 2_000_000
) {
if (a.lat != null && a.lon != null) {
const p = wgs84ToLv95(a.lat, a.lon);
e = p.e;
n = p.n;
}
}
if (e == null || n == null) continue;
out.push({ label: stripHtml(a.label ?? q), lv95: { e, n } });
}
return out;
}
/** Esri-Polygon-Geometrie, wie sie identify bei returnGeometry=true liefert. */
interface EsriPolygon {
rings?: number[][][]; // [ring][point][x,y] in LV95
}
/**
* Gebäude-Grundrisse für ein Zentrum + Radius (Meter) laden.
* Rückgabe: Ringe in LOKALEN Metern relativ zur (aus dem Zentrum abgeleiteten
* oder übergebenen) Origin — plus die verwendete Origin, damit weitere Quellen
* dasselbe Bezugssystem teilen.
*/
export async function fetchBuildings(
center: LV95,
radius: number,
originIn?: GeoOrigin,
): Promise<{ origin: GeoOrigin; features: GeoFeature[] }> {
const origin = originIn ?? makeOrigin(center);
const [eMin, nMin, eMax, nMax] = bboxAround(center, radius);
const geom = `${eMin},${nMin},${eMax},${nMax}`;
const url =
`${GEOADMIN}/rest/services/api/MapServer/identify` +
`?geometryType=esriGeometryEnvelope` +
`&geometry=${geom}` +
`&mapExtent=${geom}` +
`&imageDisplay=500,500,96` +
`&tolerance=0` +
`&layers=all:ch.swisstopo.vec25-gebaeude` +
`&returnGeometry=true&sr=2056&limit=200`;
const res = await fetch(viaProxy(url));
if (!res.ok) throw new Error(`Gebäude-Abruf fehlgeschlagen (${res.status})`);
const data = (await res.json()) as {
results?: { geometry?: EsriPolygon }[];
};
const features: GeoFeature[] = [];
for (const r of data.results ?? []) {
const rings = r.geometry?.rings;
if (!rings) continue;
for (const ring of rings) {
if (ring.length < 3) continue;
const pts = ring.map(([e, n]) => ({ x: e - origin.e, y: n - origin.n }));
features.push({ category: "building", pts, closed: true });
}
}
return { origin, features };
}
+269
View File
@@ -0,0 +1,269 @@
// ambientCG-API-Client — Live-Zugriff auf die KOMPLETTE CC0-Materialbibliothek
// (ambientcg.com, ~2000+ Materialien). Statt alles zu bündeln (Gigabytes)
// durchsucht die App die Bibliothek zur Laufzeit über die API, zeigt Thumbnails
// und lädt die Textur-Karten eines Materials ERST bei Auswahl herunter,
// entpackt das Zip (jszip) und erzeugt Blob-URLs je Karte — direkt kompatibel
// mit `MaterialRuntime` (runtime.ts), das beliebige URLs/Blob-URLs lädt.
//
// CORS: Die Such-JSON (`ambientcg.com/api/v2/full_json`) und der Download-
// Starter (`ambientcg.com/get`, ein 302-Redirect) senden KEINE CORS-Header und
// sind daher aus dem Browser NICHT direkt abrufbar. Sie laufen deshalb über
// einen Proxy (Dev: Vite-Proxy `/ambientcg` → siehe vite.config.ts; Prod: eine
// eigene Proxy-Route, konfigurierbar über VITE_AMBIENTCG_PROXY). Die Thumbnails
// (acg-media.struffelproductions.com) senden `Access-Control-Allow-Origin: *`
// und werden daher DIREKT geladen (kein Proxy nötig).
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import JSZip from "jszip";
import type { ComponentMaterial } from "../model/types";
/**
* Proxy-Basis für die CORS-behafteten ambientCG-Endpunkte (`/api/...` und
* `/get`). Default `/ambientcg` — im Dev vom Vite-Proxy auf `https://
* ambientcg.com` gemappt. Für Prod via `VITE_AMBIENTCG_PROXY` überschreibbar
* (z. B. eine eigene Proxy-Route). Ohne trailing slash.
*/
// `import.meta.env` wird von Vite injiziert; da im Projekt keine vite/client-
// Typen eingebunden sind, greifen wir defensiv typisiert darauf zu.
const ENV = (import.meta as unknown as { env?: Record<string, string | undefined> })
.env;
const PROXY_BASE: string =
ENV?.VITE_AMBIENTCG_PROXY?.replace(/\/$/, "") ?? "/ambientcg";
/** Ein Suchtreffer der ambientCG-Bibliothek (für das Thumbnail-Grid). */
export interface AmbientMaterial {
/** ambientCG-Asset-ID, z. B. „Wood095". */
id: string;
/** Anzeigename (aus der API; sonst die ID). */
name: string;
/** Kategorie-Anzeigename, z. B. „Wood" (kann leer sein). */
category: string;
/** Thumbnail-URL (direkt ladbar, CORS `*`). */
thumbUrl: string;
}
/** Eine Seite Suchtreffer plus Gesamtzahl (für Pagination/„mehr laden"). */
export interface AmbientSearchResult {
items: AmbientMaterial[];
/** Gesamtzahl gefundener Assets (server-seitig gezählt). */
total: number;
/** Verwendeter Offset dieser Seite. */
offset: number;
/** Angeforderte Seitengröße. */
limit: number;
}
/** Verfügbare Textur-Auflösungen (ambientCG-Attribut-Präfix). */
export type AmbientResolution = "1K" | "2K" | "4K";
/** Optionen für {@link searchMaterials}. */
export interface SearchOptions {
/** Freitext-Suche (leer = alle, nach Popularität sortiert). */
query?: string;
/** Optionaler Kategorie-Filter (ambientCG-Kategorie-Schlüssel). */
category?: string;
/** Seitengröße (Default 24). */
limit?: number;
/** Offset für Pagination (Default 0). */
offset?: number;
/** Abbruch-Signal (z. B. bei neuer Suche). */
signal?: AbortSignal;
}
// ── Interne API-Typen (nur die genutzten Felder) ───────────────────────────
interface RawPreviewImage {
[size: string]: string | undefined;
}
interface RawAsset {
assetId: string;
displayName?: string;
customDisplayName?: string;
displayCategory?: string;
category?: string | null;
previewImage?: RawPreviewImage;
}
interface RawFullJson {
numberOfResults?: number;
foundAssets?: RawAsset[];
}
/** Baut eine Proxy-URL für einen ambientCG-Pfad (mit führendem `/`). */
function proxyUrl(path: string): string {
return `${PROXY_BASE}${path.startsWith("/") ? path : `/${path}`}`;
}
/** Wählt das größte verfügbare Thumbnail aus dem previewImage-Objekt. */
function pickThumb(preview: RawPreviewImage | undefined): string {
if (!preview) return "";
// Bevorzugt größere PNG-Thumbnails (bessere Vorschau im Grid).
const order = ["512-PNG", "256-PNG", "128-PNG", "64-PNG"];
for (const key of order) {
const url = preview[key];
if (url) return url;
}
// Fallback: irgendein vorhandener Wert.
for (const v of Object.values(preview)) if (v) return v;
return "";
}
/**
* Durchsucht die ambientCG-Materialbibliothek. Liefert eine Seite Treffer mit
* Thumbnails plus die Gesamtzahl (für „mehr laden"). Läuft über den Proxy
* (CORS). Wirft bei Netz-/CORS-Fehlern — der Aufrufer zeigt einen Hinweis.
*/
export async function searchMaterials(
opts: SearchOptions = {},
): Promise<AmbientSearchResult> {
const { query, category, limit = 24, offset = 0, signal } = opts;
const params = new URLSearchParams();
params.set("type", "Material");
// Nur die für Grid + IDs nötigen Daten anfordern (kleinere Antwort).
params.set("include", "imageData");
params.set("limit", String(limit));
params.set("offset", String(offset));
if (query && query.trim()) params.set("q", query.trim());
if (category && category.trim()) params.set("category", category.trim());
// Ohne Freitext nach Popularität sortieren (sinnvolle Default-Reihenfolge).
if (!query || !query.trim()) params.set("sort", "Popular");
const url = proxyUrl(`/api/v2/full_json?${params.toString()}`);
const res = await fetch(url, { signal });
if (!res.ok) {
throw new Error(`ambientCG-API HTTP ${res.status}`);
}
const json = (await res.json()) as RawFullJson;
const items: AmbientMaterial[] = (json.foundAssets ?? []).map((a) => ({
id: a.assetId,
name: a.customDisplayName || a.displayName || a.assetId,
category: a.displayCategory || a.category || "",
thumbUrl: pickThumb(a.previewImage),
}));
return {
items,
total: json.numberOfResults ?? items.length,
offset,
limit,
};
}
// ── Kategorie-Liste (statisch, aus den ambientCG-Material-Kategorien) ───────
// Die häufigsten Material-Kategorien für den Filter. Die API kennt weitere; das
// deckt die BIM-relevanten Oberflächen ab. Wert = ambientCG-Kategorie-Schlüssel.
export const AMBIENT_CATEGORIES: string[] = [
"Wood",
"WoodFloor",
"Concrete",
"Bricks",
"Plaster",
"Tiles",
"Marble",
"Rock",
"PavingStones",
"Metal",
"Ground",
"Gravel",
"Grass",
"Fabric",
"Leather",
"Asphalt",
"Terrazzo",
"Wallpaper",
"Roof",
"OfficeCeiling",
];
// ── Karten-Download + Entpacken ────────────────────────────────────────────
/** Zuordnung ambientCG-Dateinamen-Bestandteile → ComponentMaterial-Karten. */
const MAP_MATCHERS: { kind: keyof ComponentMaterial; needles: string[] }[] = [
{ kind: "color", needles: ["_color", "_col", "_diffuse", "_albedo"] },
{ kind: "normal", needles: ["_normalgl", "_normal", "_nrm", "_nor"] },
{ kind: "roughness", needles: ["_roughness", "_rough", "_rgh"] },
{ kind: "metalness", needles: ["_metalness", "_metallic", "_metal"] },
{ kind: "displacement", needles: ["_displacement", "_disp", "_height"] },
{ kind: "ao", needles: ["_ambientocclusion", "_ao", "_occlusion"] },
];
/** Ordnet einen Zip-Eintragsnamen einer Karten-Art zu (oder null). */
function classifyMap(fileName: string): keyof ComponentMaterial | null {
const lower = fileName.toLowerCase();
if (!/\.(jpg|jpeg|png)$/.test(lower)) return null;
// ambientCG liefert die Normal-Map in zwei Konventionen: DirectX (…NormalDX)
// und OpenGL (…NormalGL). three.js erwartet OpenGL — die DX-Variante daher
// NICHT als Normal-Karte übernehmen (sonst kippt die Tiefe je nach Zip-
// Reihenfolge in die falsche Richtung).
if (lower.includes("_normaldx")) return null;
for (const m of MAP_MATCHERS) {
if (m.needles.some((n) => lower.includes(n))) return m.kind;
}
return null;
}
/** Ergebnis von {@link fetchMaterialMaps}: Karten als Blob-URLs + Metadaten. */
export interface FetchedMaterial {
material: ComponentMaterial;
/** Alle erzeugten Blob-URLs (zum Freigeben via `revokeMaterial`). */
blobUrls: string[];
}
/**
* Lädt das 1K-JPG-Zip eines ambientCG-Materials (über den Proxy, da `/get`
* keine CORS-Header sendet), entpackt es mit jszip und erzeugt je erkannter
* Karte (color/normal/roughness/metalness/displacement/ao) eine Blob-URL. Das
* Ergebnis ist ein `ComponentMaterial`, das `MaterialRuntime` direkt laden kann.
*
* `resolution` wählt das Auflösungs-Attribut (1K/2K/4K). 1K ist der sinnvolle
* Default (schnell, für Echtzeit-3D ausreichend).
*/
export async function fetchMaterialMaps(
id: string,
resolution: AmbientResolution = "1K",
sizeM = 1.0,
signal?: AbortSignal,
): Promise<FetchedMaterial> {
const file = `${id}_${resolution}-JPG.zip`;
const url = proxyUrl(`/get?file=${encodeURIComponent(file)}`);
const res = await fetch(url, { signal });
if (!res.ok) {
throw new Error(`ambientCG-Download HTTP ${res.status}`);
}
const buf = await res.arrayBuffer();
const zip = await JSZip.loadAsync(buf);
const material: ComponentMaterial = { libraryId: id, sizeM };
const blobUrls: string[] = [];
// Alle Bild-Einträge durchgehen, je Karten-Art die erste Übereinstimmung
// übernehmen (ambientCG liefert je Art genau eine Datei).
const entries = Object.values(zip.files).filter((f) => !f.dir);
for (const entry of entries) {
const kind = classifyMap(entry.name);
if (!kind || material[kind]) continue;
const blob = await entry.async("blob");
// Korrekten Bild-MIME setzen, damit der Browser die Blob-URL als Bild lädt.
const ext = entry.name.toLowerCase().endsWith(".png")
? "image/png"
: "image/jpeg";
const typed = blob.type ? blob : new Blob([blob], { type: ext });
const objUrl = URL.createObjectURL(typed);
(material as Record<string, unknown>)[kind] = objUrl;
blobUrls.push(objUrl);
}
if (blobUrls.length === 0) {
throw new Error("Keine Textur-Karten im Zip gefunden");
}
return { material, blobUrls };
}
/** Gibt die Blob-URLs eines heruntergeladenen Materials frei. */
export function revokeMaterial(blobUrls: string[]): void {
for (const u of blobUrls) URL.revokeObjectURL(u);
}
+171
View File
@@ -0,0 +1,171 @@
// Auto-generated built-in material library (ambientCG, CC0 1.0).
// Static asset paths under /public; loadable directly via three.js TextureLoader.
// Source data mirrors public/assets/materials/manifest.json.
export type MaterialMapKind =
| 'color'
| 'normal'
| 'roughness'
| 'metalness'
| 'displacement'
| 'ao';
export interface MaterialAsset {
/** ambientCG asset id, e.g. 'Concrete048'. */
id: string;
/** Localized display name (German). */
name: string;
/** ambientCG category key, e.g. 'Concrete'. */
category: string;
/** Absolute public paths to the available texture maps. */
maps: Partial<Record<MaterialMapKind, string>>;
}
export const MATERIAL_LIBRARY_SOURCE = "ambientCG (ambientcg.com)";
export const MATERIAL_LIBRARY_LICENSE = "CC0 1.0 Universal (Public Domain)";
export const MATERIAL_LIBRARY_RESOLUTION = "1K";
export const MATERIAL_LIBRARY: MaterialAsset[] = [
{
id: "Concrete048",
name: "Beton",
category: "Concrete",
maps: {
color: "/assets/materials/Concrete048/color.jpg",
normal: "/assets/materials/Concrete048/normal.jpg",
roughness: "/assets/materials/Concrete048/roughness.jpg",
displacement: "/assets/materials/Concrete048/displacement.jpg",
ao: "/assets/materials/Concrete048/ao.jpg",
},
},
{
id: "Plaster001",
name: "Putz/Stuck",
category: "Plaster",
maps: {
color: "/assets/materials/Plaster001/color.jpg",
normal: "/assets/materials/Plaster001/normal.jpg",
roughness: "/assets/materials/Plaster001/roughness.jpg",
displacement: "/assets/materials/Plaster001/displacement.jpg",
},
},
{
id: "Wood095",
name: "Holz-Diele",
category: "Wood",
maps: {
color: "/assets/materials/Wood095/color.jpg",
normal: "/assets/materials/Wood095/normal.jpg",
roughness: "/assets/materials/Wood095/roughness.jpg",
displacement: "/assets/materials/Wood095/displacement.jpg",
},
},
{
id: "WoodFloor051",
name: "Holz-Parkett",
category: "WoodFloor",
maps: {
color: "/assets/materials/WoodFloor051/color.jpg",
normal: "/assets/materials/WoodFloor051/normal.jpg",
roughness: "/assets/materials/WoodFloor051/roughness.jpg",
displacement: "/assets/materials/WoodFloor051/displacement.jpg",
ao: "/assets/materials/WoodFloor051/ao.jpg",
},
},
{
id: "Bricks104",
name: "Backstein",
category: "Bricks",
maps: {
color: "/assets/materials/Bricks104/color.jpg",
normal: "/assets/materials/Bricks104/normal.jpg",
roughness: "/assets/materials/Bricks104/roughness.jpg",
displacement: "/assets/materials/Bricks104/displacement.jpg",
ao: "/assets/materials/Bricks104/ao.jpg",
},
},
{
id: "Tiles141",
name: "Bodenfliesen",
category: "Tiles",
maps: {
color: "/assets/materials/Tiles141/color.jpg",
normal: "/assets/materials/Tiles141/normal.jpg",
roughness: "/assets/materials/Tiles141/roughness.jpg",
displacement: "/assets/materials/Tiles141/displacement.jpg",
},
},
{
id: "Marble012",
name: "Naturstein/Marmor",
category: "Marble",
maps: {
color: "/assets/materials/Marble012/color.jpg",
normal: "/assets/materials/Marble012/normal.jpg",
roughness: "/assets/materials/Marble012/roughness.jpg",
displacement: "/assets/materials/Marble012/displacement.jpg",
},
},
{
id: "PavingStones150",
name: "Pflasterstein",
category: "PavingStones",
maps: {
color: "/assets/materials/PavingStones150/color.jpg",
normal: "/assets/materials/PavingStones150/normal.jpg",
roughness: "/assets/materials/PavingStones150/roughness.jpg",
displacement: "/assets/materials/PavingStones150/displacement.jpg",
ao: "/assets/materials/PavingStones150/ao.jpg",
},
},
{
id: "Metal063",
name: "Metall",
category: "Metal",
maps: {
color: "/assets/materials/Metal063/color.jpg",
normal: "/assets/materials/Metal063/normal.jpg",
roughness: "/assets/materials/Metal063/roughness.jpg",
metalness: "/assets/materials/Metal063/metalness.jpg",
displacement: "/assets/materials/Metal063/displacement.jpg",
},
},
{
id: "Gravel043",
name: "Kies/Schotter",
category: "Gravel",
maps: {
color: "/assets/materials/Gravel043/color.jpg",
normal: "/assets/materials/Gravel043/normal.jpg",
roughness: "/assets/materials/Gravel043/roughness.jpg",
displacement: "/assets/materials/Gravel043/displacement.jpg",
ao: "/assets/materials/Gravel043/ao.jpg",
},
},
{
id: "Grass005",
name: "Gras",
category: "Grass",
maps: {
color: "/assets/materials/Grass005/color.jpg",
normal: "/assets/materials/Grass005/normal.jpg",
roughness: "/assets/materials/Grass005/roughness.jpg",
displacement: "/assets/materials/Grass005/displacement.jpg",
ao: "/assets/materials/Grass005/ao.jpg",
},
},
{
id: "Ground103",
name: "Boden",
category: "Ground",
maps: {
color: "/assets/materials/Ground103/color.jpg",
normal: "/assets/materials/Ground103/normal.jpg",
roughness: "/assets/materials/Ground103/roughness.jpg",
displacement: "/assets/materials/Ground103/displacement.jpg",
ao: "/assets/materials/Ground103/ao.jpg",
},
},
];
export default MATERIAL_LIBRARY;
+169
View File
@@ -0,0 +1,169 @@
// 3D-Material-Laufzeit: baut aus einem `ComponentMaterial` (Karten-URLs +
// physische Kachelgröße) ein gecachtes three.js `MeshStandardMaterial`. Die
// Texturen werden lazy über den `TextureLoader` geladen (asynchron; das
// Material erscheint sofort, die Karten „poppen" nach dem Laden ein) und je
// Karten-URL nur EINMAL dekodiert (geteilte Bilddaten). Die physische
// Skalierung läuft über `texture.repeat`: ExtrudeGeometry erzeugt die Seiten-
// wand-UVs bereits in WELT-Metern (U = Position entlang der Wand in Metern,
// V = Höhe in Metern). Eine Kachel soll `sizeM` Meter messen → `repeat = 1/sizeM`
// (uniform), unabhängig von der Flächengröße. Dadurch braucht es KEINE flächen-
// spezifischen Material-Instanzen — alle Wandflächen eines Bauteils teilen sich
// dasselbe Material; die Kacheln laufen über Flächengrenzen hinweg konsistent.
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import * as THREE from "three";
import type { ComponentMaterial } from "../model/types";
import type { MaterialAsset } from "./library";
/** Default-Kachelgröße in Metern, falls `sizeM` fehlt. */
export const DEFAULT_TILE_SIZE_M = 1.0;
/**
* Cache + Loader für PBR-Materialien. Lebt so lange wie der Viewport-Aufbau
* (eine Instanz je Szenen-Aufbau) und gibt im `dispose()` alle Texturen und
* Materialien frei. Bilddaten werden je URL geteilt (mehrere Bauteile, die
* dieselbe Karte nutzen, dekodieren sie nur einmal); die je Material geklonten
* Textur-Objekte tragen ihre eigene `repeat`-Transform.
*/
export class MaterialRuntime {
private readonly loader = new THREE.TextureLoader();
/** Geteilte Quell-Texturen je URL (Bilddaten-Quelle für Klone). */
private readonly sources = new Map<string, THREE.Texture>();
/** Klone je Quell-URL — werden beim Bild-Load gesammelt geflaggt. */
private readonly clonesBySource = new Map<string, Set<THREE.Texture>>();
/** Fertige Materialien je Cache-Schlüssel (stabile Signatur). */
private readonly materials = new Map<string, THREE.MeshStandardMaterial>();
/** Alle erzeugten Textur-Klone (für dispose). */
private readonly textures = new Set<THREE.Texture>();
/**
* Lädt (oder liefert aus dem Cache) die Quell-Textur für `url`. Beim ersten
* Aufruf wird asynchron geladen; sobald das Bild da ist, werden ALLE Klone
* dieser Quelle als aktualisierungsbedürftig markiert (sonst zeigen sie kein
* Bild bzw. der Renderer meldet „no image data"). `srgb` markiert die Farb-/
* Albedo-Karte als sRGB (korrekte Farbe), alle anderen Karten sind Linear-Daten.
*/
private source(url: string, srgb: boolean): THREE.Texture {
const key = `${srgb ? "s" : "l"}|${url}`;
let tex = this.sources.get(key);
if (!tex) {
tex = this.loader.load(url, () => {
// Bild geladen → alle bereits erzeugten Klone neu hochladen lassen.
for (const clone of this.clonesBySource.get(key) ?? []) {
clone.needsUpdate = true;
}
});
tex.colorSpace = srgb ? THREE.SRGBColorSpace : THREE.NoColorSpace;
this.sources.set(key, tex);
this.clonesBySource.set(key, new Set());
}
return tex;
}
/**
* Eine Material-Karte: klont die Quell-Textur (teilt deren Bilddaten via
* `.image`/`.source`, kein erneutes Dekodieren), setzt RepeatWrapping und
* `repeat = 1/sizeM` (physische Kachelung). Der Klon wird für dispose gemerkt.
*/
private map(url: string, srgb: boolean, sizeM: number): THREE.Texture {
const key = `${srgb ? "s" : "l"}|${url}`;
const src = this.source(url, srgb);
const tex = src.clone();
tex.wrapS = THREE.RepeatWrapping;
tex.wrapT = THREE.RepeatWrapping;
const r = 1 / Math.max(sizeM, 0.001);
tex.repeat.set(r, r);
// Ist das Bild bereits geladen, sofort flaggen; sonst übernimmt das der
// Source-onLoad (s. source()), der alle Klone dieser Quelle aktualisiert.
// (three.js gibt für asynchron geladene Klone vor dem Bild-Load eine
// harmlose „no image data"-Konsolenmeldung aus — die Karten erscheinen
// korrekt, sobald das Bild da ist.)
if (src.image) tex.needsUpdate = true;
this.clonesBySource.get(key)?.add(tex);
this.textures.add(tex);
return tex;
}
/** Stabile Cache-Signatur eines Materials (alle Karten-URLs + Größe). */
private static keyOf(m: ComponentMaterial): string {
return [
m.color ?? "",
m.normal ?? "",
m.roughness ?? "",
m.metalness ?? "",
m.displacement ?? "",
m.ao ?? "",
m.sizeM ?? DEFAULT_TILE_SIZE_M,
].join("|");
}
/**
* Liefert das (gecachte) `MeshStandardMaterial` für ein `ComponentMaterial`
* oder null, wenn keinerlei Karte gesetzt ist (dann gilt das matte Default-
* Verhalten). Das Material gilt für ALLE Flächen des Bauteils (geteilte
* Kachel-Skalierung), da die UVs in Welt-Metern liegen.
*/
get(m: ComponentMaterial | undefined): THREE.MeshStandardMaterial | null {
if (!m) return null;
const hasAnyMap =
m.color || m.normal || m.roughness || m.metalness || m.displacement || m.ao;
if (!hasAnyMap) return null;
const key = MaterialRuntime.keyOf(m);
const cached = this.materials.get(key);
if (cached) return cached;
const sizeM = m.sizeM ?? DEFAULT_TILE_SIZE_M;
const mat = new THREE.MeshStandardMaterial({
// Weiß als Albedo-Basis, damit die Farb-Karte unverfälscht erscheint;
// ohne Farb-Karte ein neutrales Hellgrau (sichtbares Volumen).
color: m.color ? 0xffffff : 0xcccccc,
roughness: 1,
metalness: m.metalness ? 1 : 0,
});
if (m.color) mat.map = this.map(m.color, true, sizeM);
if (m.normal) mat.normalMap = this.map(m.normal, false, sizeM);
if (m.roughness) mat.roughnessMap = this.map(m.roughness, false, sizeM);
if (m.metalness) mat.metalnessMap = this.map(m.metalness, false, sizeM);
if (m.ao) mat.aoMap = this.map(m.ao, false, sizeM);
if (m.displacement) {
mat.displacementMap = this.map(m.displacement, false, sizeM);
// Sehr dezent — eine echte Verschiebung braucht Tessellation; hier nur ein
// Hauch, damit die Silhouette nicht aufreißt (Tiefe kommt aus normalMap).
mat.displacementScale = 0.01;
}
mat.needsUpdate = true;
this.materials.set(key, mat);
return mat;
}
/** Gibt alle Texturen und Materialien frei. */
dispose(): void {
for (const tex of this.sources.values()) tex.dispose();
this.sources.clear();
for (const tex of this.textures) tex.dispose();
this.textures.clear();
this.clonesBySource.clear();
for (const mat of this.materials.values()) mat.dispose();
this.materials.clear();
}
}
/** Baut aus einem Bibliotheks-Asset ein `ComponentMaterial` (Karten + Größe). */
export function materialFromAsset(
asset: MaterialAsset,
sizeM = DEFAULT_TILE_SIZE_M,
): ComponentMaterial {
return {
libraryId: asset.id,
color: asset.maps.color,
normal: asset.maps.normal,
roughness: asset.maps.roughness,
metalness: asset.maps.metalness,
displacement: asset.maps.displacement,
ao: asset.maps.ao,
sizeM,
};
}
+105
View File
@@ -0,0 +1,105 @@
import { describe, it, expect } from "vitest";
import {
defaultRoomStamp,
roomStampToDoc,
roomStampExtraLines,
roomDisplayName,
} from "./roomStamp";
import type { Room } from "./types";
function makeRoom(over: Partial<Room> = {}): Room {
return {
id: "r1",
type: "room",
floorId: "f1",
categoryCode: "45",
siaCategory: "HNF",
name: "Wohnen",
boundary: [
{ x: 0, y: 0 },
{ x: 4, y: 0 },
{ x: 4, y: 3 },
{ x: 0, y: 3 },
],
color: "#123456",
...over,
};
}
describe("defaultRoomStamp", () => {
it("leitet Name/Flags aus dem Raum ab", () => {
const stamp = defaultRoomStamp(makeRoom());
expect(stamp.name).toBe("Wohnen");
expect(stamp.showFloorArea).toBe(true);
expect(stamp.showUsage).toBe(true);
expect(stamp.number).toBeUndefined();
});
});
describe("roomStampToDoc", () => {
it("eine Zeile ohne nameLine2", () => {
const doc = roomStampToDoc({ name: "Bad", showFloorArea: true, showUsage: true });
expect(doc.paragraphs).toHaveLength(1);
});
it("zwei Zeilen mit nameLine2", () => {
const doc = roomStampToDoc({
name: "Bad",
nameLine2: "WC",
showFloorArea: true,
showUsage: true,
});
expect(doc.paragraphs).toHaveLength(2);
});
it("Nummer als Präfix-Run in Zeile 1", () => {
const doc = roomStampToDoc({
number: "1.01",
name: "Bad",
showFloorArea: true,
showUsage: true,
});
expect(doc.paragraphs[0].runs).toHaveLength(2);
expect(doc.paragraphs[0].runs[0].text).toBe("1.01 ");
expect(doc.paragraphs[0].runs[1].text).toBe("Bad");
});
});
describe("roomStampExtraLines", () => {
it("beachtet Flags und Präfix", () => {
const lines = roomStampExtraLines(
{ name: "Bad", showFloorArea: true, showUsage: true, floorAreaPrefix: "BF " },
{ netArea: 12.345, siaCategory: "HNF", siaLabel: "Hauptnutzfläche" },
);
expect(lines[0]).toBe("BF 12.35 m²");
expect(lines[1]).toBe("HNF · Hauptnutzfläche");
});
it("blendet Zeilen aus, wenn Flags falsch", () => {
const lines = roomStampExtraLines(
{ name: "Bad", showFloorArea: false, showUsage: false },
{ netArea: 12.345, siaCategory: "HNF", siaLabel: "Hauptnutzfläche" },
);
expect(lines).toHaveLength(0);
});
});
describe("roomDisplayName", () => {
it("faellt auf room.name zurück ohne Stempel", () => {
expect(roomDisplayName(makeRoom())).toBe("Wohnen");
});
it("zieht name + nameLine2 zusammen", () => {
const room = makeRoom({
stamp: { name: "Bad", nameLine2: "WC", showFloorArea: true, showUsage: true },
});
expect(roomDisplayName(room)).toBe("Bad WC");
});
it("nutzt nur name ohne nameLine2", () => {
const room = makeRoom({
stamp: { name: "Küche", showFloorArea: true, showUsage: true },
});
expect(roomDisplayName(room)).toBe("Küche");
});
});
+86
View File
@@ -0,0 +1,86 @@
// Reines Modul für den strukturierten Raum-Stempel (Feldmodell). App-frei und
// unit-getestet: baut aus einem RoomStamp das gerenderte Rich-Text-Dokument
// (roomStampToDoc) und die Live-Zeilen (roomStampExtraLines), und liefert den
// Anzeigenamen für Listen (roomDisplayName). Keine React-/Store-Abhängigkeit.
//
// Folge-Arbeit: Fensterfläche (braucht Öffnung-in-Raum-Geometrie).
import type { Room, RoomStamp } from "./types";
import type { SiaCategory } from "../geometry/roomArea";
import type { RichTextDoc, Paragraph, TextRun } from "../text/richText";
import { makeRun } from "../text/richText";
/** Grösse (pt) des Raumnamens (Zeile 1) relativ zur Stempel-Basis. */
const NAME_PT = 12;
/** Grösse (pt) der kleinen Raumnummer als Präfix. */
const NUMBER_PT = 9;
/** Grösse (pt) der zweiten Namenszeile. */
const LINE2_PT = 9;
/**
* Standard-Stempel aus einem Raum ableiten: Name = Raum-Name, Bodenfläche und
* Nutzung angezeigt. Nummer/Zeile 2 bleiben leer (frei ergänzbar).
*/
export function defaultRoomStamp(room: Room): RoomStamp {
return {
name: room.name,
showFloorArea: true,
showUsage: true,
};
}
/**
* Baut das Rich-Text-Dokument des Stempels:
* • Zeile 1: optionale Raumnummer (kleiner Präfix) + Raumname (grösser, fett).
* • Zeile 2 (optional): nameLine2 (kursiv, kleiner).
* Die Live-Zeilen (Fläche/Nutzung) werden separat als extraLines gesetzt.
*/
export function roomStampToDoc(stamp: RoomStamp): RichTextDoc {
const line1: TextRun[] = [];
const number = (stamp.number ?? "").trim();
if (number) {
line1.push(makeRun(`${number} `, { sizePt: NUMBER_PT }));
}
line1.push(makeRun(stamp.name, { bold: true, sizePt: NAME_PT }));
const paragraphs: Paragraph[] = [{ runs: line1 }];
const line2 = (stamp.nameLine2 ?? "").trim();
if (line2) {
paragraphs.push({ runs: [makeRun(line2, { italic: true, sizePt: LINE2_PT })] });
}
return { version: 1, paragraphs };
}
/** Kontext für die Live-Zeilen (nicht gespeichert, bei jedem Rendern frisch). */
export interface RoomStampContext {
netArea: number;
siaCategory: SiaCategory;
siaLabel: string;
}
/**
* Die Live-Zeilen unter dem Stempel: Bodenfläche (mit Präfix) wenn showFloorArea,
* Nutzung (SIA-Kürzel · Bezeichnung) wenn showUsage.
*/
export function roomStampExtraLines(stamp: RoomStamp, ctx: RoomStampContext): string[] {
const lines: string[] = [];
if (stamp.showFloorArea) {
const prefix = stamp.floorAreaPrefix ?? "";
lines.push(`${prefix}${ctx.netArea.toFixed(2)}`);
}
if (stamp.showUsage) {
lines.push(`${ctx.siaCategory} · ${ctx.siaLabel}`);
}
return lines;
}
/**
* Anzeigename für Listen: strukturierter Name (name + optional nameLine2 zu
* einem Namen zusammengezogen), sonst der Alt-Raumname.
*/
export function roomDisplayName(room: Room): string {
const stamp = room.stamp;
if (!stamp) return room.name;
const line2 = (stamp.nameLine2 ?? "").trim();
return line2 ? `${stamp.name} ${line2}` : stamp.name;
}
+69 -1
View File
@@ -133,9 +133,10 @@ export const sampleProject: Project = {
},
{ code: "30", name: "Decken", color: "#605850", lw: 0.35, visible: true, locked: false },
{ code: "31", name: "Dächer", color: "#7a4a3a", lw: 0.35, visible: true, locked: false },
{ code: "40", name: "Treppen", color: "#4a6a58", lw: 0.35, visible: true, locked: false },
{ code: "35", name: "Träger", color: "#a87858", lw: 0.5, visible: true, locked: false },
{ code: "50", name: "Text", color: "#d0d0d0", lw: 0.13, visible: true, locked: false },
{ code: "60", name: "Plangrafik", color: "#c0a040", lw: 0.13, visible: true, locked: false },
{ code: "60", name: "Räume", color: "#5a7a9a", lw: 0.13, visible: true, locked: false },
{ code: "90", name: "Referenzen", color: "#585860", lw: 0.13, visible: true, locked: false },
{ code: "99", name: "Konstruktion", color: "#404048", lw: 0.13, visible: true, locked: false },
],
@@ -173,6 +174,73 @@ export const sampleProject: Project = {
hinge: "start",
},
],
// Öffnungen (Fenster/Türen) — ein Beispiel-Fenster in der EG-Südwand (W1,
// frei von der Tür) und in der EG-Nordwand (W3). Kategorie „21" (Türen/Fenster).
openings: [
{
id: "O1",
type: "opening",
hostWallId: "W1",
categoryCode: "21",
kind: "window",
position: 3.6, // hinter der Tür (2.02.9), noch innerhalb der 5-m-Wand
width: 1.0,
height: 1.2,
sillHeight: 0.9,
},
{
id: "O2",
type: "opening",
hostWallId: "W3",
categoryCode: "21",
kind: "window",
position: 1.8,
width: 1.4,
height: 1.3,
sillHeight: 0.9,
},
],
// Decken (Slabs) — anfangs leer; werden mit dem Decken-Werkzeug gefüllt.
ceilings: [],
// Treppen — eine gerade Beispieltreppe im EG entlang der Ostwand, steigt ins OG
// (totalRise = Geschosshöhe). Kategorie „40 Treppen".
stairs: [
{
id: "ST1",
type: "stair",
floorId: "eg",
categoryCode: "40",
shape: "straight",
start: { x: 3.7, y: 0.4 },
dir: { x: 0, y: 1 },
runLength: 3.2,
width: 1.0,
totalRise: 2.6,
stepCount: 15,
up: true,
},
],
// Räume (SIA-416-Flächen) — ein Beispielraum im EG (lichte Innenkontur des
// 5×4-m-Umrisses, Aussenwände 0.2 m → Innenmaß 4.8×3.8 m ≈ 18.24 m²).
// Kategorie „45 Räume", SIA-Blatt HNF (Hauptnutzfläche).
rooms: [
{
id: "R1",
type: "room",
floorId: "eg",
categoryCode: "60",
siaCategory: "HNF",
name: "Wohnen",
boundary: [
{ x: 0.1, y: 0.1 },
{ x: 4.9, y: 0.1 },
{ x: 4.9, y: 3.9 },
{ x: 0.1, y: 3.9 },
],
color: "#5a7a9a",
stampAnchor: { x: 2.5, y: 2.0 },
},
],
// Freie 2D-Zeichengeometrie — anfangs leer; wird mit den Zeichenwerkzeugen
// gefüllt (docs/design/drawing-tools.md).
drawings2d: [],
+29
View File
@@ -635,6 +635,35 @@ export interface Room {
* Text. Die LIVE-Flächenzeile wird beim Rendern separat darunter gesetzt.
*/
stampDoc?: RichTextDoc;
/**
* Strukturierter Raum-Stempel (Feldmodell). Ist er gesetzt, hat er Vorrang vor
* `stampDoc`/`name`: der Stempel-Text wird aus den Feldern gebaut, die Live-
* Zeilen (Bodenfläche/Nutzung) aus den Flags. Fehlt er, gilt der Alt-Pfad.
*/
stamp?: RoomStamp;
}
/**
* Feldmodell des Raum-Stempels. Ersetzt den freien Text durch benannte Felder;
* daraus baut roomStamp.ts sowohl das gerenderte Rich-Text-Dokument als auch die
* Live-Zeilen. Minimal gehalten (MVP).
*
* Folge-Arbeit: Fensterfläche (braucht Öffnung-in-Raum-Geometrie) — hier bewusst
* NICHT enthalten.
*/
export interface RoomStamp {
/** Raumnummer (kleiner Präfix in Zeile 1). */
number?: string;
/** Raumname (Zeile 1). */
name: string;
/** Raumname Zeile 2 (in Listen mit `name` zu einem Namen zusammengezogen). */
nameLine2?: string;
/** Bodenfläche anzeigen (Live-Zeile). */
showFloorArea: boolean;
/** Präfix vor der Bodenfläche, z. B. "BF " (Default leer). */
floorAreaPrefix?: string;
/** Nutzung (HNF/… · Bezeichnung) anzeigen (Live-Zeile). */
showUsage: boolean;
}
/** Eine Tür, gehostet in einer Wand. Ihr Geschoss ergibt sich aus der Wand. */
+57 -3
View File
@@ -3,10 +3,10 @@
// und die vertikale Ausdehnung (UK..OK). Alle Defaults entsprechen exakt dem
// bisherigen Verhalten, sodass bestehende Wände unverändert rendern.
//
// Bezeichner englisch, Kommentare deutsch (CLAUDE.md).
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import type { Project, VerticalAnchor, Wall } from "./types";
import { getFloor } from "./types";
import type { Ceiling, Project, Stair, VerticalAnchor, Wall } from "./types";
import { ceilingThickness, getFloor } from "./types";
/**
* Versatz der Schichtstapelung entlang der linken Normalen `+n` (leftNormal),
@@ -70,6 +70,60 @@ export function wallVerticalExtent(
return { zBottom, zTop };
}
/**
* Vertikale Ausdehnung einer Decke (absolute Z-Werte in Metern).
* • zTop: aus `ceiling.top`, sonst Oberkante des Geschosses
* (baseElevation + floorHeight) — also bündig mit dem Wandkopf.
* • zBottom: zTop Deckendicke (die Decke wächst nach UNTEN).
* Fehlt das Geschoss, dient 0 als sicherer Rückfall für die Oberkante.
*/
export function ceilingVerticalExtent(
project: Project,
ceiling: Ceiling,
): { zBottom: number; zTop: number } {
let floorTop = 0;
try {
const floor = getFloor(project, ceiling.floorId);
floorTop = (floor.baseElevation ?? 0) + (floor.floorHeight ?? 0);
} catch {
floorTop = 0;
}
const zTop = resolveAnchor(project, ceiling.top) ?? floorTop;
const zBottom = zTop - ceilingThickness(project, ceiling);
return { zBottom, zTop };
}
/**
* Vertikale Ausdehnung einer Treppe (absolute Z-Werte in Metern).
* • zBottom: OKFF (baseElevation) des zugehörigen Geschosses.
* • zTop: zBottom + Gesamt-Steighöhe. Fehlt `stair.totalRise`, gilt die
* Geschosshöhe (floorHeight) des Geschosses — so steigt die Treppe genau ins
* nächste Geschoss (OKFF → OKFF).
* Fehlt das Geschoss, dient 0 als sicherer Rückfall für die Unterkante.
*/
export function stairVerticalExtent(
project: Project,
stair: Stair,
): { zBottom: number; zTop: number } {
let base = 0;
let floorHeight = 0;
try {
const floor = getFloor(project, stair.floorId);
base = floor.baseElevation ?? 0;
floorHeight = floor.floorHeight ?? 0;
} catch {
base = 0;
floorHeight = 0;
}
const rise =
stair.totalRise != null && stair.totalRise > 0
? stair.totalRise
: floorHeight > 0
? floorHeight
: 2.6;
return { zBottom: base, zTop: base + rise };
}
/**
* Das Geschoss DIREKT ÜBER dem Wand-Geschoss (in Dokumentreihenfolge der
* "floor"-Ebenen) oder `undefined`, wenn die Wand im obersten Geschoss liegt.
+449 -2
View File
@@ -17,10 +17,22 @@
import { useState } from "react";
import { t } from "../i18n";
import { formatM } from "../model/types";
import type { VerticalAnchor, WallReferenceLine } from "../model/types";
import type {
SiaCategory,
StairShape,
VerticalAnchor,
WallReferenceLine,
} from "../model/types";
import { SIA_CATEGORIES, siaLabelFull } from "../geometry/roomArea";
import { Dropdown } from "../ui/Dropdown";
import { usePanelHost } from "./host";
import type { WallInfo } from "../state/selectionInfo";
import type {
CeilingInfo,
OpeningInfo,
RoomInfo,
StairInfo,
WallInfo,
} from "../state/selectionInfo";
// ── Bezugspunkt-Raster ───────────────────────────────────────────────────────
// Neun Anker als 3×3-Raster. Zeile 0 = oben (fy=0), Spalte 0 = links (fx=0).
@@ -71,6 +83,10 @@ export function ObjectInfoPanel() {
}
const wall = sel.wall;
const ceiling = sel.ceiling;
const opening = sel.opening;
const stair = sel.stair;
const room = sel.room;
return (
<div className="objinfo-panel">
@@ -161,10 +177,441 @@ export function ObjectInfoPanel() {
{/* ── Wand-Abschnitt (nur bei genau einer Wand) ────────────────────── */}
{wall && <WallSection wall={wall} host={host} />}
{/* ── Decken-Abschnitt (nur bei genau einer Decke) ─────────────────── */}
{ceiling && <CeilingSection ceiling={ceiling} host={host} />}
{/* ── Öffnungs-Abschnitt (nur bei genau einer Öffnung) ─────────────── */}
{opening && <OpeningSection opening={opening} host={host} />}
{/* ── Treppen-Abschnitt (nur bei genau einer Treppe) ───────────────── */}
{stair && <StairSection stair={stair} host={host} />}
{/* ── Raum-Abschnitt (nur bei genau einem Raum) ────────────────────── */}
{room && <RoomSection room={room} roomId={sel.id} host={host} />}
</div>
);
}
// ── Raum-Attribut-Abschnitt ──────────────────────────────────────────────────
// Editierbarer Name, SIA-416-Kategorie (5 Blatt-Kategorien), abgeleitete Fläche
// + Umfang (read-only), Referenzgeschoss, sowie ein Button, der den Rich-Text-
// Stempel-Editor öffnet. Alle Werte/Setter über den Host.
function RoomSection({
room,
roomId,
host,
}: {
room: RoomInfo;
roomId: string;
host: ReturnType<typeof usePanelHost>;
}) {
return (
<>
<div className="objinfo-sep" />
<div className="objinfo-section-label">{t("objinfo.room.section")}</div>
{/* Name (editierbar). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.room.name")}</span>
<input
type="text"
className="objinfo-text"
value={room.name}
onChange={(e) => host.onSetRoomName(e.target.value)}
title={t("objinfo.room.name")}
/>
</div>
{/* SIA-416-Kategorie (5 Blatt-Kategorien). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.room.sia")}</span>
<Dropdown
value={room.siaCategory}
onChange={(v) => host.onSetRoomSia(v as SiaCategory)}
options={SIA_CATEGORIES.map((c) => ({
value: c,
label: siaLabelFull(c),
}))}
title={t("objinfo.room.sia")}
width={170}
/>
</div>
{/* Fläche + Umfang (read-only, abgeleitet). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.room.area")}</span>
<span className="objinfo-fval">{room.area.toFixed(2)} m²</span>
</div>
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.room.perimeter")}</span>
<span className="objinfo-fval">{formatM(room.perimeter)}</span>
</div>
{/* Referenzgeschoss (read-only). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.room.refFloor")}</span>
<span className="objinfo-fval">{room.floorName}</span>
</div>
{/* Stempel bearbeiten (öffnet den Rich-Text-Editor). */}
<button
type="button"
className="objinfo-btn"
onClick={() => host.onEditRoomStamp(roomId)}
>
{t("objinfo.room.editStamp")}
</button>
</>
);
}
// ── Treppen-Attribut-Abschnitt ───────────────────────────────────────────────
// Grundform (gerade/L/Wendel), Laufbreite, Stufenanzahl, Steighöhe (+ abgeleitete
// Steigungshöhe/Auftrittstiefe), Laufrichtung; Referenzgeschoss + UK/OK read-only.
function StairSection({
stair,
host,
}: {
stair: StairInfo;
host: ReturnType<typeof usePanelHost>;
}) {
return (
<>
<div className="objinfo-sep" />
<div className="objinfo-section-label">{t("objinfo.stair.section")}</div>
{/* Grundform-Dropdown. */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.stair.shape")}</span>
<Dropdown
value={stair.shape}
onChange={(v) => host.onSetStairShape(v as StairShape)}
options={[
{ value: "straight", label: t("objinfo.stair.shape.straight") },
{ value: "L", label: t("objinfo.stair.shape.L") },
{ value: "spiral", label: t("objinfo.stair.shape.spiral") },
]}
title={t("objinfo.stair.shape")}
width={130}
/>
</div>
{/* Laufrichtung (aufwärts/abwärts). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.stair.direction")}</span>
<div className="objinfo-segment">
<button
type="button"
className={"objinfo-seg-btn" + (stair.up ? " active" : "")}
onClick={() => host.onSetStairUp(true)}
>
{t("objinfo.stair.direction.up")}
</button>
<button
type="button"
className={"objinfo-seg-btn" + (!stair.up ? " active" : "")}
onClick={() => host.onSetStairUp(false)}
>
{t("objinfo.stair.direction.down")}
</button>
</div>
</div>
{/* Editierbare Maße. */}
<DimensionField
label={t("objinfo.stair.width")}
value={stair.width}
onCommit={(v) => host.onSetStairWidth(v)}
/>
<DimensionField
label={t("objinfo.stair.steps")}
value={stair.stepCount}
onCommit={(v) => host.onSetStairSteps(v)}
/>
<DimensionField
label={t("objinfo.stair.rise")}
value={stair.totalRise}
onCommit={(v) => host.onSetStairRise(v)}
/>
{/* Abgeleitete Werte (read-only). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.stair.riser")}</span>
<span className="objinfo-fval">{formatM(stair.riserHeight)}</span>
</div>
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.stair.tread")}</span>
<span className="objinfo-fval">{formatM(stair.treadDepth)}</span>
</div>
{/* Referenzgeschoss (read-only). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.stair.refFloor")}</span>
<span className="objinfo-fval">{stair.floorName}</span>
</div>
<div className="objinfo-sep" />
{/* UK/OK (read-only). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.stair.bottom")}</span>
<span className="objinfo-fval">{formatM(stair.zBottom)}</span>
</div>
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.stair.top")}</span>
<span className="objinfo-fval">{formatM(stair.zTop)}</span>
</div>
</>
);
}
// ── Öffnungs-Attribut-Abschnitt ──────────────────────────────────────────────
// Art (Fenster/Tür), Wirts-Wand, Position/Breite/Höhe/Brüstung, bei Türen
// zusätzlich Anschlag/Aufschlag/Richtung/Winkel; UK/OK read-only.
function OpeningSection({
opening,
host,
}: {
opening: OpeningInfo;
host: ReturnType<typeof usePanelHost>;
}) {
const isDoor = opening.kind === "door";
return (
<>
<div className="objinfo-sep" />
<div className="objinfo-section-label">{t("objinfo.opening.section")}</div>
{/* Art-Segment Fenster/Tür. */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.opening.kind")}</span>
<div className="objinfo-segment">
<button
type="button"
className={"objinfo-seg-btn" + (!isDoor ? " active" : "")}
onClick={() => host.onSetOpeningKind("window")}
>
{t("objinfo.opening.kind.window")}
</button>
<button
type="button"
className={"objinfo-seg-btn" + (isDoor ? " active" : "")}
onClick={() => host.onSetOpeningKind("door")}
>
{t("objinfo.opening.kind.door")}
</button>
</div>
</div>
{/* Wirts-Wand (read-only Anzeige des Geschosses/Wand-Bezugs). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.opening.hostWall")}</span>
<span className="objinfo-fval">{opening.hostWallName}</span>
</div>
{/* Editierbare Maße. */}
<DimensionField
label={t("objinfo.opening.position")}
value={opening.position}
onCommit={(v) => host.onSetOpeningPosition(v)}
/>
<DimensionField
label={t("objinfo.opening.width")}
value={opening.width}
onCommit={(v) => host.onSetOpeningWidth(v)}
/>
<DimensionField
label={t("objinfo.opening.height")}
value={opening.height}
onCommit={(v) => host.onSetOpeningHeight(v)}
/>
{!isDoor && (
<DimensionField
label={t("objinfo.opening.sill")}
value={opening.sillHeight}
onCommit={(v) => host.onSetOpeningSill(v)}
/>
)}
{/* Tür-spezifisch: Anschlag / Aufschlagseite / Richtung / Winkel. */}
{isDoor && (
<>
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.opening.hinge")}</span>
<Dropdown
value={opening.hinge ?? "start"}
onChange={(v) => host.onSetOpeningHinge(v as "start" | "end")}
options={[
{ value: "start", label: t("objinfo.opening.hinge.start") },
{ value: "end", label: t("objinfo.opening.hinge.end") },
]}
title={t("objinfo.opening.hinge")}
width={130}
/>
</div>
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.opening.swing")}</span>
<Dropdown
value={opening.swing ?? "left"}
onChange={(v) => host.onSetOpeningSwing(v as "left" | "right")}
options={[
{ value: "left", label: t("objinfo.opening.swing.left") },
{ value: "right", label: t("objinfo.opening.swing.right") },
]}
title={t("objinfo.opening.swing")}
width={130}
/>
</div>
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.opening.dir")}</span>
<Dropdown
value={opening.openingDir ?? "in"}
onChange={(v) => host.onSetOpeningDir(v as "in" | "out")}
options={[
{ value: "in", label: t("objinfo.opening.dir.in") },
{ value: "out", label: t("objinfo.opening.dir.out") },
]}
title={t("objinfo.opening.dir")}
width={130}
/>
</div>
<DimensionField
label={t("objinfo.opening.swingAngle")}
value={opening.swingAngle ?? 90}
min={1}
onCommit={(v) => host.onSetOpeningSwingAngle(v)}
/>
</>
)}
<div className="objinfo-sep" />
{/* UK/OK (read-only, aus der Wand-UK + Brüstung/Höhe abgeleitet). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.opening.bottom")}</span>
<span className="objinfo-fval">{formatM(opening.zBottom)}</span>
</div>
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.opening.top")}</span>
<span className="objinfo-fval">{formatM(opening.zTop)}</span>
</div>
</>
);
}
// ── Decken-Attribut-Abschnitt ────────────────────────────────────────────────
// Aufbau-Typ (einschichtig/mehrschichtig), Dicke/Preset, Referenzgeschoss +
// OK-Bindung, Fläche. Alle Werte/Setter über den Host.
function CeilingSection({
ceiling,
host,
}: {
ceiling: CeilingInfo;
host: ReturnType<typeof usePanelHost>;
}) {
const isSingle = ceiling.singleLayer;
const multiPresets = ceiling.wallTypes.filter((wt) => wt.layerCount > 1);
function chooseSingle() {
if (isSingle) return;
host.onSetCeilingThickness(ceiling.thickness);
}
function chooseMulti() {
if (!isSingle) return;
const first = multiPresets[0];
if (first) host.onSetCeilingType(first.id);
}
return (
<>
<div className="objinfo-sep" />
<div className="objinfo-section-label">{t("objinfo.ceiling.section")}</div>
{/* Aufbau-Typ-Segment. */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.ceiling.buildup")}</span>
<div className="objinfo-segment">
<button
type="button"
className={"objinfo-seg-btn" + (isSingle ? " active" : "")}
onClick={chooseSingle}
>
{t("objinfo.wall.single")}
</button>
<button
type="button"
className={"objinfo-seg-btn" + (!isSingle ? " active" : "")}
onClick={chooseMulti}
disabled={isSingle && multiPresets.length === 0}
>
{t("objinfo.wall.multi")}
</button>
</div>
</div>
{/* Einschichtig → Dicke-Zahlfeld. Mehrschichtig → Preset-Dropdown. */}
{isSingle ? (
<DimensionField
label={t("objinfo.ceiling.thickness")}
value={ceiling.thickness}
onCommit={(v) => host.onSetCeilingThickness(v)}
/>
) : (
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.ceiling.preset")}</span>
<Dropdown
value={ceiling.wallTypeId}
onChange={(id) => host.onSetCeilingType(id)}
options={ceiling.wallTypes.map((wt) => ({
value: wt.id,
label: t("objinfo.wall.presetLabel", {
name: wt.name,
thickness: wt.thickness.toFixed(2),
}),
}))}
title={t("objinfo.ceiling.preset")}
width={150}
/>
</div>
)}
{/* Referenzgeschoss (read-only Anzeige). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.ceiling.refFloor")}</span>
<span className="objinfo-fval">{ceiling.floorName}</span>
</div>
{/* Fläche (read-only). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.ceiling.area")}</span>
<span className="objinfo-fval">{ceiling.area.toFixed(2)} m²</span>
</div>
<div className="objinfo-sep" />
{/* OK der Decke: an Geschoss gebunden ODER eigene Höhe. */}
<VerticalAnchorRow
label={t("objinfo.ceiling.top")}
anchor={ceiling.top}
defaultZ={ceiling.zTop}
floors={ceiling.floors}
defaultFloorId={ceiling.floorId}
onChange={(a) => host.onSetCeilingTop(a)}
/>
{/* UK = OK Dicke (abgeleitet, read-only). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.ceiling.bottom")}</span>
<span className="objinfo-fval">{formatM(ceiling.zBottom)}</span>
</div>
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.ceiling.height")}</span>
<span className="objinfo-fval">{formatM(ceiling.zTop - ceiling.zBottom)}</span>
</div>
</>
);
}
// ── Wand-Attribut-Abschnitt ──────────────────────────────────────────────────
// Aufbau-Typ (einschichtig/mehrschichtig), Dicke/Preset, Referenzgeschoss +
// Oberverknüpfung, UK/OK je Modus-Umschalter. Alle Werte/Setter über den Host.
+123
View File
@@ -0,0 +1,123 @@
// Inhalts-Panel „Flächenbilanz" (SIA 416) — listet alle Räume gruppiert nach
// ihrer SIA-416-Blatt-Kategorie mit Zwischen-/Gesamtsummen und bietet einen
// CSV-Export. Fläche/Bilanz werden LIVE aus den Raum-Umrissen abgeleitet
// (geometry/roomArea: evaluateRoom/balance/roomsToCsv) — nie gespeichert.
//
// Daten kommen über usePanelHost (project.rooms). Bezeichner englisch, UI-Text
// deutsch via t() (CONVENTIONS.md).
import { t } from "../i18n";
import { usePanelHost } from "./host";
import {
balance,
evaluateRoom,
roomsToCsv,
siaLabelFull,
} from "../geometry/roomArea";
import type { Room } from "../model/types";
import { roomDisplayName } from "../model/roomStamp";
/** Löst je Raum die anrechenbare (Netto-)Fläche + Kategorie auf. */
function roomAreas(rooms: Room[]): { category: Room["siaCategory"]; area: number }[] {
return rooms.map((r) => ({
category: r.siaCategory,
area: evaluateRoom(r.boundary, r.siaCategory).netArea,
}));
}
/** Löst den Anzeigenamen eines Geschosses (oder die ID) auf. */
function floorNameOf(
levels: { id: string; name: string }[],
floorId: string,
): string {
return levels.find((z) => z.id === floorId)?.name ?? floorId;
}
/** Stösst den Download einer CSV-Datei an (reiner Client, Blob-URL). */
function downloadCsv(fileName: string, content: string): void {
// BOM voranstellen, damit Excel UTF-8 (Umlaute/„m²") korrekt liest.
const blob = new Blob(["" + content], {
type: "text/csv;charset=utf-8;",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
export function RoomBalancePanel() {
const host = usePanelHost();
const rooms = host.project.rooms ?? [];
const levels = host.project.drawingLevels;
if (rooms.length === 0) {
return (
<div className="balance-panel">
<div className="balance-empty">{t("balance.empty")}</div>
</div>
);
}
const bal = balance(roomAreas(rooms));
const onExport = () => {
const csv = roomsToCsv(
rooms.map((r) => ({
number: r.stamp?.number ?? r.id,
name: roomDisplayName(r),
level: floorNameOf(levels, r.floorId),
category: r.siaCategory,
area: evaluateRoom(r.boundary, r.siaCategory).netArea,
})),
);
downloadCsv("flaechenbilanz.csv", csv);
};
return (
<div className="balance-panel">
<div className="balance-head">
<span className="balance-title">{t("balance.title")}</span>
<button type="button" className="balance-export" onClick={onExport}>
{t("balance.export")}
</button>
</div>
{/* Raumliste (je Raum: Name · Geschoss · SIA · Fläche). */}
<div className="balance-section-label">{t("balance.rooms")}</div>
<div className="balance-table">
{rooms.map((r) => {
const res = evaluateRoom(r.boundary, r.siaCategory);
return (
<div key={r.id} className="balance-row">
<span className="balance-name">{roomDisplayName(r)}</span>
<span className="balance-cat">{r.siaCategory}</span>
<span className="balance-area">{res.netArea.toFixed(2)} m²</span>
</div>
);
})}
</div>
{/* SIA-416-Bilanz (Blatt-Kategorien + Zwischensummen NF/NGF + Gesamt GF). */}
<div className="balance-section-label">{t("balance.summary")}</div>
<div className="balance-table">
{bal.rows.map((row) => (
<div
key={row.key}
className={"balance-row" + (row.aggregate ? " aggregate" : "")}
title={siaLabelFull(row.key)}
>
<span className="balance-name">
{row.key} · {row.label}
</span>
<span className="balance-count">{row.count}</span>
<span className="balance-area">{row.area.toFixed(2)} m²</span>
</div>
))}
</div>
</div>
);
}
+136
View File
@@ -0,0 +1,136 @@
// Schwebender Dialog zum Bearbeiten des strukturierten Raum-Stempels (Feldmodell):
// Raumnummer, Raumname (+ Zeile 2), Bodenfläche (mit Präfix) und Nutzung. Ersetzt
// für neue Räume den freien Text-Editor. Kontrolliert (value/onApply/onCancel);
// arbeitet auf einem lokalen Entwurf, erst „Übernehmen" schreibt zurück.
// Bezeichner englisch, UI-Text über t().
//
// Folge-Arbeit: Zeilen-Layout-Editor (welches Feld auf welche Zeile),
// Per-Feld-Typografie, Fensterfläche.
import { useState } from "react";
import { t } from "../i18n";
import type { RoomStamp } from "../model/types";
export interface RoomStampEditorProps {
/** Ausgangsstempel (wird beim Öffnen kopiert; Editor arbeitet lokal). */
value: RoomStamp;
/** „Übernehmen" — den bearbeiteten Stempel zurückschreiben. */
onApply: (stamp: RoomStamp) => void;
/** „Abbrechen"/Schliessen — ohne Übernahme. */
onCancel: () => void;
}
export function RoomStampEditor({ value, onApply, onCancel }: RoomStampEditorProps) {
const [draft, setDraft] = useState<RoomStamp>(value);
const patch = (p: Partial<RoomStamp>) => setDraft((d) => ({ ...d, ...p }));
return (
<div
className="texteditor-overlay"
role="dialog"
aria-modal="true"
aria-label={t("roomStamp.title")}
onMouseDown={(e) => {
if (e.target === e.currentTarget) onCancel();
}}
onKeyDown={(e) => {
if (e.key === "Escape") onCancel();
}}
>
<div className="texteditor-dialog roomstamp-dialog">
<div className="texteditor-head">
<span className="texteditor-title">{t("roomStamp.title")}</span>
<button
type="button"
className="texteditor-x"
onClick={onCancel}
title={t("text.cancel")}
aria-label={t("text.cancel")}
>
<span className="material-symbols-outlined" aria-hidden="true">
close
</span>
</button>
</div>
<div className="texteditor-body roomstamp-body">
<label className="roomstamp-field">
<span className="roomstamp-label">{t("roomStamp.number")}</span>
<input
type="text"
className="roomstamp-input"
value={draft.number ?? ""}
onChange={(e) => patch({ number: e.target.value })}
autoFocus
/>
</label>
<label className="roomstamp-field">
<span className="roomstamp-label">{t("roomStamp.name")}</span>
<input
type="text"
className="roomstamp-input"
value={draft.name}
onChange={(e) => patch({ name: e.target.value })}
/>
</label>
<label className="roomstamp-field">
<span className="roomstamp-label">{t("roomStamp.nameLine2")}</span>
<input
type="text"
className="roomstamp-input"
value={draft.nameLine2 ?? ""}
onChange={(e) => patch({ nameLine2: e.target.value })}
/>
</label>
<label className="roomstamp-check">
<input
type="checkbox"
checked={draft.showFloorArea}
onChange={(e) => patch({ showFloorArea: e.target.checked })}
/>
<span>{t("roomStamp.showFloorArea")}</span>
</label>
<label className="roomstamp-field">
<span className="roomstamp-label">{t("roomStamp.floorAreaPrefix")}</span>
<input
type="text"
className="roomstamp-input"
value={draft.floorAreaPrefix ?? ""}
disabled={!draft.showFloorArea}
onChange={(e) => patch({ floorAreaPrefix: e.target.value })}
/>
</label>
<label className="roomstamp-check">
<input
type="checkbox"
checked={draft.showUsage}
onChange={(e) => patch({ showUsage: e.target.checked })}
/>
<span>{t("roomStamp.showUsage")}</span>
</label>
</div>
<div className="texteditor-foot">
<button type="button" className="texteditor-btn" onClick={onCancel}>
{t("text.cancel")}
</button>
<button
type="button"
className="texteditor-btn texteditor-btn-primary"
onClick={() => onApply(draft)}
>
{t("text.apply")}
</button>
</div>
</div>
</div>
);
}
export default RoomStampEditor;
+18
View File
@@ -16,6 +16,7 @@ import { t } from "../i18n";
import type { TranslationKey } from "../i18n";
import { usePanelHost } from "./host";
import type { ContextObject } from "../model/types";
import { ContextImportDialog } from "../ui/ContextImportDialog";
/** Typ-spezifisches Badge-Label (i18n-Key) je Kontext-Objekt-Art. */
function typeBadgeKey(obj: ContextObject): TranslationKey {
@@ -34,6 +35,9 @@ export function SitePanel() {
const objects = host.contextObjects;
const contourSets = objects.filter((o) => o.type === "contourSet");
// Standort-Import-Dialog (geo.admin/OSM) offen?
const [ctxImportOpen, setCtxImportOpen] = useState(false);
// Gewählter Kontur-Satz für „Gelände erzeugen" (default: erster vorhandener).
const [selectedSet, setSelectedSet] = useState<string>("");
const effectiveSet =
@@ -62,8 +66,22 @@ export function SitePanel() {
>
{t("site.import")}
</button>
<button
type="button"
className="site-btn"
onClick={() => setCtxImportOpen(true)}
>
{t("site.importLocation")}
</button>
</div>
{ctxImportOpen && (
<ContextImportDialog
onImport={(objs) => host.onAddContextObjects(objs)}
onClose={() => setCtxImportOpen(false)}
/>
)}
{contourSets.length > 0 && (
<div className="site-terrain-row">
{contourSets.length > 1 ? (
+65
View File
@@ -45,6 +45,50 @@ function ToolIcon({ id }: { id: ToolId }) {
<line x1="2" y1="10.5" x2="14" y2="10.5" />
</svg>
);
case "ceiling":
// Deckenplatte: gefülltes Rechteck mit Diagonal-Schraffur (Slab-Symbol).
return (
<svg {...common}>
<rect x="2.5" y="4" width="11" height="8" />
<line x1="4" y1="12" x2="8" y2="4" />
<line x1="8" y1="12" x2="12" y2="4" />
</svg>
);
case "window":
// Fenster: Rahmen-Rechteck mit Mittelsprosse (Verglasung).
return (
<svg {...common}>
<rect x="2.5" y="4" width="11" height="8" />
<line x1="8" y1="4" x2="8" y2="12" />
</svg>
);
case "door":
// Tür: Pfosten + Türblatt mit Schwenkbogen.
return (
<svg {...common}>
<line x1="3" y1="13" x2="3" y2="4" />
<line x1="3" y1="4" x2="11" y2="4" />
<path d="M3 13 A9 9 0 0 0 11 4" />
</svg>
);
case "stair":
// Treppensymbol: gestufte Linie (Tritte) + Lauflinie mit Pfeil.
return (
<svg {...common}>
<polyline points="2,14 2,11 6,11 6,8 10,8 10,5 14,5 14,2" />
<line x1="4" y1="12.5" x2="12" y2="3.5" />
<polyline points="10.5,3 12,3.5 11.5,5" />
</svg>
);
case "room":
// Raum: umschlossene Fläche (Rechteck) mit Flächen-Marke (kleines Kreuz).
return (
<svg {...common}>
<rect x="2.5" y="3.5" width="11" height="9" />
<line x1="6" y1="8" x2="10" y2="8" />
<line x1="8" y1="6" x2="8" y2="10" />
</svg>
);
case "line":
// Einzelne Diagonale.
return (
@@ -126,6 +170,27 @@ export function ToolsPanel() {
{host.activeTool === "wall" && <div className="tools-sep" />}
{/* Deckentyp-Auswahl — nur sichtbar, wenn das Decken-Werkzeug aktiv ist.
Nutzt denselben Aufbau-Typ (WallType) wie die Wand. */}
{host.activeTool === "ceiling" && (
<label className="tools-field">
<span>{t("tool.ceilingType")}</span>
<select
value={host.activeWallTypeId}
disabled={!host.toolsEnabled}
onChange={(e) => host.onActiveWallTypeId(e.target.value)}
>
{host.project.wallTypes.map((wt) => (
<option key={wt.id} value={wt.id}>
{wt.name}
</option>
))}
</select>
</label>
)}
{host.activeTool === "ceiling" && <div className="tools-sep" />}
{/* Fang-Abschnitt. Master-Checkbox steuert die übrigen Fang-Optionen. */}
<label className="tools-snap-row">
<input
+10
View File
@@ -21,6 +21,7 @@ import { ToolsPanel } from "./ToolsPanel";
import { AttributesPanel } from "./AttributesPanel";
import { ObjectInfoPanel } from "./ObjectInfoPanel";
import { SitePanel } from "./SitePanel";
import { RoomBalancePanel } from "./RoomBalancePanel";
/** IDs der eingebauten Dock-Panels (auch für defaultLayout/Filter nutzbar). */
export const BUILTIN_PANEL_IDS = {
@@ -30,6 +31,7 @@ export const BUILTIN_PANEL_IDS = {
attributes: "attributes",
objectInfo: "object-info",
site: "site",
roomBalance: "room-balance",
} as const;
// Werkzeug-Palette (Symbol + Name je Werkzeug, ArchiCAD/Vectorworks-Stil).
@@ -79,3 +81,11 @@ registerPanel({
hasDisplayMode: false,
render: () => <SitePanel />,
});
// Flächenbilanz (SIA 416) — Raumliste + Kategorie-Summen + CSV-Export.
registerPanel({
id: BUILTIN_PANEL_IDS.roomBalance,
title: "nav.roomBalance",
hasDisplayMode: false,
render: () => <RoomBalancePanel />,
});
+50
View File
@@ -22,6 +22,8 @@ import type {
LayerCategory,
LineStyle,
Project,
SiaCategory,
StairShape,
VerticalAnchor,
WallReferenceLine,
} from "../model/types";
@@ -156,6 +158,54 @@ export interface PanelHostValue {
/** Setzt/entfernt die OK-Bindung (`null` = UK + height). */
onSetWallTop: (anchor: VerticalAnchor | null) => void;
// ── Decken-Attribute (Object-Info-Panel; wirken NUR auf die selektierte Decke) ─
/** Weist der Decke einen Aufbau-Typ-Preset zu (wie WallType). */
onSetCeilingType: (wallTypeId: string) => void;
/** Setzt die Gesamtdicke der Decke (Meter, thickness-Übersteuerung). */
onSetCeilingThickness: (thickness: number) => void;
/** Setzt/entfernt die OK-Bindung der Decke (`null` = Geschoss-Oberkante). */
onSetCeilingTop: (anchor: VerticalAnchor | null) => void;
// ── Öffnungs-Attribute (Object-Info-Panel; nur die selektierte Öffnung) ─
/** Wechselt die Art (Fenster/Tür); setzt bei Tür die Tür-Defaults. */
onSetOpeningKind: (kind: "window" | "door") => void;
/** Setzt die Öffnungsbreite (Meter). */
onSetOpeningWidth: (width: number) => void;
/** Setzt die Öffnungshöhe (Meter). */
onSetOpeningHeight: (height: number) => void;
/** Setzt die Brüstungshöhe (Meter; nur Fenster sinnvoll). */
onSetOpeningSill: (sillHeight: number) => void;
/** Setzt die Position entlang der Wandachse (Meter ab Wandanfang). */
onSetOpeningPosition: (position: number) => void;
/** Setzt den Türöffnungswinkel (Grad). */
onSetOpeningSwingAngle: (swingAngle: number) => void;
/** Setzt den Anschlag-Pfosten der Tür. */
onSetOpeningHinge: (hinge: "start" | "end") => void;
/** Setzt die Aufschlagseite der Tür. */
onSetOpeningSwing: (swing: "left" | "right") => void;
/** Setzt die Aufschlagrichtung der Tür (innen/außen). */
onSetOpeningDir: (dir: "in" | "out") => void;
// ── Treppen-Attribute (Object-Info-Panel; nur die selektierte Treppe) ───
/** Setzt die Grundform (gerade/L/Wendel). */
onSetStairShape: (shape: StairShape) => void;
/** Setzt die Laufbreite (Meter). */
onSetStairWidth: (width: number) => void;
/** Setzt die Stufenanzahl (Setzstufen, ≥ 2). */
onSetStairSteps: (stepCount: number) => void;
/** Setzt die Gesamt-Steighöhe (Meter, OKFF → OKFF). */
onSetStairRise: (totalRise: number) => void;
/** Setzt die Laufrichtung (aufwärts/abwärts). */
onSetStairUp: (up: boolean) => void;
// ── Raum-Attribute (Object-Info-Panel; nur der selektierte Raum) ────────
/** Setzt den Raum-Namen. */
onSetRoomName: (name: string) => void;
/** Setzt die SIA-416-Blatt-Kategorie des Raums. */
onSetRoomSia: (category: SiaCategory) => void;
/** Öffnet den Rich-Text-Editor des Raum-Stempels (per ID). */
onEditRoomStamp: (roomId: string) => void;
// ── Site-/Kontext-Schicht (SitePanel) ───────────────────────────────────
/** Aktuelle Kontext-Objekte (Meshes/Konturen/Gelände) aus `project.context`. */
contextObjects: ContextObject[];
+1 -1
View File
@@ -60,7 +60,7 @@ const DEFAULT_LEFT_GROUPS: DockGroup[] = [
{ tabs: ["attributes"], activeTab: "attributes", weight: 1.1 },
];
const DEFAULT_RIGHT_GROUPS: DockGroup[] = [
{ tabs: ["object-info"], activeTab: "object-info", weight: 1 },
{ tabs: ["object-info", "room-balance"], activeTab: "object-info", weight: 1 },
{
tabs: ["drawing-levels", "layers", "site"],
activeTab: "drawing-levels",
+1108 -146
View File
File diff suppressed because it is too large Load Diff
+498 -11
View File
@@ -6,22 +6,36 @@
// Öffnungen) — exakt, schnell, sauber.
import type {
Ceiling,
Drawing2D,
HatchPattern,
LayerCategory,
Opening,
Project,
Room,
Stair,
Vec2,
Wall,
} from "../model/types";
import {
flattenCategories,
getCeilingType,
getComponent,
getHatch,
getLineStyle,
getWallType,
openingsOfWall,
roomsOfFloor,
stairsOfFloor,
wallTypeThickness,
} from "../model/types";
import { wallReferenceOffset } from "../model/wall";
import { evaluateRoom, siaLabel } from "../geometry/roomArea";
import type { RichTextDoc } from "../text/richText";
import { docFromText } from "../text/richText";
import { roomStampToDoc, roomStampExtraLines } from "../model/roomStamp";
import { stairVerticalExtent, wallReferenceOffset } from "../model/wall";
import { stairGeometry, stairCut } from "../geometry/stair";
import { doorSymbol, openingInterval, windowSymbol } from "../geometry/opening";
import {
add,
along,
@@ -140,6 +154,17 @@ export type Primitive =
* geschlossene 2D-Form ist (für die Links-Klick-Auswahl der Fläche).
*/
drawingId?: string;
/**
* ID der Decke (Ceiling), falls dieses Polygon eine Decken-Fläche/-Umriss
* ist (für die Links-Klick-Auswahl der Decke).
*/
ceilingId?: string;
/** ID der Öffnung (Fenster/Tür), z. B. der Fensterrahmen (Auswahl). */
openingId?: string;
/** ID der Treppe (Stair), falls dieses Polygon ein Tritt/Podest ist (Auswahl). */
stairId?: string;
/** ID des Raums (Room), falls dieses Polygon eine Raum-Fläche ist (Auswahl). */
roomId?: string;
}
| {
kind: "line";
@@ -154,6 +179,10 @@ export type Primitive =
color?: string;
/** ID des 2D-Zeichenelements (für Links-Klick-Auswahl von Drawing2D). */
drawingId?: string;
/** ID der Öffnung (für Links-Klick-Auswahl von Fenster/Tür-Symbolen). */
openingId?: string;
/** ID der Treppe (für Links-Klick-Auswahl von Treppen-Symbollinien). */
stairId?: string;
greyed?: boolean;
}
| {
@@ -167,6 +196,30 @@ export type Primitive =
weightMm: number;
/** Strichmuster in mm Papier; null/undefined = durchgezogen. */
dash?: number[] | null;
/** ID der Öffnung (für Links-Klick-Auswahl von Tür-Schwenkbögen). */
openingId?: string;
greyed?: boolean;
}
| {
kind: "text";
/** Ankerpunkt im Modell (Meter). */
at: Vec2;
/**
* Frei editierbarer Stempel-Text (Rich-Text). Wird zuerst gezeichnet;
* `align` je Absatz steuert die Ausrichtung.
*/
doc: RichTextDoc;
/**
* LIVE-Zusatzzeilen (z. B. Fläche „24.30 m²" + SIA-Tag), UNTER dem Doc
* gezeichnet — unabhängig vom editierten Text, stets aktuell.
*/
extraLines: string[];
/** Basis-Schriftgrösse in PUNKT (für Runs ohne eigene Grösse + Zusatzzeilen). */
basePt: number;
/** Textfarbe (hex) als Default. */
color: string;
/** ID des Raums (Room), zu dem dieser Stempel gehört (Auswahl). */
roomId?: string;
greyed?: boolean;
};
@@ -211,6 +264,8 @@ export function generatePlan(
mono = false,
): Plan {
const primitives: Primitive[] = [];
// Aktives Geschoss (für die Grundriss-Schnitthöhe der Treppen).
const floor = project.drawingLevels.find((z) => z.id === floorId);
// Sichtbarkeit wie bisher; der Darstellungsmodus verfeinert sie zusätzlich:
// render=false blendet die Wand aus, greyed=true dimmt sie.
const walls = project.walls.filter(
@@ -235,20 +290,52 @@ export function generatePlan(
categoryDisplay(d.categoryCode).render,
);
// Decken dieses Geschosses (wie Wände nach sichtbaren Kategorien +
// Darstellungsmodus gefiltert). Eine Decke liegt (als Slab) über der
// Schnittebene → im Grundriss als gefüllter/schraffierter Umriss dargestellt,
// UNTER der Wand-Poché (damit die Wände darüber lesbar bleiben).
const ceilings = (project.ceilings ?? []).filter(
(c) =>
c.floorId === floorId &&
visibleCodes.has(c.categoryCode) &&
categoryDisplay(c.categoryCode).render,
);
for (const ceiling of ceilings) {
const greyed = categoryDisplay(ceiling.categoryCode).greyed;
const lwMm = lwByCode.get(ceiling.categoryCode) ?? WALL_FALLBACK_MM;
addCeilingPoche(primitives, project, ceiling, greyed, detail, lwMm);
}
// Räume (SIA-416-Flächen) dieses Geschosses: transluzente Farbfüllung + Stempel
// (Name + Fläche + SIA-Tag). UNTER der Wand-Poché gezeichnet, damit die Wände
// lesbar bleiben. Der Stempel (Text) entfällt beim Detailgrad „grob".
const rooms = roomsOfFloor(project, floorId).filter(
(r) =>
visibleCodes.has(r.categoryCode) && categoryDisplay(r.categoryCode).render,
);
for (const room of rooms) {
const greyed = categoryDisplay(room.categoryCode).greyed;
addRoomArea(primitives, room, greyed, detail);
}
// Gehrungs-Schnittlinien einmal pro Plan berechnen (auf der gefilterten Menge).
const joins = computeJoins(project, walls);
for (const wall of walls) {
const wallGreyed = categoryDisplay(wall.categoryCode).greyed;
const wallLwMm = lwByCode.get(wall.categoryCode) ?? WALL_FALLBACK_MM;
// Türen am Wandhost: die Öffnung bestimmt immer die Wandgeometrie.
// Türen (Legacy) + Öffnungen (Fenster/Türen) am Wandhost: BEIDE sparen die
// Wand-Poché aus. Aus beiden wird eine gemeinsame Lücken-Intervall-Liste.
const doors = project.doors.filter((d) => d.hostWallId === wall.id);
const openings = openingsOfWall(project, wall.id);
const gaps = wallGaps(wall, doors, openings);
const cuts = joins.get(wall.id) ?? { startCut: null, endCut: null };
addWallPoche(primitives, project, wall, doors, cuts, wallGreyed, detail, wallLwMm);
addWallPoche(primitives, project, wall, gaps, cuts, wallGreyed, detail, wallLwMm);
// Referenzlinie: dünne gestrichelte Wand-Achslinie (von der Oberleiste
// umschaltbar). Wird über der Poché gezeichnet, damit sie sichtbar bleibt.
if (showReferenceLines) addReferenceLine(primitives, wall, wallGreyed);
// Tür-Symbol nur, wenn die Tür-Kategorie sichtbar ist und der Modus sie zeigt.
// Tür-Symbol (Legacy-Door) nur, wenn die Kategorie sichtbar ist und der Modus
// sie zeigt.
for (const door of doors) {
const d = categoryDisplay(door.categoryCode);
if (visibleCodes.has(door.categoryCode) && d.render) {
@@ -256,6 +343,28 @@ export function generatePlan(
addDoorSymbol(primitives, wall, door, d.greyed, detail, doorLwMm);
}
}
// Öffnungs-Symbole (Fenster/Tür) je Detailgrad + Auswahl-taggbar.
for (const o of openings) {
const d = categoryDisplay(o.categoryCode);
if (visibleCodes.has(o.categoryCode) && d.render) {
const oLwMm = lwByCode.get(o.categoryCode) ?? LAYER_LINE_MM;
addOpeningSymbol(primitives, project, wall, o, d.greyed, detail, oLwMm);
}
}
}
// Treppen dieses Geschosses: Tritte + Lauflinie + Auf-/Abpfeil, mit dem
// Grundriss-Schnitt (Tritte unter der Schnitthöhe durchgezogen, darüber
// gestrichelt/verdeckt + SIA-Bruchlinie). Über der Wand-Poché gezeichnet.
const cutHeight = floor?.cutHeight ?? 1.0;
const stairs = stairsOfFloor(project, floorId).filter(
(s) =>
visibleCodes.has(s.categoryCode) && categoryDisplay(s.categoryCode).render,
);
for (const stair of stairs) {
const greyed = categoryDisplay(stair.categoryCode).greyed;
const lwMm = lwByCode.get(stair.categoryCode) ?? WALL_FALLBACK_MM;
addStairSymbol(primitives, project, stair, greyed, lwMm, cutHeight);
}
// 2D-Zeichengeometrie zuletzt (über der Poché).
@@ -274,7 +383,10 @@ export function generatePlan(
// Musterlinien/Striche schwarz — so entsteht ein reiner S/W-Plan.
if (mono) toMono(primitives);
return { primitives, bounds: computeBounds(project, walls, drawings) };
return {
primitives,
bounds: computeBounds(project, walls, drawings, ceilings, stairs, rooms),
};
}
/** Dezente Plan-Farbe der Kontext-Konturen (gedämpftes Grau). */
@@ -332,6 +444,8 @@ function toMono(primitives: Primitive[]): void {
p.stroke = MONO_INK;
} else if (p.kind === "line") {
p.color = MONO_INK;
} else if (p.kind === "text") {
p.color = MONO_INK;
}
}
}
@@ -452,11 +566,40 @@ function addDrawing2D(
* joinPriority) mit dicker Umrisslinie. Bewusst vereinfacht (≙ DOSSIER
* „Einfach").
*/
/**
* Ein Öffnungs-Intervall entlang der Wandachse (from..to in Metern). `headHeight`
* = Sturzhöhe (OK der Öffnung über der Wand-UK) für die 3D-Aussparung; im Plan
* ungenutzt, aber Teil des gemeinsamen Vertrags mit dem 3D-Pfad.
*/
export interface WallGap {
from: number;
to: number;
}
/**
* Vereint Legacy-Türen (`Door`) und Öffnungen (`Opening`) einer Wand zu einer
* sortierten Lücken-Intervall-Liste entlang der Wandachse. Entartete Intervalle
* (Breite ≤ 0 oder außerhalb der Achse) werden verworfen.
*/
export function wallGaps(
wall: Wall,
doors: Project["doors"],
openings: Opening[],
): WallGap[] {
const gaps: WallGap[] = [];
for (const d of doors) gaps.push({ from: d.position, to: d.position + d.width });
for (const o of openings) {
const iv = openingInterval(wall, o);
if (iv) gaps.push(iv);
}
return gaps.sort((a, b) => a.from - b.from);
}
function addWallPoche(
out: Primitive[],
project: Project,
wall: Wall,
doors: Project["doors"],
gaps: WallGap[],
cuts: WallCuts,
greyed: boolean,
detail: DetailLevel,
@@ -477,10 +620,8 @@ function addWallPoche(
// Schichtfugen), nicht die Schicht-Füllfarben/Schraffuren.
const stroke = wall.color ?? POCHE_STROKE;
// Öffnungs-Intervalle entlang der Wandachse, sortiert.
const openings = doors
.map((d) => ({ from: d.position, to: d.position + d.width }))
.sort((a, b) => a.from - b.from);
// Öffnungs-Intervalle entlang der Wandachse, sortiert (bereits vereint).
const openings = gaps;
// Wand in Segmente zwischen den Öffnungen zerlegen.
let cursor = 0;
@@ -634,6 +775,105 @@ function addDoorSymbol(
}
}
/**
* Öffnungs-Symbol (Fenster/Tür) je Detailgrad. Anders als das Legacy-`addDoorSymbol`
* arbeitet diese Funktion über die reinen Geometrie-Helfer (opening.ts) und taggt
* ihre Primitive mit `openingId`, sodass die Öffnung im Plan selektierbar ist.
*
* Tür:
* • grob — gerade Türblatt-Linie (die Lücke kommt aus der ausgesparten Poché).
* • mittel — + Schwenkbogen (Öffnungswinkel).
* • fein — + Anschlag-Striche an beiden Pfosten.
* Fenster:
* • grob — eine einzelne Glaslinie über die Lücke.
* • mittel — Rahmen-Rechteck (Umriss) + eine Glaslinie.
* • fein — Rahmen + zwei Glaslinien (Doppelverglasung angedeutet).
*/
function addOpeningSymbol(
out: Primitive[],
project: Project,
wall: Wall,
o: Opening,
greyed: boolean,
detail: DetailLevel,
lwMm: number,
): void {
if (o.kind === "door") {
const sym = doorSymbol(wall, o);
if (!sym) return;
// Türblatt (alle Stufen).
out.push({
kind: "line",
a: sym.hinge,
b: sym.openEnd,
cls: "door-leaf",
weightMm: lwMm,
greyed,
openingId: o.id,
});
// mittel + fein: Schwenkbogen von geschlossen → offen.
if (detail !== "grob") {
out.push({
kind: "arc",
center: sym.hinge,
from: sym.closedEnd,
to: sym.openEnd,
r: sym.radius,
cls: "door-swing",
weightMm: lwMm * 0.6,
dash: [0.06, 0.04],
greyed,
openingId: o.id,
});
}
// fein: Anschlag-Striche an beiden Pfosten (quer zur Wand).
if (detail === "fein") {
const reveal = 0.06;
for (const jamb of [sym.jambStart, sym.jambEnd]) {
out.push({
kind: "line",
a: add(jamb, scale(sym.normal, reveal)),
b: add(jamb, scale(sym.normal, -reveal)),
cls: "door-frame",
weightMm: lwMm,
greyed,
openingId: o.id,
});
}
}
return;
}
// Fenster: Rahmen (mittel/fein) + Glaslinie(n) je Detailgrad.
const glassCount = detail === "fein" ? 2 : 1;
const sym = windowSymbol(project, wall, o, glassCount);
if (!sym) return;
if (detail !== "grob") {
// Rahmen-Rechteck als reine Umrisslinie (Poché-Stroke, keine Füllung).
out.push({
kind: "polygon",
pts: sym.frame,
fill: "none",
stroke: o.color ?? POCHE_STROKE,
strokeWidthMm: lwMm,
hatch: NO_HATCH,
greyed,
openingId: o.id,
});
}
for (const [a, b] of sym.glassLines) {
out.push({
kind: "line",
a,
b,
cls: "window-glass",
weightMm: lwMm * 0.7,
greyed,
openingId: o.id,
});
}
}
/** Schraffur-Platzhalter „ohne" (für reine Umriss-/Sammelflächen). */
const NO_HATCH: HatchRender = {
pattern: "none",
@@ -660,9 +900,248 @@ function addReferenceLine(out: Primitive[], wall: Wall, greyed: boolean): void {
});
}
/**
* Decken-Poché im Grundriss: die Decke liegt als Slab ÜBER der Schnittebene und
* wird daher als geschlossener Umriss ihres Grundriss-Polygons gezeichnet — eine
* gefüllte + schraffierte Fläche plus eine kräftige Umrisslinie. Fill/Schraffur
* stammen aus dem BAUTEIL der ersten (obersten) Schicht des Aufbau-Typs (analog
* zur Wand-Schichtauflösung); die Umrissfarbe aus der optionalen Decken-Farbe
* bzw. dem Poché-Strich. Jedes Polygon trägt die `ceilingId` für die Auswahl.
*
* Im Detailgrad „grob" entfällt die Schraffur (nur Fläche + Umriss); „mittel"/
* „fein" zeigen die Component-Schraffur. `lwMm` ist die Kategorie-Strichstärke.
*/
function addCeilingPoche(
out: Primitive[],
project: Project,
ceiling: Ceiling,
greyed: boolean,
detail: DetailLevel,
lwMm: number,
): void {
const pts = ceiling.outline;
if (pts.length < 3) return;
const wt = getCeilingType(project, ceiling);
const comp = wt.layers.length > 0 ? getComponent(project, wt.layers[0].componentId) : null;
const stroke = ceiling.color ?? POCHE_STROKE;
const outlineMm = lwMm * OUTLINE_DETAIL_FACTOR[detail];
const ceilingId = ceiling.id;
// Gefüllte + schraffierte Fläche (Component-Poché). Im Detailgrad „grob" ohne
// Schraffur (reine Füllung), sonst die Bauteil-Schraffur.
out.push({
kind: "polygon",
pts,
fill: comp ? comp.color : "none",
stroke,
strokeWidthMm: LAYER_LINE_MM * LAYER_DETAIL_FACTOR[detail],
hatch: comp && detail !== "grob" ? resolveHatch(project, comp.hatchId) : NO_HATCH,
greyed,
ceilingId,
});
// Kräftige Umrisslinie über die volle Decke (nur Kontur, keine Füllung).
out.push({
kind: "polygon",
pts,
fill: "none",
stroke,
strokeWidthMm: outlineMm,
hatch: NO_HATCH,
greyed,
ceilingId,
});
}
/** Deckkraft (0..255) der transluzenten Raum-Füllung → 2-stelliges Hex-Suffix. */
const ROOM_FILL_ALPHA = "33"; // ~20 % Deckkraft
/** Basis-Schriftgrösse des Raum-Stempels in Punkt (pt). */
const ROOM_STAMP_PT = 10;
/** Farbe mit Alpha-Suffix versehen (nur, wenn `color` ein 6-stelliges Hex ist). */
function withAlpha(color: string, alphaHex: string): string {
return /^#[0-9a-fA-F]{6}$/.test(color) ? `${color}${alphaHex}` : color;
}
/**
* Raum (SIA-416-Fläche) im Grundriss: eine transluzente farbige Füllfläche über
* dem lichten Umriss + eine dünne Umrisslinie (beide `roomId`-getaggt für die
* Auswahl) + ein Raum-Stempel (Text) am Anker (Centroid oder `stampAnchor`) mit
* Name, Fläche „24.30 m²" und SIA-Kürzel. Der Stempel entfällt beim Detailgrad
* „grob"; Fläche/Umfang/Schwerpunkt werden aus `boundary` abgeleitet (nie
* gespeichert). Wird UNTER der Wand-Poché gezeichnet.
*/
function addRoomArea(
out: Primitive[],
room: Room,
greyed: boolean,
detail: DetailLevel,
): void {
const pts = room.boundary;
if (pts.length < 3) return;
const res = evaluateRoom(pts, room.siaCategory);
const stroke = room.color;
// Transluzente Farbfüllung.
out.push({
kind: "polygon",
pts,
fill: withAlpha(room.color, ROOM_FILL_ALPHA),
stroke,
strokeWidthMm: LAYER_LINE_MM * LAYER_DETAIL_FACTOR[detail],
hatch: NO_HATCH,
greyed,
roomId: room.id,
});
// Raum-Stempel: frei editierbarer Rich-Text (Name/Notizen) + LIVE-Flächenzeile
// + SIA-Kürzel. Anker fix (stampAnchor, sonst Centroid). Nur ab Detailgrad
// „mittel". Die Flächenzeile aktualisiert sich unabhängig vom editierten Text.
if (detail !== "grob") {
const at = room.stampAnchor ?? res.centroid;
// Strukturierter Stempel (Feldmodell) hat Vorrang; sonst Alt-Pfad (Rich-Text
// bzw. Raum-Name als einfacher Text).
const doc = room.stamp
? roomStampToDoc(room.stamp)
: room.stampDoc ?? docFromText(room.name);
const extraLines = room.stamp
? roomStampExtraLines(room.stamp, {
netArea: res.netArea,
siaCategory: room.siaCategory,
siaLabel: siaLabel(room.siaCategory),
})
: [
`${res.netArea.toFixed(2)}`,
`${room.siaCategory} · ${siaLabel(room.siaCategory)}`,
];
out.push({
kind: "text",
at,
doc,
extraLines,
basePt: ROOM_STAMP_PT,
color: stroke,
roomId: room.id,
greyed,
});
}
}
/**
* Treppen-Symbol im Grundriss (DOSSIER/SIA):
* • Tritte als Rechtecke: die Tritt-Trennlinien werden gezeichnet — unter der
* Schnittebene DURCHGEZOGEN (sichtbar), darüber GESTRICHELT (verdeckt).
* • Der erste Tritt oberhalb der Schnittebene trägt die diagonale SIA-Bruchlinie
* (Doppelstrich quer über den Lauf).
* • Zwischenpodest (L/Wendel) als Umriss-Polygon.
* • Lauflinie (Lauflinie) mit Auf-/Abpfeil (Auf-/Abpfeil) und Stufenanzahl-Label.
* Alle Primitive tragen `stairId` für die Auswahl.
*/
function addStairSymbol(
out: Primitive[],
project: Project,
stair: Stair,
greyed: boolean,
lwMm: number,
cutHeight: number,
): void {
const { zBottom, zTop } = stairVerticalExtent(project, stair);
const totalRise = zTop - zBottom;
const geo = stairGeometry(stair, totalRise);
const cut = stairCut(geo, cutHeight);
const below = new Set(cut.belowIndices);
const stroke = stair.color ?? POCHE_STROKE;
const stairId = stair.id;
// Umriss + Tritt-Trennlinien je Stufe. Der letzte Punkt jedes Tritt-Rechtecks
// liefert die vordere Kante (Auftritt); wir zeichnen jede Tritt-Kante.
for (const tr of geo.treads) {
const isBelow = below.has(tr.index);
// Tritt-Rechteck als reine Umrisslinie (keine Füllung), durchgezogen wenn
// unter der Schnittebene, sonst gestrichelt (verdeckt).
out.push({
kind: "polygon",
pts: tr.pts,
fill: "none",
stroke,
strokeWidthMm: lwMm * 0.7,
hatch: NO_HATCH,
greyed,
stairId,
});
// Zusätzliche Auftrittskante (vordere Kante der Stufe) etwas kräftiger.
const front: [Vec2, Vec2] = [tr.pts[1], tr.pts[2]];
out.push({
kind: "line",
a: front[0],
b: front[1],
cls: "stair-tread",
weightMm: lwMm * (isBelow ? 1.0 : 0.6),
dash: isBelow ? null : [0.12, 0.08],
color: stroke,
greyed,
stairId,
});
}
// Zwischenpodest (L/Wendel-Auge): Umriss-Polygon.
if (geo.landing && geo.landing.length >= 3) {
out.push({
kind: "polygon",
pts: geo.landing,
fill: "none",
stroke,
strokeWidthMm: lwMm * 0.7,
hatch: NO_HATCH,
greyed,
stairId,
});
}
// SIA-Bruchlinie (Doppelstrich diagonal über den Schnitt-Tritt).
if (cut.breakLine) {
for (const [a, b] of cut.breakLine) {
out.push({
kind: "line",
a,
b,
cls: "stair-break",
weightMm: lwMm,
color: stroke,
greyed,
stairId,
});
}
}
// Lauflinie (Lauflinie) — kräftig, durchgezogen.
for (let i = 0; i < geo.runLine.length - 1; i++) {
out.push({
kind: "line",
a: geo.runLine[i],
b: geo.runLine[i + 1],
cls: "stair-run",
weightMm: lwMm,
color: stroke,
greyed,
stairId,
});
}
// Auf-/Abpfeil an der Spitze der Lauflinie.
const [h1, tip, h2] = geo.arrow.head;
out.push({ kind: "line", a: h1, b: tip, cls: "stair-arrow", weightMm: lwMm, color: stroke, greyed, stairId });
out.push({ kind: "line", a: h2, b: tip, cls: "stair-arrow", weightMm: lwMm, color: stroke, greyed, stairId });
}
const length = (w: Wall): number => Math.hypot(w.end.x - w.start.x, w.end.y - w.start.y);
function computeBounds(project: Project, walls: Wall[], drawings: Drawing2D[] = []) {
function computeBounds(
project: Project,
walls: Wall[],
drawings: Drawing2D[] = [],
ceilings: Ceiling[] = [],
stairs: Stair[] = [],
rooms: Room[] = [],
) {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
const acc = (p: Vec2) => {
minX = Math.min(minX, p.x); minY = Math.min(minY, p.y);
@@ -672,6 +1151,14 @@ function computeBounds(project: Project, walls: Wall[], drawings: Drawing2D[] =
const t = wallTypeThickness(getWallType(project, w));
for (const c of wallCorners(w.start, w.end, t)) acc(c);
}
for (const c of ceilings) for (const p of c.outline) acc(p);
for (const r of rooms) for (const p of r.boundary) acc(p);
for (const s of stairs) {
const { zBottom, zTop } = stairVerticalExtent(project, s);
const geo = stairGeometry(s, zTop - zBottom);
for (const tr of geo.treads) for (const p of tr.pts) acc(p);
if (geo.landing) for (const p of geo.landing) acc(p);
}
for (const d of drawings) {
const g = d.geom;
if (g.shape === "line") { acc(g.a); acc(g.b); }
+111
View File
@@ -0,0 +1,111 @@
/**
* Unit-Tests für die GPU-Tessellierung: Ear-Clipping (konvex + konkav) und
* Bildschirm-Raum-Abbildung.
*/
import { describe, it, expect } from 'vitest';
import { triangulate } from './glPlanCompile';
import { computeOrthoMatrix } from './glPlanRender';
import type { Vec2 } from '../../model/types';
/** Summierte Dreiecksfläche (Betrag) aus Indizes über pts. */
function triArea(pts: Vec2[], idx: number[]): number {
let area = 0;
for (let i = 0; i < idx.length; i += 3) {
const a = pts[idx[i]];
const b = pts[idx[i + 1]];
const c = pts[idx[i + 2]];
area += Math.abs((b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x)) / 2;
}
return area;
}
/** Polygon-Fläche (Shoelace, Betrag). */
function polyArea(pts: Vec2[]): number {
let a = 0;
for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
a += (pts[j].x + pts[i].x) * (pts[j].y - pts[i].y);
}
return Math.abs(a) / 2;
}
describe('triangulate', () => {
it('trianguliert ein Quadrat (2 Dreiecke, volle Fläche)', () => {
const sq: Vec2[] = [
{ x: 0, y: 0 },
{ x: 4, y: 0 },
{ x: 4, y: 4 },
{ x: 0, y: 4 },
];
const idx = triangulate(sq);
expect(idx.length).toBe(6); // 2 Dreiecke
expect(triArea(sq, idx)).toBeCloseTo(polyArea(sq), 6);
});
it('trianguliert ein KONKAVES L-Polygon flächentreu (Fan wäre falsch)', () => {
// L-Form: konkave Ecke bei (2,2).
const L: Vec2[] = [
{ x: 0, y: 0 },
{ x: 4, y: 0 },
{ x: 4, y: 2 },
{ x: 2, y: 2 },
{ x: 2, y: 4 },
{ x: 0, y: 4 },
];
const idx = triangulate(L);
// 6 Ecken → 4 Dreiecke.
expect(idx.length).toBe(12);
// Flächentreu: Summe der Dreiecke == Polygonfläche (12). Ein Fan über die
// konkave Ecke würde deutlich mehr Fläche (außerhalb des L) erzeugen.
expect(triArea(L, idx)).toBeCloseTo(polyArea(L), 6);
expect(triArea(L, idx)).toBeCloseTo(12, 6);
});
it('liefert [] bei <3 Ecken', () => {
expect(triangulate([])).toEqual([]);
expect(triangulate([{ x: 0, y: 0 }])).toEqual([]);
expect(triangulate([{ x: 0, y: 0 }, { x: 1, y: 1 }])).toEqual([]);
});
it('behandelt beide Wicklungsrichtungen (CW + CCW) gleich', () => {
const cw: Vec2[] = [
{ x: 0, y: 0 },
{ x: 0, y: 4 },
{ x: 4, y: 4 },
{ x: 4, y: 0 },
];
const idx = triangulate(cw);
expect(idx.length).toBe(6);
expect(triArea(cw, idx)).toBeCloseTo(polyArea(cw), 6);
});
});
describe('computeOrthoMatrix', () => {
it('liefert eine 4×4-Matrix', () => {
const m = computeOrthoMatrix({ x: 0, y: 0, w: 100, h: 100 }, 100, 100);
expect(m).toBeInstanceOf(Float32Array);
expect(m.length).toBe(16);
});
it('bildet die viewBox-Ecken bei passendem Aspekt auf Clip [-1,1] ab', () => {
// Quadratischer viewBox + quadratisches Canvas → keine Aspekt-Dehnung.
const m = computeOrthoMatrix({ x: 0, y: 0, w: 100, h: 100 }, 100, 100);
// Bildschirm-Punkt (0,0) = obere-linke Ecke → Clip (-1, +1).
const clip = (x: number, y: number) => ({
x: m[0] * x + m[4] * y + m[12],
y: m[1] * x + m[5] * y + m[13],
});
const tl = clip(0, 0);
expect(tl.x).toBeCloseTo(-1, 5);
expect(tl.y).toBeCloseTo(1, 5);
const br = clip(100, 100);
expect(br.x).toBeCloseTo(1, 5);
expect(br.y).toBeCloseTo(-1, 5);
});
it('verschiedene viewBoxes → verschiedene Matrizen', () => {
const a = computeOrthoMatrix({ x: 0, y: 0, w: 100, h: 100 }, 100, 100);
const b = computeOrthoMatrix({ x: 50, y: 50, w: 200, h: 200 }, 100, 100);
expect([...a].every((v, i) => v === b[i])).toBe(false);
});
});
+334
View File
@@ -0,0 +1,334 @@
/**
* Tessellierung: wandelt Plan-`Primitive` in GPU-fertige Puffer.
* Polygone echtes Ear-Clipping (konkav-fähig), Bildschirm-Raum-Dreiecke
* Linien Quad mit Normale + Seiten-Flag (bildschirmkonstante Breite)
*
* Koordinaten: alles wird in BILDSCHIRM-Raum abgelegt (wie `toScreen`):
* sx = mx·PX_PER_M, sy = -my·PX_PER_M.
* Damit ist die Projektion eine reine viewBox-Orthografie (siehe glPlanRender).
*/
import type { Primitive } from '../generatePlan';
import type { Vec2 } from '../../model/types';
import type { GpuGeometry, Rgba } from './glPlanTypes';
const PX_PER_M = 90;
/** Modell-Meter → Bildschirm-Raum (identisch zu PlanView.toScreen). */
function toScreen(p: Vec2): Vec2 {
return { x: p.x * PX_PER_M, y: -p.y * PX_PER_M };
}
/** Signierte Fläche (Shoelace); >0 = CCW (Modell-Y nach oben). */
function signedArea(pts: Vec2[]): number {
let a = 0;
for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
a += pts[j].x * pts[i].y - pts[i].x * pts[j].y;
}
return a / 2;
}
/** Kreuzprodukt (b-a)×(c-a). */
function cross(a: Vec2, b: Vec2, c: Vec2): number {
return (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
}
/** Liegt p im (a,b,c)-Dreieck? (CCW-orientiert). */
function pointInTri(a: Vec2, b: Vec2, c: Vec2, p: Vec2): boolean {
const d1 = cross(a, b, p);
const d2 = cross(b, c, p);
const d3 = cross(c, a, p);
const hasNeg = d1 < 0 || d2 < 0 || d3 < 0;
const hasPos = d1 > 0 || d2 > 0 || d3 > 0;
return !(hasNeg && hasPos);
}
/**
* Ear-Clipping-Triangulierung eines einfachen (lochfreien) Polygons. Robust für
* konvexe UND konkave Ringe. O(n²) für Plan-Polygone (wenige Ecken) völlig
* ausreichend. Gibt Dreiecks-Indizes (0-basiert auf `pts`) zurück; [] bei <3
* Ecken oder Degeneration.
*/
export function triangulate(pts: Vec2[]): number[] {
const n = pts.length;
if (n < 3) return [];
// Ohr-Test unten nutzt cross>0 = konvex, was CCW voraussetzt. Bei CW-Polygonen
// die Index-Reihenfolge umdrehen (Triangulierung ist raum-affin-invariant, die
// Indizes gelten danach auch für die Bildschirm-Raum-Vertices).
const idx: number[] = [];
for (let i = 0; i < n; i++) idx.push(i);
if (signedArea(pts) < 0) idx.reverse(); // <0 = CW → auf CCW drehen
const tris: number[] = [];
let guard = 0;
const maxGuard = n * n + 16;
while (idx.length > 3 && guard++ < maxGuard) {
let clipped = false;
for (let i = 0; i < idx.length; i++) {
const iPrev = idx[(i + idx.length - 1) % idx.length];
const iCur = idx[i];
const iNext = idx[(i + 1) % idx.length];
const a = pts[iPrev];
const b = pts[iCur];
const c = pts[iNext];
// Konvexe Ecke? (bei CCW: cross > 0)
if (cross(a, b, c) <= 0) continue;
// Kein anderer Vertex im Ohr?
let contains = false;
for (let k = 0; k < idx.length; k++) {
const vi = idx[k];
if (vi === iPrev || vi === iCur || vi === iNext) continue;
if (pointInTri(a, b, c, pts[vi])) {
contains = true;
break;
}
}
if (contains) continue;
// Ohr abschneiden.
tris.push(iPrev, iCur, iNext);
idx.splice(i, 1);
clipped = true;
break;
}
if (!clipped) break; // Degeneriert → abbrechen (kein Absturz).
}
if (idx.length === 3) tris.push(idx[0], idx[1], idx[2]);
return tris;
}
/**
* Farbe RGBA[0..1]; ungültig/"none"/"transparent" null.
* Unterstützt "#rgb", "#rrggbb", "#rrggbbaa" und "rgb()/rgba()".
*/
function parseColor(hex: string | undefined): Rgba | null {
if (!hex) return null;
const s = hex.trim().toLowerCase();
if (s === 'none' || s === 'transparent') return null;
if (s.startsWith('rgb')) {
const m = s.match(/[\d.]+/g);
if (!m || m.length < 3) return null;
const a = m.length >= 4 ? parseFloat(m[3]) : 1;
return [+m[0] / 255, +m[1] / 255, +m[2] / 255, a > 1 ? a / 255 : a];
}
let h = s[0] === '#' ? s.slice(1) : s;
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
if (h.length !== 6 && h.length !== 8) return null;
const r = parseInt(h.slice(0, 2), 16);
const g = parseInt(h.slice(2, 4), 16);
const b = parseInt(h.slice(4, 6), 16);
const a = h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1;
if (isNaN(r) || isNaN(g) || isNaN(b)) return null;
return [r / 255, g / 255, b / 255, a];
}
/**
* Tessellliert Plan-Primitive zu GPU-Geometrie (MVP: gefüllte Polygone +
* bildschirmkonstante Striche). Text/Bögen bleiben im SVG-Overlay.
*/
export function compilePrimitivesToGpu(
gl: WebGL2RenderingContext,
primitives: Primitive[],
): GpuGeometry {
// Füll-Puffer (Bildschirm-Raum Positionen + Indizes).
const fillPos: number[] = [];
const fillIdx: number[] = [];
const fillBatches: GpuGeometry['fill']['batches'] = [];
// Linien-Puffer (interleaved [x,y, nx,ny, side]).
const lineVerts: number[] = [];
const lineIdx: number[] = [];
const lineBatches: GpuGeometry['line']['batches'] = [];
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
const track = (mx: number, my: number) => {
if (mx < minX) minX = mx;
if (my < minY) minY = my;
if (mx > maxX) maxX = mx;
if (my > maxY) maxY = my;
};
const sameRgba = (a: Rgba, b: Rgba): boolean =>
a[0] === b[0] && a[1] === b[1] && a[2] === b[2] && a[3] === b[3];
// Ordnungserhaltendes Batch-Merging: aufeinanderfolgende Indizes gleicher Farbe
// (+ Breite bei Linien) werden zu EINEM Draw-Call zusammengefasst (Reihenfolge
// bleibt exakt → korrekte Z-/Alpha-Überlagerung, nur viel weniger State-Wechsel).
const addFillBatch = (count: number, color: Rgba) => {
const last = fillBatches[fillBatches.length - 1];
if (last && sameRgba(last.color, color)) last.indexCount += count;
else fillBatches.push({ startIndex: fillIdx.length - count, indexCount: count, color });
};
const addLineBatch = (count: number, color: Rgba, strokeMm: number) => {
const last = lineBatches[lineBatches.length - 1];
if (last && last.strokeMm === strokeMm && sameRgba(last.color, color))
last.indexCount += count;
else lineBatches.push({ startIndex: lineIdx.length - count, indexCount: count, color, strokeMm });
};
/** Maximaler Miter-Längenfaktor; darüber wird geklemmt (kein Spike an spitzen Ecken). */
const MITER_LIMIT = 4;
/**
* Zeichnet einen zusammenhängenden Linienzug (Bildschirm-Raum) als EINEN
* gehrten Streifen: an jedem Stützpunkt wird der Versatz entlang des Miter-
* Bisektors verlängert (1/cos(θ/2)), sodass benachbarte Segmente bündig
* verschmelzen gehrte Ecke statt Butt-Cap-Stufe. `closed` schließt den Ring
* (letztererster Punkt). Vertex-Layout: [x,y, bx,by, side, miter].
*/
const strokePolyline = (ptsM: Vec2[], closed: boolean, color: Rgba, strokeMm: number) => {
// Auf Bildschirm-Raum abbilden + aufeinanderfolgende Duplikate entfernen.
const S: Vec2[] = [];
for (const p of ptsM) {
const s = toScreen(p);
if (S.length && Math.abs(S[S.length - 1].x - s.x) < 1e-6 && Math.abs(S[S.length - 1].y - s.y) < 1e-6)
continue;
S.push(s);
track(p.x, p.y);
}
if (closed && S.length > 1) {
const f = S[0], l = S[S.length - 1];
if (Math.abs(f.x - l.x) < 1e-6 && Math.abs(f.y - l.y) < 1e-6) S.pop();
}
const k = S.length;
if (k < 2) return;
const leftNormal = (from: Vec2, to: Vec2): Vec2 | null => {
const dx = to.x - from.x, dy = to.y - from.y;
const len = Math.hypot(dx, dy);
return len < 1e-6 ? null : { x: -dy / len, y: dx / len };
};
const base = lineVerts.length / 6;
for (let i = 0; i < k; i++) {
const hasIn = closed || i > 0;
const hasOut = closed || i < k - 1;
const nIn = hasIn ? leftNormal(S[(i - 1 + k) % k], S[i]) : null;
const nOut = hasOut ? leftNormal(S[i], S[(i + 1) % k]) : null;
let bx: number, by: number, miter: number;
if (nIn && nOut) {
let sx = nIn.x + nOut.x, sy = nIn.y + nOut.y;
const slen = Math.hypot(sx, sy);
if (slen < 1e-3) {
// ~180°-Umkehr → kein sinnvoller Bisektor, gerade weiterlaufen.
bx = nOut.x; by = nOut.y; miter = 1;
} else {
bx = sx / slen; by = sy / slen;
const denom = bx * nOut.x + by * nOut.y; // cos(θ/2)
miter = denom > 1e-3 ? Math.min(1 / denom, MITER_LIMIT) : 1;
}
} else {
const n = nIn ?? nOut!;
bx = n.x; by = n.y; miter = 1;
}
lineVerts.push(S[i].x, S[i].y, bx, by, +1, miter);
lineVerts.push(S[i].x, S[i].y, bx, by, -1, miter);
}
const segs = closed ? k : k - 1;
for (let i = 0; i < segs; i++) {
const a = base + 2 * i;
const b = base + 2 * ((i + 1) % k);
lineIdx.push(a, a + 1, b, a + 1, b + 1, b);
}
addLineBatch(segs * 6, color, strokeMm);
};
/** Einzelnes Segment als (ungehrter) 2-Punkt-Zug. */
const pushLine = (aM: Vec2, bM: Vec2, color: Rgba, strokeMm: number) =>
strokePolyline([aM, bM], false, color, strokeMm);
for (const prim of primitives) {
if (prim.kind === 'polygon') {
// Füllung (falls vorhanden).
const fill = parseColor(prim.fill);
if (fill) {
const tris = triangulate(prim.pts);
if (tris.length) {
const base = fillPos.length / 2;
for (const pt of prim.pts) {
const s = toScreen(pt);
fillPos.push(s.x, s.y);
track(pt.x, pt.y);
}
for (const t of tris) fillIdx.push(base + t);
addFillBatch(tris.length, fill);
}
}
// Umriss (crispe Kante) — geschlossener Ring, GEHRT (kein Stufen-Cap an
// Ecken). Breite = ECHTE Papier-mm; der Renderer rechnet massstabs-/
// zoomrichtig in px (wie SVG-printStrokeVb → GL == SVG).
const stroke = parseColor(prim.stroke);
if (stroke && prim.strokeWidthMm > 0 && prim.pts.length >= 2) {
strokePolyline(prim.pts, true, stroke, prim.strokeWidthMm);
}
} else if (prim.kind === 'line') {
const color = parseColor(prim.color) ?? [0.1, 0.1, 0.1, 1];
pushLine(prim.a, prim.b, color, prim.weightMm || 0.18);
} else if (prim.kind === 'arc') {
// Bogen → ein gehrter Polylinienzug (glatt, keine Segment-Stufen).
const color = parseColor((prim as { color?: string }).color) ?? [0.1, 0.1, 0.1, 1];
const strokeMm = prim.weightMm || 0.18;
const c = prim.center;
const a0 = Math.atan2(prim.from.y - c.y, prim.from.x - c.x);
const a1 = Math.atan2(prim.to.y - c.y, prim.to.x - c.x);
// Kürzeste Drehrichtung (Delta nach (-π, π]).
let d = a1 - a0;
while (d <= -Math.PI) d += 2 * Math.PI;
while (d > Math.PI) d -= 2 * Math.PI;
const segs = Math.max(4, Math.ceil((Math.abs(d) / (Math.PI / 2)) * 16));
const arcPts: Vec2[] = [prim.from];
for (let i = 1; i <= segs; i++) {
const t = a0 + (d * i) / segs;
arcPts.push({ x: c.x + prim.r * Math.cos(t), y: c.y + prim.r * Math.sin(t) });
}
strokePolyline(arcPts, false, color, strokeMm);
}
// Text: bleibt im SVG-Overlay (scharfe Schrift, DOM-Hit-Test).
}
// Puffer hochladen.
const uploadArray = (data: number[]): WebGLBuffer | null => {
if (!data.length) return null;
const buf = gl.createBuffer();
if (!buf) return null;
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(data), gl.STATIC_DRAW);
return buf;
};
const uploadIndex = (data: number[]): WebGLBuffer | null => {
if (!data.length) return null;
const buf = gl.createBuffer();
if (!buf) return null;
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, buf);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint32Array(data), gl.STATIC_DRAW);
return buf;
};
return {
fill: {
positionBuffer: uploadArray(fillPos),
indexBuffer: uploadIndex(fillIdx),
batches: fillBatches,
},
line: {
vertexBuffer: uploadArray(lineVerts),
indexBuffer: uploadIndex(lineIdx),
batches: lineBatches,
},
bounds: {
minX: isFinite(minX) ? minX : 0,
minY: isFinite(minY) ? minY : 0,
maxX: isFinite(maxX) ? maxX : 1,
maxY: isFinite(maxY) ? maxY : 1,
},
};
}
+261
View File
@@ -0,0 +1,261 @@
/**
* WebGL2-Render-Loop des GPU-Grundrisses.
*
* Zeichnet zwischengespeicherte GPU-Geometrie (aus glPlanCompile) und aktualisiert
* beim Pan/Zoom NUR die Projektionsmatrix (kein Re-Tessellieren). Getrieben vom
* Aufrufer (PlanView-Pan/Zoom-Handler), nicht per requestAnimationFrame.
*/
import type { GpuGeometry, ShaderProgram, ViewBox } from './glPlanTypes';
/** viewBox-Einheiten je Meter (identisch zu PlanView/toScreen). */
const PX_PER_M = 90;
/** 4×4-Einheitsmatrix (Spalten-Major, WebGL-Konvention). */
function mat4Identity(): Float32Array {
const m = new Float32Array(16);
m[0] = 1;
m[5] = 1;
m[10] = 1;
m[15] = 1;
return m;
}
/**
* Orthografische Projektion [left,right]×[bottom,top] Clip [-1,1].
* Spalten-Major.
*/
function mat4Ortho(
left: number,
right: number,
bottom: number,
top: number,
): Float32Array {
const m = mat4Identity();
const w = right - left;
const h = top - bottom;
m[0] = 2 / w;
m[5] = 2 / h;
m[10] = -1;
m[12] = -(right + left) / w;
m[13] = -(top + bottom) / h;
return m;
}
/** viewBox-Gleichheit (Cache-Invalidierung). */
function viewBoxEqual(a: ViewBox, b: ViewBox): boolean {
return a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h;
}
/**
* Projektionsmatrix für einen viewBox, aspekt-korrigiert wie SVG
* `preserveAspectRatio="xMidYMid meet"`: der viewBox wird auf das
* Canvas-Seitenverhältnis GEDEHNT (zentriert), damit die Skalierung in X und Y
* gleich ist (keine Verzerrung, Perpendikel bleiben senkrecht).
*
* Eingabe-Positionen sind bereits im BILDSCHIRM-Raum (toScreen), daher keine
* zusätzliche Meter-Skalierung. Bildschirm-Y zeigt nach unten top=vb.y,
* bottom=vb.y+vb.h (kleineres Y oben Clip +1).
*/
export function computeOrthoMatrix(
viewBox: ViewBox,
canvasW: number,
canvasH: number,
): Float32Array {
let { x, y, w, h } = viewBox;
if (canvasW > 0 && canvasH > 0 && w > 0 && h > 0) {
const cAspect = canvasW / canvasH;
const vAspect = w / h;
if (cAspect > vAspect) {
// Canvas breiter → viewBox in der Breite dehnen, X zentrieren.
const nw = h * cAspect;
x -= (nw - w) / 2;
w = nw;
} else {
// Canvas höher → viewBox in der Höhe dehnen, Y zentrieren.
const nh = w / cAspect;
y -= (nh - h) / 2;
h = nh;
}
}
// top = y (Bildschirm-Y oben, kleiner), bottom = y + h.
return mat4Ortho(x, x + w, y + h, y);
}
/**
* WebGL2-GPU-Renderer. Verwaltet Shader-Programme + Projektionsmatrix, zeichnet
* Füll- und Linien-Batches effizient über Matrix-Uniforms.
*/
export class PlanGpuRenderer {
private gl: WebGL2RenderingContext;
private fillProgram: ShaderProgram;
private lineProgram: ShaderProgram;
private lastViewBox: ViewBox | null = null;
private lastCanvas: { w: number; h: number } = { w: 0, h: 0 };
private projMatrix: Float32Array;
constructor(
gl: WebGL2RenderingContext,
fillProg: ShaderProgram,
lineProg: ShaderProgram,
) {
this.gl = gl;
this.fillProgram = fillProg;
this.lineProgram = lineProg;
this.projMatrix = mat4Identity();
// Zeichenblatt-Hintergrund (hell, Default #f0f0f0 = --sheet), keine Tiefe;
// Alpha-Blending für weiche Kanten/transluzente Räume.
gl.clearColor(0.941, 0.941, 0.941, 1);
gl.disable(gl.DEPTH_TEST);
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
}
/** Löschfarbe (Zeichenblatt) setzen — theme-sicher aus --sheet. */
setClearColor(r: number, g: number, b: number): void {
this.gl.clearColor(r, g, b, 1);
}
/** Projektionsmatrix nur bei viewBox-/Canvas-Änderung neu berechnen. */
private updateProjection(viewBox: ViewBox, canvasW: number, canvasH: number): void {
if (
!this.lastViewBox ||
!viewBoxEqual(viewBox, this.lastViewBox) ||
this.lastCanvas.w !== canvasW ||
this.lastCanvas.h !== canvasH
) {
this.projMatrix = computeOrthoMatrix(viewBox, canvasW, canvasH);
this.lastViewBox = { ...viewBox };
this.lastCanvas = { w: canvasW, h: canvasH };
}
}
/**
* Zeichnet einen Frame.
* @param geometry GPU-Geometrie (Fill + Line); null nur löschen.
* @param viewBox Aktueller Ausschnitt (Bildschirm-Einheiten).
* @param viewport Zeichenpuffer-Größe in Geräte-Pixeln.
* @param paperScaleN Papier-Massstab-Nenner (1:N) für echte mm-Strichbreiten.
*/
render(
geometry: GpuGeometry | null,
viewBox: ViewBox,
viewport: { width: number; height: number },
paperScaleN = 100,
): void {
const gl = this.gl;
gl.viewport(0, 0, viewport.width, viewport.height);
gl.clear(gl.COLOR_BUFFER_BIT);
if (!geometry) return;
this.updateProjection(viewBox, viewport.width, viewport.height);
// Umrechnung Papier-mm → Geräte-px, EXAKT wie der SVG-printStrokeVb-Pfad:
// Breite_vb = mm·N/1000·PX_PER_M (viewBox-Einheiten, skaliert mit Zoom)
// Breite_px = Breite_vb · meetSkala (Geräte-px je viewBox-Einheit)
// → mm·(N/1000·PX_PER_M·meet). meet nutzt die Geräte-px-Viewport-Größe, enthält
// damit dpr. So gilt: 0.35 mm bei 1:100 = echte 0.35 mm Papier (== SVG).
const meet = Math.min(viewport.width / viewBox.w, viewport.height / viewBox.h);
const mmToDevicePx = (paperScaleN / 1000) * PX_PER_M * meet;
this.drawFills(geometry);
this.drawLines(geometry, viewport, mmToDevicePx);
}
/** Gefüllte Polygone (Poché). */
private drawFills(geometry: GpuGeometry): void {
const gl = this.gl;
const { positionBuffer, indexBuffer, batches } = geometry.fill;
if (!positionBuffer || !indexBuffer || !batches.length) return;
const prog = this.fillProgram;
if (!prog?.program) return;
gl.useProgram(prog.program);
if (prog.uniforms.viewProj) {
gl.uniformMatrix4fv(prog.uniforms.viewProj, false, this.projMatrix);
}
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
const pos = prog.attribs.position;
if (pos !== undefined && pos >= 0) {
gl.enableVertexAttribArray(pos);
gl.vertexAttribPointer(pos, 2, gl.FLOAT, false, 8, 0);
}
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
for (const b of batches) {
if (prog.uniforms.color) gl.uniform4fv(prog.uniforms.color, b.color);
gl.drawElements(gl.TRIANGLES, b.indexCount, gl.UNSIGNED_INT, b.startIndex * 4);
}
if (pos !== undefined && pos >= 0) gl.disableVertexAttribArray(pos);
}
/** Striche mit echter Papier-mm-Breite (massstabs- + zoomrichtig). */
private drawLines(
geometry: GpuGeometry,
viewport: { width: number; height: number },
mmToDevicePx: number,
): void {
const gl = this.gl;
const { vertexBuffer, indexBuffer, batches } = geometry.line;
if (!vertexBuffer || !indexBuffer || !batches.length) return;
const prog = this.lineProgram;
if (!prog?.program) return;
gl.useProgram(prog.program);
if (prog.uniforms.viewProj) {
gl.uniformMatrix4fv(prog.uniforms.viewProj, false, this.projMatrix);
}
if (prog.uniforms.viewportPx) {
gl.uniform2f(prog.uniforms.viewportPx, viewport.width, viewport.height);
}
// strokeScale = mm → Geräte-px; die Basis-Breite (strokePx) trägt die mm.
if (prog.uniforms.strokeScale) {
gl.uniform1f(prog.uniforms.strokeScale, mmToDevicePx);
}
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
const stride = 6 * 4; // [x,y, nx,ny, side, miter]
const pos = prog.attribs.position;
const nrm = prog.attribs.normal;
const side = prog.attribs.side;
const miter = prog.attribs.miter;
if (pos !== undefined && pos >= 0) {
gl.enableVertexAttribArray(pos);
gl.vertexAttribPointer(pos, 2, gl.FLOAT, false, stride, 0);
}
if (nrm !== undefined && nrm >= 0) {
gl.enableVertexAttribArray(nrm);
gl.vertexAttribPointer(nrm, 2, gl.FLOAT, false, stride, 2 * 4);
}
if (side !== undefined && side >= 0) {
gl.enableVertexAttribArray(side);
gl.vertexAttribPointer(side, 1, gl.FLOAT, false, stride, 4 * 4);
}
if (miter !== undefined && miter >= 0) {
gl.enableVertexAttribArray(miter);
gl.vertexAttribPointer(miter, 1, gl.FLOAT, false, stride, 5 * 4);
}
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
// strokePx-Uniform trägt die ECHTE Papier-mm-Breite; der Shader multipliziert
// mit strokeScale (= mm→Geräte-px) und klemmt bei ~0.6 px (Haarlinie sichtbar,
// MSAA via antialias:true), damit sehr feine Gewichte beim Rauszoomen nicht
// verschwinden.
for (const b of batches) {
if (prog.uniforms.color) gl.uniform4fv(prog.uniforms.color, b.color);
if (prog.uniforms.strokePx) gl.uniform1f(prog.uniforms.strokePx, b.strokeMm);
gl.drawElements(gl.TRIANGLES, b.indexCount, gl.UNSIGNED_INT, b.startIndex * 4);
}
if (pos !== undefined && pos >= 0) gl.disableVertexAttribArray(pos);
if (nrm !== undefined && nrm >= 0) gl.disableVertexAttribArray(nrm);
if (side !== undefined && side >= 0) gl.disableVertexAttribArray(side);
if (miter !== undefined && miter >= 0) gl.disableVertexAttribArray(miter);
}
/** Cache invalidieren (z. B. nach Größenänderung). */
invalidateViewBox(): void {
this.lastViewBox = null;
}
}
+185
View File
@@ -0,0 +1,185 @@
/**
* WebGL2-Shader-Programme für den GPU-Grundriss-Renderer.
* Kompilierung + Fehlerbehandlung (Warnung + null, keine Exceptions).
*/
import type { ShaderProgram } from './glPlanTypes';
/**
* Füll-Vertex-Shader: bildet Bildschirm-Raum-Positionen über die viewProj-Matrix
* in den Clip-Raum ab. Für gefüllte Polygone (Wand-Poché, Räume, Decken).
*/
export const FILL_VERT = `#version 300 es
precision highp float;
uniform mat4 viewProj;
in vec2 position;
void main() {
gl_Position = viewProj * vec4(position, 0.0, 1.0);
}
`;
/** Füll-Fragment-Shader: einheitliche Farbe (MVP, noch keine Schraffur). */
export const FILL_FRAG = `#version 300 es
precision mediump float;
uniform vec4 color;
out vec4 outColor;
void main() {
outColor = color;
}
`;
/**
* Linien-Vertex-Shader mit BILDSCHIRMKONSTANTER Breite.
*
* Idee: sowohl der Endpunkt als auch (Endpunkt + Normale) werden durch viewProj
* transformiert; ihre Differenz im Clip-Raum (in Pixel skaliert) ergibt die
* Perpendikel-Richtung. Diese wird auf `strokePx/2` Pixel normiert und je nach
* `side` auf die passende Seite versetzt, dann pxClip zurückgerechnet. So ist
* die Strichbreite unabhängig vom Zoom (keine Re-Tessellierung nötig).
*/
export const LINE_VERT = `#version 300 es
precision highp float;
uniform mat4 viewProj;
uniform float strokePx; // Basis-Breite in Geräte-px (bei strokeScale=1)
uniform float strokeScale; // Zoom-Skalierung: >1 = reingezoomt → dicker (Papier)
uniform vec2 viewportPx; // Zeichenpuffer-Größe [w,h] in Geräte-px
in vec2 position; // Bildschirm-Raum-Stützpunkt
in vec2 normal; // Bildschirm-Raum-Einheits-Miter-Bisektor (Richtung des Versatzes)
in float side; // +1.0 oder -1.0
in float miter; // Miter-Längenfaktor 1/cos(θ/2) (1.0 = gerade, keine Ecke)
void main() {
vec4 clipP = viewProj * vec4(position, 0.0, 1.0);
vec4 clipN = viewProj * vec4(position + normal, 0.0, 1.0);
// Bisektor-Richtung im Pixel-Raum (Clip-Differenz → px).
vec2 dirPx = (clipN.xy - clipP.xy) * viewportPx * 0.5;
float len = length(dirPx);
vec2 unitPx = len > 1e-6 ? dirPx / len : vec2(0.0);
// Echte Papierbreite in Geräte-px = mm(strokePx) · (mm→px)(strokeScale); mind.
// 0.6 px. Der Miter-Faktor verlängert den Versatz an Ecken, sodass benachbarte
// Segmente bündig verschmelzen (gehrte Ecke statt Butt-Cap-Stufe).
float widthPx = max(0.6, strokePx * strokeScale) * miter;
vec2 offsetPx = unitPx * (widthPx * 0.5) * side;
clipP.xy += offsetPx / (viewportPx * 0.5);
gl_Position = clipP;
}
`;
/** Linien-Fragment-Shader: einheitliche Farbe. */
export const LINE_FRAG = `#version 300 es
precision mediump float;
uniform vec4 color;
out vec4 outColor;
void main() {
outColor = color;
}
`;
/** Kompiliert einen einzelnen Shader; null + Warnung bei Fehler. */
function compileShader(
gl: WebGL2RenderingContext,
type: number,
source: string,
): WebGLShader | null {
const shader = gl.createShader(type);
if (!shader) {
console.warn('WebGL: Shader-Objekt konnte nicht erstellt werden');
return null;
}
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.warn(`WebGL: Shader-Kompilierung fehlgeschlagen:\n${gl.getShaderInfoLog(shader)}`);
gl.deleteShader(shader);
return null;
}
return shader;
}
/** Verlinkt Vertex + Fragment zu einem Programm; null + Warnung bei Fehler. */
function linkProgram(
gl: WebGL2RenderingContext,
vert: WebGLShader,
frag: WebGLShader,
): WebGLProgram | null {
const program = gl.createProgram();
if (!program) {
console.warn('WebGL: Programm konnte nicht erstellt werden');
return null;
}
gl.attachShader(program, vert);
gl.attachShader(program, frag);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.warn(`WebGL: Programm-Verlinkung fehlgeschlagen:\n${gl.getProgramInfoLog(program)}`);
gl.deleteProgram(program);
return null;
}
return program;
}
/**
* Kompiliert + verlinkt ein vollständiges Shader-Programm und ermittelt alle
* Attribut-/Uniform-Orte. Gibt null zurück (mit Warnung) statt zu werfen.
*/
export function compileShaderProgram(
gl: WebGL2RenderingContext,
vertSrc: string,
fragSrc: string,
): ShaderProgram | null {
const vert = compileShader(gl, gl.VERTEX_SHADER, vertSrc);
if (!vert) return null;
const frag = compileShader(gl, gl.FRAGMENT_SHADER, fragSrc);
if (!frag) {
gl.deleteShader(vert);
return null;
}
const program = linkProgram(gl, vert, frag);
gl.deleteShader(vert);
gl.deleteShader(frag);
if (!program) return null;
const loc = (name: string): GLint => gl.getAttribLocation(program, name);
const uni = (name: string) => gl.getUniformLocation(program, name);
return {
program,
attribs: {
position: loc('position'),
normal: loc('normal'),
side: loc('side'),
miter: loc('miter'),
},
uniforms: {
viewProj: uni('viewProj'),
color: uni('color'),
strokePx: uni('strokePx'),
strokeScale: uni('strokeScale'),
viewportPx: uni('viewportPx'),
},
};
}
/** Füll-Programm (gefüllte Polygone). */
export function createFillProgram(gl: WebGL2RenderingContext): ShaderProgram | null {
return compileShaderProgram(gl, FILL_VERT, FILL_FRAG);
}
/** Linien-Programm (bildschirmkonstante Striche). */
export function createLineProgram(gl: WebGL2RenderingContext): ShaderProgram | null {
return compileShaderProgram(gl, LINE_VERT, LINE_FRAG);
}
+93
View File
@@ -0,0 +1,93 @@
/**
* Typen des WebGL2-GPU-Plan-Renderers.
* Gemeinsamer Vertrag zwischen Shader-Kompilierung, Tessellierung und Render-Loop.
*
* Koordinaten-Konvention (WICHTIG, muss überall gleich sein):
* Die Tessellierung liefert BILDSCHIRM-Raum-Koordinaten exakt wie `toScreen`
* in PlanView: `sx = mx·PX_PER_M`, `sy = -my·PX_PER_M` (Modell-Y zeigt nach
* oben, Bildschirm-Y nach unten). So ist die Projektion eine reine viewBox-
* Orthografie kein zusätzlicher Skalen-/Spiegel-Schritt, deckungsgleich zum
* SVG-Renderer.
* Linienbreiten sind BILDSCHIRM-KONSTANT (Papier-px): die Geometrie trägt eine
* Normale + Seiten-Flag, der Vertex-Shader expandiert erst im Clip-Raum um
* `strokePx` unabhängig vom Zoom, ohne Re-Tessellierung.
*/
/** RGBA-Farbe, Komponenten in [0,1]. */
export type Rgba = [number, number, number, number];
/** Shader-Programm mit kompiliertem Programm + Attribut-/Uniform-Orten. */
export interface ShaderProgram {
program: WebGLProgram;
attribs: {
position?: GLint;
/** Bildschirm-Raum-Miter-Bisektor (Einheits-Versatzrichtung) für Linien. */
normal?: GLint;
/** Seite der Linie: +1 oder -1. */
side?: GLint;
/** Miter-Längenfaktor 1/cos(θ/2) (1 = gerade). */
miter?: GLint;
};
uniforms: {
viewProj?: WebGLUniformLocation | null;
color?: WebGLUniformLocation | null;
/** Basis-Strichbreite in Geräte-Pixeln (bei strokeScale=1). */
strokePx?: WebGLUniformLocation | null;
/** Zoom-Skalierung der Strichbreite (>1 = reingezoomt → dicker, Papier). */
strokeScale?: WebGLUniformLocation | null;
/** Zeichenpuffer-Größe in Geräte-Pixeln [w,h] (px→Clip-Umrechnung). */
viewportPx?: WebGLUniformLocation | null;
};
}
/** Ein Zeichen-Batch: zusammenhängender Index-Bereich mit einer Farbe. */
export interface FillBatch {
startIndex: number;
indexCount: number;
color: Rgba;
}
/** Ein Linien-Batch: Index-Bereich mit Farbe + echter Papier-Strichbreite. */
export interface LineBatch {
startIndex: number;
indexCount: number;
color: Rgba;
/**
* ECHTE Strichbreite in Papier-Millimeter. Der Renderer rechnet sie über den
* Massstab (1:N) + die aktuelle meet-Skala in Pixel genau wie der SVG-
* `printStrokeVb`-Pfad, sodass GL == SVG (0.35 mm bei 1:100 = 0.35 mm Papier).
*/
strokeMm: number;
}
/**
* GPU-fertige Geometrie: getrennte Puffer für gefüllte Flächen (Poché) und
* Striche (bildschirmkonstante Breite). Einmal je Plan tessellieren, danach
* treibt nur die Projektionsmatrix Pan/Zoom.
*/
export interface GpuGeometry {
/** Gefüllte Polygone (Poché/Räume/Decken) — Bildschirm-Raum. */
fill: {
/** Interleaved [x,y, x,y, …] in Bildschirm-Raum. */
positionBuffer: WebGLBuffer | null;
indexBuffer: WebGLBuffer | null;
batches: FillBatch[];
};
/** Striche — Bildschirm-Raum-Position + Normale + Seiten-Flag. */
line: {
/** Interleaved [x,y, nx,ny, side, …] in Bildschirm-Raum. */
vertexBuffer: WebGLBuffer | null;
indexBuffer: WebGLBuffer | null;
batches: LineBatch[];
};
/** Modell-Bounds (Meter) für Debug/Einpassen; nicht render-kritisch. */
bounds: { minX: number; minY: number; maxX: number; maxY: number };
}
/** Rechteckiger Ausschnitt in Bildschirm-Einheiten (viewBox). */
export interface ViewBox {
x: number;
y: number;
w: number;
h: number;
}
+12
View File
@@ -0,0 +1,12 @@
/**
* WebGL2-GPU-Plan-Renderer öffentliche API.
*/
export * from './glPlanTypes';
export {
compileShaderProgram,
createFillProgram,
createLineProgram,
} from './glPlanShaders';
export { compilePrimitivesToGpu, triangulate } from './glPlanCompile';
export { PlanGpuRenderer, computeOrthoMatrix } from './glPlanRender';
+122
View File
@@ -0,0 +1,122 @@
/**
* React-Hook für den WebGL2-GPU-Plan-Renderer.
* Verwaltet GL-Kontext, Shader, Geometrie-Cache und Render-Aufrufe.
*
* Fällt bei fehlendem WebGL2/Shader-Fehler still auf null zurück; der Aufrufer
* (PlanView) behält dann den SVG-Pfad (Parität-Fallback).
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import type { Plan } from './generatePlan';
import {
compilePrimitivesToGpu,
createFillProgram,
createLineProgram,
PlanGpuRenderer,
type GpuGeometry,
type ViewBox,
} from './glPlan';
/** Liest eine CSS-Custom-Property (z. B. "--sheet") als RGB[0..1] oder null. */
function readCssColor(varName: string): [number, number, number] | null {
if (typeof window === 'undefined') return null;
const raw = getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
let h = raw.startsWith('#') ? raw.slice(1) : raw;
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
if (h.length < 6) return null;
const r = parseInt(h.slice(0, 2), 16);
const g = parseInt(h.slice(2, 4), 16);
const b = parseInt(h.slice(4, 6), 16);
if (isNaN(r) || isNaN(g) || isNaN(b)) return null;
return [r / 255, g / 255, b / 255];
}
export function useGlPlanRenderer(
canvasRef: React.RefObject<HTMLCanvasElement | null>,
enabled: boolean,
) {
const glRef = useRef<WebGL2RenderingContext | null>(null);
const rendererRef = useRef<PlanGpuRenderer | null>(null);
const geomRef = useRef<GpuGeometry | null>(null);
// GL WIRKLICH aktiv (Kontext + Shader ok)? Steuert den SVG-Fallback: bei false
// rendert der Aufrufer den vollen SVG-Pfad, bei true übernimmt die GL-Ebene.
const [ready, setReady] = useState(false);
// GL-Kontext + Shader einmal aufsetzen.
useEffect(() => {
if (!enabled || !canvasRef.current) {
setReady(false);
return;
}
let disposed = false;
try {
const gl = canvasRef.current.getContext('webgl2', {
antialias: true,
alpha: false,
premultipliedAlpha: false,
});
if (!gl) {
console.warn('WebGL2 nicht verfügbar → SVG-Fallback');
return;
}
const fillProg = createFillProgram(gl);
const lineProg = createLineProgram(gl);
if (!fillProg || !lineProg) {
console.warn('Shader-Kompilierung fehlgeschlagen → SVG-Fallback');
return;
}
if (disposed) return;
glRef.current = gl;
const renderer = new PlanGpuRenderer(gl, fillProg, lineProg);
// Löschfarbe theme-sicher aus --sheet (Zeichenblatt ist immer „Papier").
const sheet = readCssColor('--sheet');
if (sheet) renderer.setClearColor(sheet[0], sheet[1], sheet[2]);
rendererRef.current = renderer;
setReady(true);
} catch (e) {
console.warn('WebGL2-Init fehlgeschlagen:', e);
glRef.current = null;
rendererRef.current = null;
setReady(false);
}
return () => {
disposed = true;
setReady(false);
};
}, [enabled, canvasRef]);
/** Geometrie beim Plan-Wechsel neu tessellieren (einmalig, gecacht). */
const updateGeometry = useCallback((plan: Plan) => {
const gl = glRef.current;
if (!gl || !plan) return;
try {
geomRef.current = compilePrimitivesToGpu(gl, plan.primitives);
} catch (e) {
console.warn('Tessellierung fehlgeschlagen:', e);
geomRef.current = null;
}
}, []);
/** Einen Frame zeichnen (Canvas-Größe + dpr hier abgleichen). */
const render = useCallback(
(viewBox: ViewBox, paperScaleN = 100) => {
const gl = glRef.current;
const renderer = rendererRef.current;
const canvas = canvasRef.current;
if (!gl || !renderer || !canvas) return;
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const w = Math.max(1, Math.round(rect.width * dpr));
const h = Math.max(1, Math.round(rect.height * dpr));
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
}
renderer.render(geomRef.current, viewBox, { width: w, height: h }, paperScaleN);
},
[canvasRef],
);
return { render, updateGeometry, ready };
}
+270
View File
@@ -0,0 +1,270 @@
// Hidden-Line-Removal (HLR) über OCCT-WASM — Welle-C-Spike.
//
// Ziel: aus einfachen Volumenkörpern (Wände, Decke) eine 2D-Vektor-Projektion
// (Schnitt/Ansicht) mit ENTFERNTEN verdeckten Kanten erzeugen — die Grundlage,
// aus der später die bestehende SVG-Plan-Pipeline Schnitte/Ansichten zeichnet.
//
// Kernbefund des Spikes (siehe Report): der opencascade.js-Vollbuild 1.1.1
// exportiert die klassischen HLR-Klassen (`HLRBRep_Algo`/`HLRBRep_HLRToShape`,
// `HLRBRep_PolyAlgo`/`HLRBRep_PolyHLRToShape`) NICHT als konstruierbare Klassen,
// sondern nur deren `Handle_…`-Wrapper. Konstruierbar ist aber der High-Level-
// Wrapper `HLRAppli_ReflectLines`, der intern GENAU den exakten HLR-Algorithmus
// (`HLRBRep_Algo`) fährt und sichtbare/verdeckte Kanten getrennt liefert —
// aufgeteilt nach Kanten-Typ (Sharp/OutLine/…). Das ist der hier genutzte Pfad.
//
// Identifier englisch, Kommentare deutsch (CONVENTIONS.md). Einheiten: METER.
import {
loadOcct,
type GpPnt,
type OpenCascadeInstance,
type TopoDS_Shape,
} from "./occt";
/** 2D-Punkt in der Projektionsebene (Meter). */
export interface Pt2 {
x: number;
y: number;
}
/** Achsparalleler Quader: Ursprung (min-Ecke) + Größe (Meter). */
export interface BoxSpec {
origin: [number, number, number];
size: [number, number, number];
}
/**
* Orthographische Blickrichtung. `dir` = Blickrichtung (worauf die Kamera
* schaut), `up` = Hoch-Achse der Projektionsebene. Voreinstellungen decken die
* Ansichtstypen der Roadmap ab (Grundriss = Top, Schnitt/Ansicht = Front/).
*/
export interface ViewDir {
dir: [number, number, number];
up: [number, number, number];
}
/** Häufige Projektionen (rechtshändig, Z = oben). */
export const VIEWS = {
/** Grundriss: von oben (Z), Hoch-Achse = +Y. */
top: { dir: [0, 0, -1], up: [0, 1, 0] } as ViewDir,
/** Ansicht/Schnitt von vorne (Y), Hoch-Achse = +Z. */
front: { dir: [0, -1, 0], up: [0, 0, 1] } as ViewDir,
/** Ansicht/Schnitt von der Seite (X), Hoch-Achse = +Z. */
side: { dir: [-1, 0, 0], up: [0, 0, 1] } as ViewDir,
} as const;
/** Eine projizierte Polylinie (2D) mit Sichtbarkeits-Flag. */
export interface HlrPolyline {
pts: Pt2[];
/** `true` = sichtbare Kante, `false` = verdeckte Kante. */
visible: boolean;
/** Kanten-Herkunft (für spätere Stil-/Linien-Zuordnung). */
kind: "sharp" | "outline";
}
/** Ergebnis eines HLR-Laufs. */
export interface HlrResult {
visible: HlrPolyline[];
hidden: HlrPolyline[];
/** Reine Rechenzeit des HLR (ohne WASM-Init), Millisekunden. */
hlrMs: number;
}
export interface HlrOptions {
/** Verdeckte Kanten mitberechnen (Default: true). */
includeHidden?: boolean;
/** Silhouetten-/OutLine-Kanten mitnehmen (Default: true). */
includeOutline?: boolean;
}
// ── Geometrie-Aufbau ─────────────────────────────────────────────────────────
function makeBox(oc: OpenCascadeInstance, spec: BoxSpec): TopoDS_Shape {
const [ox, oy, oz] = spec.origin;
const [sx, sy, sz] = spec.size;
const p0 = new oc.gp_Pnt_3(ox, oy, oz);
const p1 = new oc.gp_Pnt_3(ox + sx, oy + sy, oz + sz);
return new oc.BRepPrimAPI_MakeBox_3(p0, p1).Shape();
}
/** Mehrere Boxen zu EINEM Solid verschmelzen (BRepAlgoAPI_Fuse, sequenziell). */
function fuseBoxes(oc: OpenCascadeInstance, specs: BoxSpec[]): TopoDS_Shape {
if (specs.length === 0) throw new Error("hlr: keine Box-Spezifikation");
let acc = makeBox(oc, specs[0]);
for (let i = 1; i < specs.length; i++) {
const next = makeBox(oc, specs[i]);
const op = new oc.BRepAlgoAPI_Fuse_3(acc, next);
op.Build?.();
acc = op.Shape();
}
return acc;
}
// ── Kanten-Extraktion ────────────────────────────────────────────────────────
/**
* Eine (bereits 2D-projizierte) OCCT-Kante als Polylinie ausgeben. Die HLR-
* Kanten sind gerade Segmente (Box-Fusion), daher genügen Start-/Endpunkt der
* Kurve; für spätere gekrümmte Geometrie hier zusätzlich tessellieren.
*/
function edgeToPolyline(
oc: OpenCascadeInstance,
edgeShape: TopoDS_Shape,
): Pt2[] {
const edge = oc.TopoDS.Edge_1(edgeShape);
const adaptor = new oc.BRepAdaptor_Curve_2(edge);
const u0 = adaptor.FirstParameter();
const u1 = adaptor.LastParameter();
const a: GpPnt = adaptor.Value(u0);
const b: GpPnt = adaptor.Value(u1);
// 2D-Projektion: X = horizontal, Y = vertikal (Z≈0 in der Projektionsebene).
return [
{ x: a.X(), y: a.Y() },
{ x: b.X(), y: b.Y() },
];
}
function collectEdges(
oc: OpenCascadeInstance,
compound: TopoDS_Shape,
visible: boolean,
kind: "sharp" | "outline",
): HlrPolyline[] {
const out: HlrPolyline[] = [];
const exp = new oc.TopExp_Explorer_2(
compound,
oc.TopAbs_ShapeEnum.TopAbs_EDGE,
oc.TopAbs_ShapeEnum.TopAbs_SHAPE,
);
for (; exp.More(); exp.Next()) {
const pts = edgeToPolyline(oc, exp.Current());
if (pts.length >= 2) out.push({ pts, visible, kind });
}
return out;
}
// ── Öffentliche API ──────────────────────────────────────────────────────────
/**
* HLR direkt auf einem bereits aufgebauten OCCT-Shape ausführen. Liefert
* sichtbare (+ optional verdeckte) 2D-Polylinien in der Projektionsebene.
*/
export function hlrShape(
oc: OpenCascadeInstance,
shape: TopoDS_Shape,
view: ViewDir,
opts: HlrOptions = {},
): HlrResult {
const includeHidden = opts.includeHidden ?? true;
const includeOutline = opts.includeOutline ?? true;
const t0 = performance.now();
const rl = new oc.HLRAppli_ReflectLines(shape);
const [dx, dy, dz] = view.dir;
const [ux, uy, uz] = view.up;
// SetAxes(projDir, projPoint(at), upDir). Blickpunkt = Ursprung genügt für
// eine orthographische Projektion (Lage in der Ebene ist translationsfrei).
rl.SetAxes(dx, dy, dz, 0, 0, 0, ux, uy, uz);
rl.Perform();
const T = oc.HLRBRep_TypeOfResultingEdge;
const visible: HlrPolyline[] = [];
const hidden: HlrPolyline[] = [];
// in3d=false → 2D-projizierte Kanten.
visible.push(
...collectEdges(
oc,
rl.GetCompoundOf3dEdges(T.HLRBRep_Sharp, true, false),
true,
"sharp",
),
);
if (includeOutline) {
visible.push(
...collectEdges(
oc,
rl.GetCompoundOf3dEdges(T.HLRBRep_OutLine, true, false),
true,
"outline",
),
);
}
if (includeHidden) {
hidden.push(
...collectEdges(
oc,
rl.GetCompoundOf3dEdges(T.HLRBRep_Sharp, false, false),
false,
"sharp",
),
);
if (includeOutline) {
hidden.push(
...collectEdges(
oc,
rl.GetCompoundOf3dEdges(T.HLRBRep_OutLine, false, false),
false,
"outline",
),
);
}
}
const hlrMs = performance.now() - t0;
return { visible, hidden, hlrMs };
}
/**
* Komfort-Einstieg: aus Box-Spezifikationen (Wände/Decke) einen Solid fusen und
* HLR für die gegebene Blickrichtung laufen lassen. Lädt OCCT lazy.
*/
export async function hlrFromBoxes(
boxes: BoxSpec[],
view: ViewDir,
opts: HlrOptions = {},
): Promise<HlrResult> {
const oc = await loadOcct();
const shape = fuseBoxes(oc, boxes);
return hlrShape(oc, shape, view, opts);
}
// ── SVG-Ausgabe (Diagnose/Proof) ─────────────────────────────────────────────
/**
* Minimaler SVG-Dump zur visuellen Kontrolle: sichtbare Kanten durchgezogen,
* verdeckte gestrichelt. Kein Bestandteil der Plan-Pipeline nur Proof/Debug.
*/
export function hlrToSvg(result: HlrResult, padding = 0.5): string {
const all = [...result.visible, ...result.hidden];
const xs: number[] = [];
const ys: number[] = [];
for (const pl of all)
for (const p of pl.pts) {
xs.push(p.x);
ys.push(p.y);
}
const minX = Math.min(...xs) - padding;
const maxX = Math.max(...xs) + padding;
const minY = Math.min(...ys) - padding;
const maxY = Math.max(...ys) + padding;
const w = maxX - minX;
const h = maxY - minY;
// SVG-Y zeigt nach unten → Projektions-Y spiegeln.
const line = (pl: HlrPolyline): string => {
const d = pl.pts
.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x} ${maxY - (p.y - minY)}`)
.join(" ");
const stroke = pl.visible ? "#111" : "#999";
const dash = pl.visible ? "" : ' stroke-dasharray="0.1 0.1"';
const sw = pl.kind === "outline" ? 0.04 : 0.02;
return `<path d="${d}" fill="none" stroke="${stroke}" stroke-width="${sw}"${dash}/>`;
};
const paths = all.map(line).join("\n ");
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${minX} ${minY} ${w} ${h}" width="800">
<rect x="${minX}" y="${minY}" width="${w}" height="${h}" fill="#fff"/>
${paths}
</svg>`;
}
+25
View File
@@ -0,0 +1,25 @@
// Ambient-Deklarationen für die WASM-Glue-/Asset-Importe von opencascade.js
// (Welle-C-Spike: Hidden-Line-Removal). Analog zu `src/io/libredwg-web.d.ts`:
// das Paket liefert seine `dist/`-Dateien nicht über sinnvolle `exports`, daher
// laden wir Emscripten-Glue + `.wasm`-URL über zwei virtuelle Module (Aliase in
// vite.config.ts → echte Dateien im Paket). Hier nur schmale Modul-Stubs.
// (Identifier englisch, Kommentare deutsch — CONVENTIONS.md.)
declare module "virtual:occt-glue" {
/**
* Emscripten-Modul-Fabrik von opencascade.js. Nimmt Modul-Overrides
* (u. a. `locateFile`, um die `.wasm`-URL aufzulösen) und liefert das
* initialisierte Modul (das gesamte OCCT-Binding als `unknown`).
*/
const createModule: (opts?: {
locateFile?: (filename: string, scriptDir: string) => string;
wasmBinary?: ArrayBuffer | Uint8Array;
}) => Promise<unknown>;
export default createModule;
}
declare module "virtual:occt-wasm-url" {
/** Von Vite aufgelöste URL der `.wasm`-Datei (gehashtes Asset im Build). */
const url: string;
export default url;
}
+178
View File
@@ -0,0 +1,178 @@
// Lazy-Loader + schmale Typ-Fassade für opencascade.js (OCCT-WASM).
//
// Welle-C-Spike (Hidden-Line-Removal). Das Paket `opencascade.js` (Vollbuild
// 1.1.1) liefert KEINE TypeScript-Typen und exportiert ~20 000 embind-Symbole.
// Wir tippen hier NUR die Klassen/Methoden, die `hlr.ts` tatsächlich benutzt —
// verifiziert gegen die reale Laufzeit-API (embind versioniert überladene
// Konstruktoren als `_1`, `_2`, …).
//
// Laden erfolgt bewusst über einen dynamischen Import (siehe `loadOcct`), damit
// die 66-MB-WASM NICHT ins Haupt-Bundle wandert und nur bei tatsächlichem
// Bedarf (Schnitt-/Ansichts-Generierung) geholt wird.
//
// Identifier englisch, Kommentare deutsch (CONVENTIONS.md).
// ── Schmale Typ-Fassade (nur das Benutzte) ───────────────────────────────────
export interface GpPnt {
X(): number;
Y(): number;
Z(): number;
}
export interface TopoDS_Shape {
ShapeType(): unknown;
IsNull(): boolean;
}
interface TopoDS_Edge extends TopoDS_Shape {}
interface Ctor1<T, A> {
new (a: A): T;
}
interface Ctor3<T, A, B, C> {
new (a: A, b: B, c: C): T;
}
interface ShapeEnum {
TopAbs_EDGE: unknown;
TopAbs_FACE: unknown;
TopAbs_SHAPE: unknown;
TopAbs_SOLID: unknown;
}
interface ResultingEdgeEnum {
HLRBRep_Sharp: unknown;
HLRBRep_OutLine: unknown;
HLRBRep_IsoLine: unknown;
HLRBRep_Rg1Line: unknown;
HLRBRep_RgNLine: unknown;
}
interface TopExpExplorer {
More(): boolean;
Next(): void;
Current(): TopoDS_Shape;
}
interface BRepAdaptorCurve {
FirstParameter(): number;
LastParameter(): number;
Value(u: number): GpPnt;
}
interface MakeBox {
Shape(): TopoDS_Shape;
}
interface BooleanOp {
Shape(): TopoDS_Shape;
Build?(): void;
}
interface ReflectLines {
SetAxes(
nx: number,
ny: number,
nz: number,
xAt: number,
yAt: number,
zAt: number,
xUp: number,
yUp: number,
zUp: number,
): void;
Perform(): void;
/**
* Kanten-Compound holen. `visible`=true sichtbar, false verdeckt.
* `in3d`=true 3D-Kanten, false 2D-projizierte Kanten (Z0 in der
* Projektionsebene: X = horizontal, Y = vertikal).
*/
GetCompoundOf3dEdges(
type: unknown,
visible: boolean,
in3d: boolean,
): TopoDS_Shape;
}
/** Nur die von `hlr.ts` genutzten OCCT-Symbole. */
export interface OpenCascadeInstance {
gp_Pnt_3: Ctor3<GpPnt, number, number, number>;
BRepPrimAPI_MakeBox_1: Ctor3<MakeBox, number, number, number>;
BRepPrimAPI_MakeBox_3: Ctor1<MakeBox, GpPnt> & {
new (origin: GpPnt, corner: GpPnt): MakeBox;
};
BRepAlgoAPI_Fuse_3: {
new (a: TopoDS_Shape, b: TopoDS_Shape): BooleanOp;
};
HLRAppli_ReflectLines: Ctor1<ReflectLines, TopoDS_Shape>;
TopExp_Explorer_2: {
new (s: TopoDS_Shape, toFind: unknown, toAvoid: unknown): TopExpExplorer;
};
BRepAdaptor_Curve_2: Ctor1<BRepAdaptorCurve, TopoDS_Edge>;
TopoDS: { Edge_1(s: TopoDS_Shape): TopoDS_Edge };
TopAbs_ShapeEnum: ShapeEnum;
HLRBRep_TypeOfResultingEdge: ResultingEdgeEnum;
}
// ── Lazy-Loader ──────────────────────────────────────────────────────────────
type OcctFactory = (opts?: {
locateFile?: (filename: string, scriptDir: string) => string;
wasmBinary?: ArrayBuffer | Uint8Array;
}) => Promise<unknown>;
let occtPromise: Promise<OpenCascadeInstance> | null = null;
/** Optionale Metriken des letzten Ladevorgangs (für den Feasibility-Report). */
export interface LoadMetrics {
wasmBytes: number;
fetchMs: number;
initMs: number;
}
let lastMetrics: LoadMetrics | null = null;
export const getLoadMetrics = (): LoadMetrics | null => lastMetrics;
/**
* Lädt + initialisiert OCCT-WASM (lazy, einmalig). Glue + `.wasm`-URL werden
* wie bei LibreDWG über virtuelle Vite-Module aufgelöst, damit die WASM in Dev
* UND Build mit korrektem MIME-Type geladen wird. Der dynamische Import hält die
* 66-MB-WASM aus dem Haupt-Bundle heraus.
*/
export function loadOcct(): Promise<OpenCascadeInstance> {
if (!occtPromise) {
occtPromise = (async () => {
const [glueMod, wasmUrlMod] = await Promise.all([
import("virtual:occt-glue"),
import("virtual:occt-wasm-url"),
]);
const createModule = glueMod.default as OcctFactory;
const wasmUrl = wasmUrlMod.default;
// WASM einmal explizit holen → Größe messen + als `wasmBinary` reichen
// (spart Emscripten den zweiten Fetch, macht die Größe messbar).
const tFetch = performance.now();
const resp = await fetch(wasmUrl);
const wasmBinary = await resp.arrayBuffer();
const fetchMs = performance.now() - tFetch;
const tInit = performance.now();
const instance = (await createModule({
locateFile: () => wasmUrl,
wasmBinary,
})) as OpenCascadeInstance;
const initMs = performance.now() - tInit;
lastMetrics = { wasmBytes: wasmBinary.byteLength, fetchMs, initMs };
return instance;
})();
}
return occtPromise;
}
+231 -1
View File
@@ -24,6 +24,7 @@ import type {
} from "../model/types";
import { commitTransform } from "../tools/transform";
import type { CopyMode, TransformOp, TransformSelection } from "../tools/transform";
import { centroid as roomCentroid } from "../geometry/roomArea";
import { t } from "../i18n";
import type { StoreApi } from "./store";
@@ -161,6 +162,74 @@ export interface ProjectSlice {
* Dicke nur auf diese Wand und teilt keine Presets versehentlich.
*/
setWallThickness: (wallId: string, thickness: number) => void;
// ── Decken-Editieren ──────────────────────────────────────────────────────
/** Verschiebt EINEN Umriss-Vertex (per Index) einer Decke. */
moveCeilingGrip: (ceilingId: string, index: number, pt: Vec2) => void;
/** Verschiebt eine Decke (ganzer Umriss) um `delta`. */
moveCeilingBy: (ceilingId: string, delta: Vec2) => void;
/** Verschiebt eine Umriss-KANTE (beide Vertices) einer Decke um `delta`. */
moveCeilingEdge: (
ceilingId: string,
aIndex: number,
bIndex: number,
delta: Vec2,
) => void;
/** Generischer immutabler Patch auf eine Decke (per ID). */
updateCeiling: (
id: string,
patch: Partial<import("../model/types").Ceiling>,
) => void;
/** Setzt die Gesamtdicke einer Decke (thickness-Übersteuerung, Meter). */
setCeilingThickness: (ceilingId: string, thickness: number) => void;
// ── Öffnungs-Editieren (Object-Info-Panel) ────────────────────────────────
/** Generischer immutabler Patch auf eine Öffnung (per ID). */
updateOpening: (
id: string,
patch: Partial<import("../model/types").Opening>,
) => void;
// ── Treppen-Editieren ──────────────────────────────────────────────────────
/** Verschiebt eine Treppe (Antritt) um `delta`. */
moveStairBy: (stairId: string, delta: Vec2) => void;
/** Generischer immutabler Patch auf eine Treppe (per ID). */
updateStair: (
id: string,
patch: Partial<import("../model/types").Stair>,
) => void;
// ── Raum-Editieren ──────────────────────────────────────────────────────────
/** Verschiebt einen Raum (ganzer Umriss + optionaler Stempel-Anker) um `delta`. */
moveRoomBy: (roomId: string, delta: Vec2) => void;
/** Verschiebt EINEN Umriss-Vertex (per Index) eines Raums. */
moveRoomGrip: (roomId: string, index: number, pt: Vec2) => void;
/** Verschiebt eine Umriss-KANTE (beide Vertices) eines Raums um `delta`. */
moveRoomEdge: (
roomId: string,
aIndex: number,
bIndex: number,
delta: Vec2,
) => void;
/** Generischer immutabler Patch auf einen Raum (per ID). */
updateRoom: (
id: string,
patch: Partial<import("../model/types").Room>,
) => void;
/** Verschiebt NUR den Stempel-Anker eines Raums um `delta` (Kontur bleibt). */
moveRoomStamp: (roomId: string, delta: Vec2) => void;
/** Setzt den Stempel-Anker eines Raums absolut. */
setRoomStampAnchor: (roomId: string, anchor: Vec2) => void;
/** Setzt den Rich-Text des Raum-Stempels. */
setRoomStampDoc: (
roomId: string,
doc: import("../model/types").RichTextDoc,
) => void;
/** Setzt den strukturierten Raum-Stempel (Feldmodell). */
setRoomStamp: (
roomId: string,
stamp: import("../model/types").RoomStamp,
) => void;
}
/**
@@ -309,7 +378,13 @@ export function createProjectSlice(
p.drawingLevels.filter((z) => z.id !== id),
),
walls: p.walls.filter((w) => w.floorId !== id),
ceilings: (p.ceilings ?? []).filter((c) => c.floorId !== id),
stairs: (p.stairs ?? []).filter((s) => s.floorId !== id),
rooms: (p.rooms ?? []).filter((r) => r.floorId !== id),
doors: p.doors.filter((d) => !removedWallIds.has(d.hostWallId)),
openings: (p.openings ?? []).filter(
(o) => !removedWallIds.has(o.hostWallId),
),
};
});
},
@@ -376,7 +451,10 @@ export function createProjectSlice(
const codes = new Set(flattenCategories([src]).map((c) => c.code));
const usedByWall = p.walls.some((w) => codes.has(w.categoryCode));
const usedByDoor = p.doors.some((d) => codes.has(d.categoryCode));
if (usedByWall || usedByDoor) {
const usedByOpening = (p.openings ?? []).some((o) =>
codes.has(o.categoryCode),
);
if (usedByWall || usedByDoor || usedByOpening) {
window.alert(t("alert.layerInUse", { code: src.code, name: src.name }));
return p;
}
@@ -630,6 +708,158 @@ export function createProjectSlice(
setWallThickness: (wallId, thickness) =>
setProject((p) => setWallThickness(p, wallId, thickness)),
// ── Decken-Editieren ──────────────────────────────────────────────────
moveCeilingGrip: (ceilingId, index, pt) =>
setProject((p) => mapCeiling(p, ceilingId, (c) => ({
...c,
outline: c.outline.map((v, i) => (i === index ? pt : v)),
}))),
moveCeilingBy: (ceilingId, delta) =>
setProject((p) => mapCeiling(p, ceilingId, (c) => ({
...c,
outline: c.outline.map((v) => ({ x: v.x + delta.x, y: v.y + delta.y })),
}))),
moveCeilingEdge: (ceilingId, aIndex, bIndex, delta) =>
setProject((p) => mapCeiling(p, ceilingId, (c) => ({
...c,
outline: c.outline.map((v, i) =>
i === aIndex || i === bIndex ? { x: v.x + delta.x, y: v.y + delta.y } : v,
),
}))),
updateCeiling: (id, patch) =>
setProject((p) => mapCeiling(p, id, (c) => ({ ...c, ...patch }))),
setCeilingThickness: (ceilingId, thickness) =>
setProject((p) =>
!isFinite(thickness) || thickness <= 0
? p
: mapCeiling(p, ceilingId, (c) => ({ ...c, thickness })),
),
// ── Öffnungs-Editieren ────────────────────────────────────────────────
updateOpening: (id, patch) =>
setProject((p) => ({
...p,
openings: (p.openings ?? []).map((o) => (o.id === id ? { ...o, ...patch } : o)),
})),
// ── Treppen-Editieren ──────────────────────────────────────────────────
moveStairBy: (stairId, delta) =>
setProject((p) =>
mapStair(p, stairId, (s) => ({
...s,
start: { x: s.start.x + delta.x, y: s.start.y + delta.y },
...(s.center
? { center: { x: s.center.x + delta.x, y: s.center.y + delta.y } }
: {}),
})),
),
updateStair: (id, patch) =>
setProject((p) => mapStair(p, id, (s) => ({ ...s, ...patch }))),
// ── Raum-Editieren ──────────────────────────────────────────────────────
moveRoomBy: (roomId, delta) =>
setProject((p) =>
mapRoom(p, roomId, (r) => ({
...r,
boundary: r.boundary.map((v) => ({ x: v.x + delta.x, y: v.y + delta.y })),
...(r.stampAnchor
? {
stampAnchor: {
x: r.stampAnchor.x + delta.x,
y: r.stampAnchor.y + delta.y,
},
}
: {}),
})),
),
moveRoomGrip: (roomId, index, pt) =>
setProject((p) =>
mapRoom(p, roomId, (r) => ({
...r,
boundary: r.boundary.map((v, i) => (i === index ? pt : v)),
})),
),
moveRoomEdge: (roomId, aIndex, bIndex, delta) =>
setProject((p) =>
mapRoom(p, roomId, (r) => ({
...r,
boundary: r.boundary.map((v, i) =>
i === aIndex || i === bIndex
? { x: v.x + delta.x, y: v.y + delta.y }
: v,
),
})),
),
updateRoom: (id, patch) =>
setProject((p) => mapRoom(p, id, (r) => ({ ...r, ...patch }))),
moveRoomStamp: (roomId, delta) =>
setProject((p) =>
mapRoom(p, roomId, (r) => {
const base = r.stampAnchor ?? roomCentroid(r.boundary);
return {
...r,
stampAnchor: { x: base.x + delta.x, y: base.y + delta.y },
};
}),
),
setRoomStampAnchor: (roomId, anchor) =>
setProject((p) => mapRoom(p, roomId, (r) => ({ ...r, stampAnchor: anchor }))),
setRoomStampDoc: (roomId, doc) =>
setProject((p) => mapRoom(p, roomId, (r) => ({ ...r, stampDoc: doc }))),
setRoomStamp: (roomId, stamp) =>
setProject((p) => mapRoom(p, roomId, (r) => ({ ...r, stamp }))),
};
}
/** Immutabler Map über einen Raum (per ID); No-op ohne Treffer. */
function mapRoom(
project: Project,
roomId: string,
fn: (r: import("../model/types").Room) => import("../model/types").Room,
): Project {
const rooms = project.rooms ?? [];
return {
...project,
rooms: rooms.map((r) => (r.id === roomId ? fn(r) : r)),
};
}
/** Immutabler Map über eine Treppe (per ID); No-op ohne Treffer. */
function mapStair(
project: Project,
stairId: string,
fn: (s: import("../model/types").Stair) => import("../model/types").Stair,
): Project {
const stairs = project.stairs ?? [];
return {
...project,
stairs: stairs.map((s) => (s.id === stairId ? fn(s) : s)),
};
}
/** Immutabler Map über eine Decke (per ID); No-op ohne Treffer. */
function mapCeiling(
project: Project,
ceilingId: string,
fn: (c: import("../model/types").Ceiling) => import("../model/types").Ceiling,
): Project {
const ceilings = project.ceilings ?? [];
return {
...project,
ceilings: ceilings.map((c) => (c.id === ceilingId ? fn(c) : c)),
};
}
+325 -3
View File
@@ -10,17 +10,38 @@
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import { flattenCategories, getWallType, wallTypeThickness } from "../model/types";
import {
ceilingThickness,
flattenCategories,
getCeilingType,
getWallType,
wallTypeThickness,
} from "../model/types";
import type {
Ceiling,
Drawing2D,
Drawing2DGeom,
LayerCategory,
Opening,
Project,
Room,
SiaCategory,
Stair,
StairShape,
VerticalAnchor,
Wall,
WallReferenceLine,
} from "../model/types";
import { nextFloorAbove, wallVerticalExtent } from "../model/wall";
import { evaluateRoom } from "../geometry/roomArea";
import {
ceilingVerticalExtent,
nextFloorAbove,
stairVerticalExtent,
wallVerticalExtent,
} from "../model/wall";
import { ceilingArea, outlineBBox } from "../geometry/ceiling";
import { openingGapQuad, openingVerticalExtent } from "../geometry/opening";
import { stairGeometry, stairBBox } from "../geometry/stair";
/** Eintrag für die WallType-Auswahl (Preset-Dropdown) im Object-Info-Panel. */
export interface WallTypeChoice {
@@ -68,6 +89,102 @@ export interface WallInfo {
zTop: number;
}
/**
* Decken-spezifische Attribute für das Object-Info-Panel (nur bei Auswahl genau
* einer Decke gesetzt). Anzeigefertige Werte + Listen für die Dropdowns.
*/
export interface CeilingInfo {
wallTypeId: string;
/** Aktuelle Gesamtdicke (Meter). */
thickness: number;
/** Ob der aktuelle Aufbau-Typ einschichtig ist (1 Schicht). */
singleLayer: boolean;
/** Grundfläche der Decke (m²). */
area: number;
/** Verfügbare Aufbau-Typ-Presets (wie WallType). */
wallTypes: WallTypeChoice[];
/** Geschoss, dem die Decke zugeordnet ist. */
floorId: string;
floorName: string;
/** Alle Geschosse (für die OK-„an Geschoss gebunden"-Auswahl). */
floors: FloorChoice[];
/** OK-Bindung (undefined = Geschoss-Oberkante). */
top?: VerticalAnchor;
/** Aufgelöste absolute UK/OK (Meter). */
zBottom: number;
zTop: number;
}
/**
* Öffnungs-spezifische Attribute für das Object-Info-Panel (nur bei Auswahl
* genau einer Öffnung gesetzt). Anzeigefertige Werte + Wirts-Wand-Bezug.
*/
export interface OpeningInfo {
kind: "window" | "door";
/** Wirts-Wand (ID + Name/Geschoss zur Anzeige). */
hostWallId: string;
hostWallName: string;
/** Abstand vom Wand-Startpunkt (Meter). */
position: number;
width: number;
height: number;
/** Brüstungshöhe (Meter); 0 bei Türen. */
sillHeight: number;
/** Nur Tür: Anschlag/Aufschlag/Winkel/Richtung. */
hinge?: "start" | "end";
swing?: "left" | "right";
swingAngle?: number;
openingDir?: "in" | "out";
/** Aufgelöste absolute UK/OK (Meter). */
zBottom: number;
zTop: number;
}
/**
* Treppen-spezifische Attribute für das Object-Info-Panel (nur bei Auswahl genau
* einer Treppe gesetzt). Anzeigefertige Werte + Listen für die Dropdowns.
*/
export interface StairInfo {
shape: StairShape;
/** Laufbreite (Meter). */
width: number;
/** Stufenanzahl (Setzstufen). */
stepCount: number;
/** Gesamt-Steighöhe (Meter, OKFF → OKFF). */
totalRise: number;
/** Abgeleitete Steigungshöhe (Meter) = totalRise / stepCount. */
riserHeight: number;
/** Abgeleitete Auftrittstiefe (Meter). */
treadDepth: number;
/** Laufrichtung aufwärts (Auf-/Abpfeil-Richtung). */
up: boolean;
/** Geschoss, dem die Treppe zugeordnet ist. */
floorId: string;
floorName: string;
/** Aufgelöste absolute UK/OK (Meter). */
zBottom: number;
zTop: number;
}
/**
* Raum-spezifische Attribute für das Object-Info-Panel (nur bei Auswahl genau
* eines Raums gesetzt). Anzeigefertige Werte; Fläche/Umfang werden aus dem
* Umriss abgeleitet (nie gespeichert).
*/
export interface RoomInfo {
/** Raum-Name (editierbar). */
name: string;
/** SIA-416-Blatt-Kategorie (HNF/NNF/VF/FF/KGF). */
siaCategory: SiaCategory;
/** Netto-anrechenbare Fläche (m²). */
area: number;
/** Umfang des Umrisses (m). */
perimeter: number;
/** Geschoss, dem der Raum zugeordnet ist. */
floorId: string;
floorName: string;
}
/** Achsparallele Bounding-Box eines Elements in Modell-Metern. */
export interface SelectionBBox {
minX: number;
@@ -82,7 +199,7 @@ export interface SelectionBBox {
* gezeichneten Wert zeigt. `fillHatchId`/`closed` sind nur für Drawing2D sinnvoll.
*/
export interface Selection {
kind: "wall" | "drawing2d" | "door";
kind: "wall" | "ceiling" | "opening" | "stair" | "room" | "drawing2d" | "door";
id: string;
/** Grafik-Kategorie (Ebene) des Elements. */
categoryCode: string;
@@ -109,6 +226,14 @@ export interface Selection {
* (Wandtyp/Dicke), Referenzgeschoss + UK/OK für den Wand-Abschnitt im Panel.
*/
wall?: WallInfo;
/** Decken-Attribute (nur bei `kind === "ceiling"`). */
ceiling?: CeilingInfo;
/** Öffnungs-Attribute (nur bei `kind === "opening"`). */
opening?: OpeningInfo;
/** Treppen-Attribute (nur bei `kind === "stair"`). */
stair?: StairInfo;
/** Raum-Attribute (nur bei `kind === "room"`). */
room?: RoomInfo;
}
const WALL_FALLBACK_MM = 0.18;
@@ -222,6 +347,183 @@ function wallSelection(project: Project, wall: Wall): Selection {
};
}
/** Selektion für eine Decke ableiten (Farbe übersteuerbar, Strichstärke = Kategorie). */
function ceilingSelection(project: Project, ceiling: Ceiling): Selection {
const cat = findCategory(project.layers, ceiling.categoryCode);
const color = ceiling.color ?? cat?.color ?? WALL_FALLBACK_COLOR;
const weightMm = cat?.lw ?? WALL_FALLBACK_MM;
const box = outlineBBox(ceiling.outline);
const wt = getCeilingType(project, ceiling);
const thickness = ceilingThickness(project, ceiling);
const floor = project.drawingLevels.find((z) => z.id === ceiling.floorId);
const { zBottom, zTop } = ceilingVerticalExtent(project, ceiling);
const ceilingInfo: CeilingInfo = {
wallTypeId: ceiling.wallTypeId,
thickness,
singleLayer: wt.layers.length <= 1,
area: ceilingArea(ceiling.outline),
wallTypes: project.wallTypes.map((t) => ({
id: t.id,
name: t.name,
thickness: wallTypeThickness(t),
layerCount: t.layers.length,
})),
floorId: ceiling.floorId,
floorName: floor?.name ?? ceiling.floorId,
floors: project.drawingLevels
.filter((z) => z.kind === "floor")
.map((z) => ({ id: z.id, name: z.name })),
top: ceiling.top,
zBottom,
zTop,
};
return {
kind: "ceiling",
id: ceiling.id,
categoryCode: ceiling.categoryCode,
color,
weightMm,
fillHatchId: undefined,
fillColor: undefined,
closed: true,
bbox: { minX: box.minX, minY: box.minY, maxX: box.maxX, maxY: box.maxY },
ceiling: ceilingInfo,
};
}
/** Selektion für eine Öffnung ableiten (Farbe übersteuerbar, Strichstärke = Kategorie). */
function openingSelection(project: Project, o: Opening): Selection {
const cat = findCategory(project.layers, o.categoryCode);
const color = o.color ?? cat?.color ?? WALL_FALLBACK_COLOR;
const weightMm = cat?.lw ?? WALL_FALLBACK_MM;
const wall = project.walls.find((w) => w.id === o.hostWallId);
const floor = wall
? project.drawingLevels.find((z) => z.id === wall.floorId)
: undefined;
// Bounding-Box aus dem Lücken-Quad (volle Wanddicke); Fallback: leere Box.
const quad = wall ? openingGapQuad(project, wall, o) : null;
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity;
for (const p of quad ?? []) {
minX = Math.min(minX, p.x);
minY = Math.min(minY, p.y);
maxX = Math.max(maxX, p.x);
maxY = Math.max(maxY, p.y);
}
if (!isFinite(minX)) {
minX = minY = maxX = maxY = 0;
}
const ext = wall
? openingVerticalExtent(project, wall, o)
: { zBottom: 0, zTop: o.height };
const openingInfo: OpeningInfo = {
kind: o.kind,
hostWallId: o.hostWallId,
hostWallName: floor ? `${floor.name}` : o.hostWallId,
position: o.position,
width: o.width,
height: o.height,
sillHeight: o.sillHeight,
hinge: o.hinge,
swing: o.swing,
swingAngle: o.swingAngle,
openingDir: o.openingDir,
zBottom: ext.zBottom,
zTop: ext.zTop,
};
return {
kind: "opening",
id: o.id,
categoryCode: o.categoryCode,
color,
weightMm,
fillHatchId: undefined,
fillColor: undefined,
closed: undefined,
bbox: { minX, minY, maxX, maxY },
opening: openingInfo,
};
}
/** Selektion für eine Treppe ableiten (Farbe übersteuerbar, Strichstärke = Kategorie). */
function stairSelection(project: Project, s: Stair): Selection {
const cat = findCategory(project.layers, s.categoryCode);
const color = s.color ?? cat?.color ?? WALL_FALLBACK_COLOR;
const weightMm = cat?.lw ?? WALL_FALLBACK_MM;
const { zBottom, zTop } = stairVerticalExtent(project, s);
const totalRise = zTop - zBottom;
const geo = stairGeometry(s, totalRise);
const box = stairBBox(geo);
const floor = project.drawingLevels.find((z) => z.id === s.floorId);
const stairInfo: StairInfo = {
shape: s.shape,
width: s.width,
stepCount: s.stepCount,
totalRise,
riserHeight: geo.riserHeight,
treadDepth: geo.treadDepth,
up: s.up !== false,
floorId: s.floorId,
floorName: floor?.name ?? s.floorId,
zBottom,
zTop,
};
return {
kind: "stair",
id: s.id,
categoryCode: s.categoryCode,
color,
weightMm,
fillHatchId: undefined,
fillColor: undefined,
closed: undefined,
bbox: { minX: box.minX, minY: box.minY, maxX: box.maxX, maxY: box.maxY },
stair: stairInfo,
};
}
/** Selektion für einen Raum ableiten (Fläche/Umfang aus dem Umriss abgeleitet). */
function roomSelection(project: Project, r: Room): Selection {
const cat = findCategory(project.layers, r.categoryCode);
const color = r.color ?? cat?.color ?? WALL_FALLBACK_COLOR;
const weightMm = cat?.lw ?? WALL_FALLBACK_MM;
const res = evaluateRoom(r.boundary, r.siaCategory);
const floor = project.drawingLevels.find((z) => z.id === r.floorId);
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity;
for (const p of r.boundary) {
minX = Math.min(minX, p.x);
minY = Math.min(minY, p.y);
maxX = Math.max(maxX, p.x);
maxY = Math.max(maxY, p.y);
}
if (!isFinite(minX)) minX = minY = maxX = maxY = 0;
const roomInfo: RoomInfo = {
name: r.name,
siaCategory: r.siaCategory,
area: res.netArea,
perimeter: res.perimeter,
floorId: r.floorId,
floorName: floor?.name ?? r.floorId,
};
return {
kind: "room",
id: r.id,
categoryCode: r.categoryCode,
color,
weightMm,
fillHatchId: undefined,
fillColor: undefined,
closed: true,
bbox: { minX, minY, maxX, maxY },
room: roomInfo,
};
}
/** Selektion für ein 2D-Zeichenelement ableiten (gleiche Auflösung wie generatePlan). */
function drawingSelection(project: Project, d: Drawing2D): Selection {
const ls = d.lineStyleId
@@ -254,15 +556,35 @@ export function deriveSelection(
project: Project,
selectedWallIds: string[],
selectedDrawingId: string | null,
selectedCeilingId: string | null = null,
selectedOpeningId: string | null = null,
selectedStairId: string | null = null,
selectedRoomId: string | null = null,
): Selection | null {
if (selectedDrawingId) {
const d = project.drawings2d.find((x) => x.id === selectedDrawingId);
return d ? drawingSelection(project, d) : null;
}
if (selectedOpeningId) {
const o = (project.openings ?? []).find((x) => x.id === selectedOpeningId);
if (o) return openingSelection(project, o);
}
const wallId = selectedWallIds[0];
if (wallId) {
const w = project.walls.find((x) => x.id === wallId);
return w ? wallSelection(project, w) : null;
}
if (selectedCeilingId) {
const c = (project.ceilings ?? []).find((x) => x.id === selectedCeilingId);
return c ? ceilingSelection(project, c) : null;
}
if (selectedStairId) {
const s = (project.stairs ?? []).find((x) => x.id === selectedStairId);
return s ? stairSelection(project, s) : null;
}
if (selectedRoomId) {
const r = (project.rooms ?? []).find((x) => x.id === selectedRoomId);
return r ? roomSelection(project, r) : null;
}
return null;
}
+29 -2
View File
@@ -11,10 +11,22 @@ export interface SelectionSlice {
// Gewählte 2D-Zeichenelemente als MENGE (Marquee/Mehrfach/Einzel) — getrennt
// von der Wand-Auswahl. Einzel-Auswahl = Array der Länge 1.
selectedDrawingIds: string[];
// Gewählte Decken als MENGE von IDs — getrennt von Wand-/2D-Auswahl.
selectedCeilingIds: string[];
// Gewählte Öffnungen (Fenster/Türen) als MENGE von IDs — getrennt.
selectedOpeningIds: string[];
// Gewählte Treppen als MENGE von IDs — getrennt von den übrigen Kanälen.
selectedStairIds: string[];
// Gewählte Räume als MENGE von IDs — getrennt von den übrigen Kanälen.
selectedRoomIds: string[];
setSelectedWallIds: (ids: string[]) => void;
setSelectedDrawingIds: (ids: string[]) => void;
/** Auswahl (Wände + 2D-Elemente) vollständig leeren. */
setSelectedCeilingIds: (ids: string[]) => void;
setSelectedOpeningIds: (ids: string[]) => void;
setSelectedStairIds: (ids: string[]) => void;
setSelectedRoomIds: (ids: string[]) => void;
/** Auswahl (Wände + Decken + Öffnungen + Treppen + 2D-Elemente) leeren. */
clearSelection: () => void;
}
@@ -25,9 +37,24 @@ export function createSelectionSlice(
return {
selectedWallIds: [],
selectedDrawingIds: [],
selectedCeilingIds: [],
selectedOpeningIds: [],
selectedStairIds: [],
selectedRoomIds: [],
setSelectedWallIds: (ids) => set({ selectedWallIds: ids }),
setSelectedDrawingIds: (ids) => set({ selectedDrawingIds: ids }),
setSelectedCeilingIds: (ids) => set({ selectedCeilingIds: ids }),
setSelectedOpeningIds: (ids) => set({ selectedOpeningIds: ids }),
setSelectedStairIds: (ids) => set({ selectedStairIds: ids }),
setSelectedRoomIds: (ids) => set({ selectedRoomIds: ids }),
clearSelection: () =>
set({ selectedWallIds: [], selectedDrawingIds: [] }),
set({
selectedWallIds: [],
selectedDrawingIds: [],
selectedCeilingIds: [],
selectedOpeningIds: [],
selectedStairIds: [],
selectedRoomIds: [],
}),
};
}
+1043 -18
View File
File diff suppressed because it is too large Load Diff
+727
View File
@@ -0,0 +1,727 @@
// WYSIWYG Rich-Text-Editor-Komponente. Kontrolliert: Props `value`/`onChange`
// steuern den Dokumentzustand; keine interne Dokumentkopie. Toolbar mit
// Bold/Italic/Underline/Strikethrough, Schriftgrösse, Farbe und Presets.
//
// Selektions-Mapping: DOM-Textknoten werden in Dokumentreihenfolge durchlaufen;
// zwischen Block-Elementen (div/p) zählt je eine Absatzgrenze als +1. So entsteht
// ein linearer Zeichen-Offset, der mit TextRange kompatibel ist.
import { useCallback, useEffect, useRef, useState } from "react";
import { t } from "../i18n";
import { docToHtml } from "./renderHtml";
import {
applyMark,
applyPreset,
commonMarkValue,
DEFAULT_PRESETS,
docLength,
emptyDoc,
isMarkActive,
normalizeDoc,
serialize,
toggleMark,
} from "./richText";
import type {
BoolMark,
Marks,
RichTextDoc,
TextRange,
TextStylePreset,
} from "./richText";
// ── Props ────────────────────────────────────────────────────────────────────
export interface RichTextEditorProps {
value: RichTextDoc;
onChange: (doc: RichTextDoc) => void;
presets?: TextStylePreset[];
autoFocus?: boolean;
}
// ── DOM ↔ Modell-Offset-Übersetzung ─────────────────────────────────────────
/**
* Wandelt eine DOM-Position (Node + Offset innerhalb des Nodes) in einen
* linearen Modell-Offset um. Die contenteditable enthält pro Absatz ein <div>,
* darin <span>-Runs; leere Absätze haben ein <br>.
* Linearer Modell-Offset = Summe der Zeichen in vorigen Absätzen
* + Absatz-Index als Trennzeichen-Offset (+1 je Absatzumbruch)
* + Zeichen innerhalb des aktuellen Absatzes bis zur Position.
*/
function domOffsetToModelOffset(
editorEl: HTMLElement,
targetNode: Node,
targetOffset: number,
): number {
const divs = Array.from(editorEl.children) as HTMLElement[];
let modelOffset = 0;
for (let pIdx = 0; pIdx < divs.length; pIdx++) {
const div = divs[pIdx];
if (div === targetNode || div.contains(targetNode)) {
let found = false;
let paraOffset = 0;
const walkPara = (node: Node): boolean => {
if (node.nodeType === Node.TEXT_NODE) {
if (node === targetNode) {
paraOffset += targetOffset;
found = true;
return true;
}
paraOffset += (node as Text).length;
return false;
}
for (const child of Array.from(node.childNodes)) {
if ((child as Element).tagName === "BR") {
if (child === targetNode || node === targetNode) {
found = true;
return true;
}
continue;
}
if (walkPara(child)) return true;
}
return false;
};
if (targetNode === div) {
const children = Array.from(div.childNodes);
const limit = Math.min(targetOffset, children.length);
for (let i = 0; i < limit; i++) {
const child = children[i];
if (child.nodeType === Node.TEXT_NODE) {
paraOffset += (child as Text).length;
} else {
paraOffset += (child as Element).textContent?.length ?? 0;
}
}
found = true;
} else {
walkPara(div);
}
if (!found) {
paraOffset = div.textContent?.replace(/\n/g, "").length ?? 0;
}
return modelOffset + paraOffset;
}
const paraText = div.textContent ?? "";
modelOffset += paraText.length + 1; // +1 für '\n'-Trennzeichen
}
return modelOffset;
}
/**
* Findet für einen linearen Modell-Offset den DOM-Node und Offset darin.
*/
function modelOffsetToDomPos(
editorEl: HTMLElement,
modelOffset: number,
): { node: Node; offset: number } | null {
const divs = Array.from(editorEl.children) as HTMLElement[];
let remaining = modelOffset;
for (let pIdx = 0; pIdx < divs.length; pIdx++) {
const div = divs[pIdx];
const paraLen = div.textContent?.length ?? 0;
if (remaining <= paraLen) {
let found: { node: Node; offset: number } | null = null;
let chars = remaining;
const walk = (node: Node): boolean => {
if (node.nodeType === Node.TEXT_NODE) {
const len = (node as Text).length;
if (chars <= len) {
found = { node, offset: chars };
return true;
}
chars -= len;
return false;
}
if ((node as Element).tagName === "BR") {
if (chars === 0) {
found = { node: div, offset: 0 };
return true;
}
return false;
}
for (const child of Array.from(node.childNodes)) {
if (walk(child)) return true;
}
return false;
};
walk(div);
if (!found) {
found = { node: div, offset: div.childNodes.length };
}
return found;
}
remaining -= paraLen + 1;
}
const lastDiv = divs[divs.length - 1];
if (!lastDiv) return null;
return { node: lastDiv, offset: lastDiv.childNodes.length };
}
// ── Plain-Text aus Editor-DOM ────────────────────────────────────────────────
function editorInnerText(editorEl: HTMLElement): string {
const divs = Array.from(editorEl.children) as HTMLElement[];
if (divs.length === 0) {
return editorEl.innerText.replace(/\n$/, "");
}
return divs
.map((div) => {
if (div.children.length === 1 && (div.children[0] as Element).tagName === "BR") {
return "";
}
return div.textContent ?? "";
})
.join("\n");
}
// ── Dokument aus bearbeitetem Text rekonstruieren ────────────────────────────
function rebuildDocFromText(oldDoc: RichTextDoc, newPlain: string): RichTextDoc {
const newLines = newPlain.split("\n");
const oldParas = oldDoc.paragraphs;
const paragraphs = newLines.map((line, pIdx) => {
const oldPara = oldParas[pIdx];
if (!oldPara) {
return { runs: line.length ? [{ text: line, marks: {} }] : [] };
}
type MarkChar = { ch: string; marks: Marks };
const oldChars: MarkChar[] = [];
for (const run of oldPara.runs) {
for (const ch of run.text) {
oldChars.push({ ch, marks: run.marks });
}
}
const newChars: MarkChar[] = [];
let oldIdx = 0;
for (const ch of line) {
if (oldIdx < oldChars.length && oldChars[oldIdx].ch === ch) {
newChars.push(oldChars[oldIdx]);
oldIdx++;
} else {
const nearMarks =
oldIdx < oldChars.length ? oldChars[oldIdx].marks : (oldChars[oldIdx - 1]?.marks ?? {});
newChars.push({ ch, marks: nearMarks });
}
}
if (newChars.length === 0) return { runs: [] };
const runs: { text: string; marks: Marks }[] = [];
let current = { text: newChars[0].ch, marks: newChars[0].marks };
for (let i = 1; i < newChars.length; i++) {
const { ch, marks } = newChars[i];
if (JSON.stringify(marks) === JSON.stringify(current.marks)) {
current.text += ch;
} else {
runs.push(current);
current = { text: ch, marks };
}
}
runs.push(current);
return { runs };
});
return normalizeDoc({ version: 1, paragraphs });
}
// ── Absatz-Einfügung / Löschung ──────────────────────────────────────────────
function insertParagraphBreak(doc: RichTextDoc, pos: number): RichTextDoc {
const paras = doc.paragraphs;
let remaining = pos;
let pIdx = 0;
let charOffset = 0;
for (let i = 0; i < paras.length; i++) {
const len = paras[i].runs.reduce((n, r) => n + r.text.length, 0);
if (remaining <= len) {
pIdx = i;
charOffset = remaining;
break;
}
remaining -= len + 1;
if (i === paras.length - 1) {
return normalizeDoc({ version: 1, paragraphs: [...paras, { runs: [] }] });
}
}
const para = paras[pIdx];
type MarkChar = { ch: string; marks: Marks };
const chars: MarkChar[] = [];
for (const run of para.runs) {
for (const ch of run.text) {
chars.push({ ch, marks: run.marks });
}
}
const buildPara = (chs: MarkChar[]) => {
if (chs.length === 0) return { runs: [] };
const runs: { text: string; marks: Marks }[] = [];
let cur = { text: chs[0].ch, marks: chs[0].marks };
for (let i = 1; i < chs.length; i++) {
if (JSON.stringify(chs[i].marks) === JSON.stringify(cur.marks)) {
cur.text += chs[i].ch;
} else {
runs.push(cur);
cur = { text: chs[i].ch, marks: chs[i].marks };
}
}
runs.push(cur);
return { runs };
};
const before = buildPara(chars.slice(0, charOffset));
const after = buildPara(chars.slice(charOffset));
const newParas = [
...paras.slice(0, pIdx),
{ ...before, align: para.align },
{ ...after, align: para.align },
...paras.slice(pIdx + 1),
];
return normalizeDoc({ version: 1, paragraphs: newParas });
}
function plainTextFromDoc(doc: RichTextDoc): string {
return doc.paragraphs.map((p) => p.runs.map((r) => r.text).join("")).join("\n");
}
function deleteCharAt(doc: RichTextDoc, pos: number): RichTextDoc {
if (pos < 0) return doc;
const plain = plainTextFromDoc(doc);
if (pos >= plain.length) return doc;
if (plain[pos] === "\n") {
return mergeParaAt(doc, pos);
}
const newPlain = plain.slice(0, pos) + plain.slice(pos + 1);
return rebuildDocFromText(doc, newPlain);
}
function mergeParaAt(doc: RichTextDoc, _pos: number): RichTextDoc {
const paras = doc.paragraphs;
let cursor = 0;
for (let i = 0; i < paras.length - 1; i++) {
const len = paras[i].runs.reduce((n, r) => n + r.text.length, 0);
cursor += len;
if (cursor === _pos) {
const merged = {
runs: [...paras[i].runs, ...paras[i + 1].runs],
align: paras[i].align,
};
const newParas = [...paras.slice(0, i), merged, ...paras.slice(i + 2)];
return normalizeDoc({ version: 1, paragraphs: newParas });
}
cursor += 1;
}
return doc;
}
// ── Selektion lesen / wiederherstellen ──────────────────────────────────────
function readSelection(editorEl: HTMLElement): TextRange | null {
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) return null;
const range = sel.getRangeAt(0);
if (!editorEl.contains(range.commonAncestorContainer)) return null;
const start = domOffsetToModelOffset(editorEl, range.startContainer, range.startOffset);
const end = domOffsetToModelOffset(editorEl, range.endContainer, range.endOffset);
return { start, end };
}
function restoreSelection(editorEl: HTMLElement, selRange: TextRange | null): void {
if (!selRange) return;
const sel = window.getSelection();
if (!sel) return;
const startPos = modelOffsetToDomPos(editorEl, selRange.start);
const endPos = modelOffsetToDomPos(editorEl, selRange.end);
if (!startPos || !endPos) return;
try {
const range = document.createRange();
range.setStart(startPos.node, startPos.offset);
range.setEnd(endPos.node, endPos.offset);
sel.removeAllRanges();
sel.addRange(range);
} catch {
// Ungültige DOM-Position ignorieren.
}
}
// ── Komponente ───────────────────────────────────────────────────────────────
export function RichTextEditor({
value,
onChange,
presets,
autoFocus,
}: RichTextEditorProps): JSX.Element {
const editorRef = useRef<HTMLDivElement>(null);
// Letzter in innerHTML geschriebener serialisierter Zustand — verhindert
// Cursor-Sprünge durch unnötige DOM-Updates.
const lastSerializedRef = useRef<string>("");
// Verhindert das nächste onInput nach programmatischem innerHTML-Set.
const suppressNextInput = useRef(false);
// Ausgewählter Bereich im linearen Modell-Offset-Raum.
const [selRange, setSelRange] = useState<TextRange | null>(null);
// Immer aktuelles value/onChange ohne Stale-Closure-Problem (keine useCallback-Deps).
const valueRef = useRef<RichTextDoc>(value);
const onChangeRef = useRef<(doc: RichTextDoc) => void>(onChange);
valueRef.current = value;
onChangeRef.current = onChange;
const effectivePresets = presets ?? DEFAULT_PRESETS;
// ── HTML-Renderer ──────────────────────────────────────────────────────────
const renderHtml = useCallback((d: RichTextDoc): string => {
return docToHtml(d, { basePt: 12, color: "var(--ink)" });
}, []);
/**
* Setzt innerHTML direkt. `suppressInput` steuert, ob das daraus folgende
* Browser-onInput-Ereignis unterdrückt wird (true nur bei programmatischen
* Edits aus Keyboard-/Toolbar-Handlern, nicht beim externen Prop-Update).
*/
const setEditorHtml = useCallback(
(d: RichTextDoc, restoreSel: TextRange | null = null, suppressInput = false) => {
const el = editorRef.current;
if (!el) return;
const serial = serialize(d);
if (serial !== lastSerializedRef.current) {
if (suppressInput) suppressNextInput.current = true;
lastSerializedRef.current = serial;
el.innerHTML = renderHtml(d);
}
if (restoreSel) {
restoreSelection(el, restoreSel);
}
},
[renderHtml],
);
// ── Initiale Render + externe prop-Änderungen ──────────────────────────────
useEffect(() => {
const el = editorRef.current;
if (!el) return;
// Programmatisches innerHTML-Set feuert kein Browser-onInput — kein suppress nötig.
const serial = serialize(value);
lastSerializedRef.current = serial;
el.innerHTML = renderHtml(value);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Nur beim Mount.
useEffect(() => {
// Externe prop-Änderungen übernehmen ohne Cursor zu versetzen.
setEditorHtml(value, null);
}, [value, setEditorHtml]);
useEffect(() => {
if (autoFocus) {
editorRef.current?.focus();
}
}, [autoFocus]);
// ── Selektion verfolgen ────────────────────────────────────────────────────
const captureSelection = useCallback(() => {
const el = editorRef.current;
if (!el) return;
const range = readSelection(el);
setSelRange(range);
}, []);
// ── Eingabe-Handler ────────────────────────────────────────────────────────
const handleInput = useCallback(() => {
if (suppressNextInput.current) {
suppressNextInput.current = false;
return;
}
const el = editorRef.current;
if (!el) return;
const currentSel = readSelection(el);
const newPlain = editorInnerText(el);
const newDoc = rebuildDocFromText(valueRef.current, newPlain);
const serial = serialize(newDoc);
lastSerializedRef.current = serial;
onChangeRef.current(newDoc);
// HTML neu setzen (ohne suppressNextInput — programmatische DOM-Änderungen
// lösen kein natives input-Event aus) und Selektion wiederherstellen.
el.innerHTML = renderHtml(newDoc);
if (currentSel) restoreSelection(el, currentSel);
setSelRange(currentSel);
}, [renderHtml]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
const el = editorRef.current;
if (!el) return;
const currentSel = readSelection(el);
if (e.key === "Enter") {
e.preventDefault();
const cur = valueRef.current;
const pos = currentSel?.start ?? plainTextFromDoc(cur).length;
const newDoc = insertParagraphBreak(cur, pos);
onChangeRef.current(newDoc);
const newSel: TextRange = { start: pos + 1, end: pos + 1 };
setEditorHtml(newDoc, newSel, true);
setSelRange(newSel);
return;
}
if (e.key === "Backspace") {
if (currentSel && currentSel.start !== currentSel.end) {
return;
}
const pos = currentSel?.start ?? 0;
if (pos === 0) return;
e.preventDefault();
const cur = valueRef.current;
const newDoc = deleteCharAt(cur, pos - 1);
const newPos = pos - 1;
onChangeRef.current(newDoc);
const newSel: TextRange = { start: newPos, end: newPos };
setEditorHtml(newDoc, newSel, true);
setSelRange(newSel);
return;
}
if (e.key === "Delete") {
if (currentSel && currentSel.start !== currentSel.end) {
return;
}
const pos = currentSel?.start ?? 0;
const cur = valueRef.current;
const plain = plainTextFromDoc(cur);
if (pos >= plain.length) return;
e.preventDefault();
const newDoc = deleteCharAt(cur, pos);
onChangeRef.current(newDoc);
const newSel: TextRange = { start: pos, end: pos };
setEditorHtml(newDoc, newSel, true);
setSelRange(newSel);
}
},
[setEditorHtml],
);
// ── Toolbar-Aktionen ───────────────────────────────────────────────────────
const effectiveRange = useCallback((): TextRange => {
if (selRange && selRange.start !== selRange.end) return selRange;
const len = docLength(valueRef.current);
return { start: 0, end: Math.max(1, len) };
}, [selRange]);
const handleToggleMark = useCallback(
(mark: BoolMark) => {
const range = effectiveRange();
const newDoc = toggleMark(valueRef.current, range, mark);
onChangeRef.current(newDoc);
setEditorHtml(newDoc, selRange, true);
},
[effectiveRange, selRange, setEditorHtml],
);
const handleSizeChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const v = parseFloat(e.target.value);
if (isNaN(v) || v <= 0) return;
const range = effectiveRange();
const newDoc = applyMark(valueRef.current, range, "sizePt", v);
onChangeRef.current(newDoc);
setEditorHtml(newDoc, selRange, true);
},
[effectiveRange, selRange, setEditorHtml],
);
const handleColorChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const range = effectiveRange();
const newDoc = applyMark(valueRef.current, range, "color", e.target.value);
onChangeRef.current(newDoc);
setEditorHtml(newDoc, selRange, true);
},
[effectiveRange, selRange, setEditorHtml],
);
const handlePreset = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => {
const id = e.target.value;
const preset = effectivePresets.find((p) => p.id === id);
if (!preset) return;
const range = selRange ?? { start: 0, end: 0 };
const newDoc = applyPreset(valueRef.current, range, preset);
onChangeRef.current(newDoc);
setEditorHtml(newDoc, selRange, true);
e.target.value = "";
},
[effectivePresets, selRange, setEditorHtml],
);
// ── Toolbar-Zustand berechnen ──────────────────────────────────────────────
const queryRange: TextRange =
selRange && selRange.start !== selRange.end
? selRange
: { start: 0, end: Math.max(1, docLength(value)) };
const isBold = isMarkActive(value, queryRange, "bold");
const isItalic = isMarkActive(value, queryRange, "italic");
const isUnderline = isMarkActive(value, queryRange, "underline");
const isStrike = isMarkActive(value, queryRange, "strike");
const currentSize = commonMarkValue(value, queryRange, "sizePt") ?? 12;
const rawColor = commonMarkValue(value, queryRange, "color") ?? "#ededed";
const currentColor = rawColor.startsWith("#") ? rawColor : "#ededed";
// ── Render ────────────────────────────────────────────────────────────────
return (
<div className="rt-editor">
<div className="rt-toolbar">
{/* Fett */}
<button
type="button"
className={`rt-btn${isBold ? " rt-btn--active" : ""}`}
title={t("rt.bold")}
onMouseDown={(e) => {
e.preventDefault();
handleToggleMark("bold");
}}
>
<strong>B</strong>
</button>
{/* Kursiv */}
<button
type="button"
className={`rt-btn${isItalic ? " rt-btn--active" : ""}`}
title={t("rt.italic")}
onMouseDown={(e) => {
e.preventDefault();
handleToggleMark("italic");
}}
>
<em>I</em>
</button>
{/* Unterstrichen */}
<button
type="button"
className={`rt-btn${isUnderline ? " rt-btn--active" : ""}`}
title={t("rt.underline")}
onMouseDown={(e) => {
e.preventDefault();
handleToggleMark("underline");
}}
>
<u>U</u>
</button>
{/* Durchgestrichen */}
<button
type="button"
className={`rt-btn${isStrike ? " rt-btn--active" : ""}`}
title={t("rt.strike")}
onMouseDown={(e) => {
e.preventDefault();
handleToggleMark("strike");
}}
>
<s>S</s>
</button>
<div className="rt-toolbar-sep" />
{/* Schriftgrösse */}
<label className="rt-toolbar-label" title={t("rt.fontSize")}>
<input
type="number"
className="rt-size-input"
min={6}
max={96}
step={1}
value={currentSize}
onChange={handleSizeChange}
title={t("rt.fontSize")}
/>
<span className="rt-unit">pt</span>
</label>
{/* Farbe */}
<input
type="color"
className="rt-color-input"
value={currentColor}
onChange={handleColorChange}
title={t("rt.color")}
/>
{/* Presets */}
{effectivePresets.length > 0 && (
<select
className="rt-preset-select"
defaultValue=""
onChange={handlePreset}
title={t("rt.preset")}
>
<option value="" disabled>
{t("rt.preset")}
</option>
{effectivePresets.map((p) => (
<option key={p.id} value={p.id}>
{p.label}
</option>
))}
</select>
)}
</div>
{/* Editierbare Fläche */}
<div
ref={editorRef}
className="rt-surface"
contentEditable
suppressContentEditableWarning
spellCheck={false}
onInput={handleInput}
onKeyDown={handleKeyDown}
onSelect={captureSelection}
onKeyUp={captureSelection}
onMouseUp={captureSelection}
/>
</div>
);
}
export default RichTextEditor;
export { emptyDoc };
+212
View File
@@ -0,0 +1,212 @@
// Render-Adapter für das Rich-Text-Modell (richText.ts): einmal nach HTML/CSS
// (für den WYSIWYG-Editor und die Bildschirm-Vorschau auf dem Canvas) und einmal
// nach SVG-<tspan> (für den vektoriellen Grundriss-/Print-Renderer). Beide Wege
// bilden dieselbe Mark→Stil-Zuordnung ab, damit Editor-Vorschau und gezeichneter
// Text identisch aussehen. Reines Modul: keine React-/App-Abhängigkeit.
import type { Marks, Paragraph, RichTextDoc, TextRun } from "./richText";
// ── Text-Escaping ───────────────────────────────────────────────────────────
/** Escapt Text für HTML/XML/SVG-Inhalt (kein Attribut). */
export function escapeText(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
/** Escapt einen Wert für ein doppelt-gequotetes XML-Attribut. */
function escapeAttr(s: string): string {
return escapeText(s).replace(/"/g, "&quot;");
}
// ── Gemeinsame Mark→Stil-Zuordnung ──────────────────────────────────────────
/** Kombiniert underline + strike zu einem CSS/SVG-text-decoration-Wert. */
function textDecoration(marks: Marks): string | undefined {
const parts: string[] = [];
if (marks.underline) parts.push("underline");
if (marks.strike) parts.push("line-through");
return parts.length ? parts.join(" ") : undefined;
}
/** Faktor, um den super-/sub-Text zu verkleinern. */
const SCRIPT_SCALE = 0.7;
/**
* CSS-Deklarationen (als `{prop: value}`) für einen Run. `basePt` ist die
* Basisschriftgrösse in pt, falls der Run selbst keine `sizePt` trägt.
*/
export function runCssProps(marks: Marks, basePt = 12): Record<string, string> {
const css: Record<string, string> = {};
if (marks.bold) css["font-weight"] = "700";
if (marks.italic) css["font-style"] = "italic";
const deco = textDecoration(marks);
if (deco) css["text-decoration"] = deco;
if (marks.font) css["font-family"] = marks.font;
const sizePt = (marks.sizePt ?? basePt) * (marks.super || marks.sub ? SCRIPT_SCALE : 1);
css["font-size"] = `${round(sizePt)}pt`;
if (marks.color) css["color"] = marks.color;
if (marks.super) css["vertical-align"] = "super";
if (marks.sub) css["vertical-align"] = "sub";
return css;
}
function cssPropsToString(css: Record<string, string>): string {
return Object.entries(css)
.map(([k, v]) => `${k}:${v}`)
.join(";");
}
function round(n: number): number {
return Math.round(n * 1000) / 1000;
}
// ── HTML-Render (Editor / Canvas-Vorschau) ──────────────────────────────────
export interface HtmlOptions {
/** Basisschriftgrösse in pt für Runs ohne eigene Grösse. */
basePt?: number;
/** Standardfarbe (CSS) falls kein Run eine Farbe setzt. */
color?: string;
}
/**
* Rendert das Dokument zu einem sanitisierten HTML-String: ein `<div>` pro
* Absatz (mit `text-align`), darin ein `<span>` pro Run mit Inline-Styles.
* Leere Absätze erhalten ein `<br>`, damit die Zeilenhöhe erhalten bleibt.
*/
export function docToHtml(doc: RichTextDoc, opts: HtmlOptions = {}): string {
const basePt = opts.basePt ?? 12;
return doc.paragraphs
.map((p) => paragraphToHtml(p, basePt, opts.color))
.join("");
}
function paragraphToHtml(p: Paragraph, basePt: number, color?: string): string {
const align = p.align && p.align !== "left" ? ` style="text-align:${p.align}"` : "";
if (p.runs.length === 0) return `<div${align}><br></div>`;
const inner = p.runs.map((r) => runToHtmlSpan(r, basePt, color)).join("");
return `<div${align}>${inner}</div>`;
}
function runToHtmlSpan(run: TextRun, basePt: number, color?: string): string {
const css = runCssProps(run.marks, basePt);
if (color && !css["color"]) css["color"] = color;
const style = cssPropsToString(css);
const text = escapeText(run.text) || "";
return `<span style="${escapeAttr(style)}">${text}</span>`;
}
// ── SVG-Render (Grundriss / Print) ──────────────────────────────────────────
/** Beschreibung eines einzelnen <tspan> (ein Run innerhalb einer Zeile). */
export interface SvgTspan {
text: string;
/** Schriftgrösse in Benutzereinheiten (bereits umgerechnet). */
fontSize: number;
fontWeight?: "700";
fontStyle?: "italic";
fontFamily?: string;
fill: string;
textDecoration?: string;
/** Vertikaler Versatz für Hoch-/Tiefstellung, in Benutzereinheiten. */
baselineShift?: number;
}
/** Eine gerenderte Zeile (= Absatz) mit ihren Runs. */
export interface SvgLine {
tspans: SvgTspan[];
align: "left" | "center" | "right";
}
export interface SvgTextOptions {
/** Ankerpunkt X in Benutzereinheiten. */
x: number;
/** Baseline der ERSTEN Zeile, Y in Benutzereinheiten. */
y: number;
/** Zeilenvorschub (Baseline zu Baseline) in Benutzereinheiten. */
lineHeight: number;
/**
* Umrechnung Schriftgrösse: `sizePt` × `unitPerPt` = Grösse in Benutzereinheiten.
* Der Aufrufer bestimmt so, ob Text in px, mm oder Metern gezeichnet wird.
*/
unitPerPt: number;
/** Basis-`sizePt` für Runs ohne eigene Grösse. */
basePt?: number;
/** Standardfarbe "#rrggbb" für Runs ohne Farbe. */
color?: string;
}
/**
* Zerlegt das Dokument in Zeilen/Tspans mit bereits in Benutzereinheiten
* umgerechneten Grössen. Der Aufrufer positioniert die Zeilen selbst (Baseline
* der Zeile i = `y + i·lineHeight`).
*/
export function docToLines(doc: RichTextDoc, opts: SvgTextOptions): SvgLine[] {
const basePt = opts.basePt ?? 12;
const color = opts.color ?? "#000000";
return doc.paragraphs.map((p) => ({
align: p.align ?? "left",
tspans: p.runs
.filter((r) => r.text.length > 0)
.map((r) => runToTspan(r, basePt, opts.unitPerPt, color)),
}));
}
function runToTspan(run: TextRun, basePt: number, unitPerPt: number, color: string): SvgTspan {
const m = run.marks;
const script = m.super || m.sub;
const sizePt = (m.sizePt ?? basePt) * (script ? SCRIPT_SCALE : 1);
const fontSize = sizePt * unitPerPt;
const tspan: SvgTspan = {
text: run.text,
fontSize: round(fontSize),
fill: m.color ?? color,
};
if (m.bold) tspan.fontWeight = "700";
if (m.italic) tspan.fontStyle = "italic";
if (m.font) tspan.fontFamily = m.font;
const deco = textDecoration(m);
if (deco) tspan.textDecoration = deco;
if (m.super) tspan.baselineShift = round(fontSize * 0.35);
if (m.sub) tspan.baselineShift = round(-fontSize * 0.2);
return tspan;
}
/**
* Baut ein komplettes `<text>`-SVG-Element als String. Jede Zeile ist ein
* `<tspan x=… dy=…>`, darin ein `<tspan>` je Run. `text-anchor` folgt der
* Absatz-Ausrichtung (leftstart, centermiddle, rightend).
*/
export function docToSvgText(doc: RichTextDoc, opts: SvgTextOptions): string {
const lines = docToLines(doc, opts);
const parts: string[] = [];
lines.forEach((line, i) => {
const dy = i === 0 ? 0 : opts.lineHeight;
const anchor = line.align === "center" ? "middle" : line.align === "right" ? "end" : "start";
const runSpans = line.tspans.map((t) => tspanToSvg(t)).join("");
// Leere Zeile: ein Zero-Width-Space, damit dy dennoch wirkt.
const content = runSpans || "&#8203;";
parts.push(
`<tspan x="${round(opts.x)}" dy="${round(dy)}" text-anchor="${anchor}">${content}</tspan>`,
);
});
return `<text x="${round(opts.x)}" y="${round(opts.y)}">${parts.join("")}</text>`;
}
function tspanToSvg(t: SvgTspan): string {
const attrs: string[] = [`font-size="${t.fontSize}"`, `fill="${escapeAttr(t.fill)}"`];
if (t.fontWeight) attrs.push(`font-weight="${t.fontWeight}"`);
if (t.fontStyle) attrs.push(`font-style="${t.fontStyle}"`);
if (t.fontFamily) attrs.push(`font-family="${escapeAttr(t.fontFamily)}"`);
if (t.textDecoration) attrs.push(`text-decoration="${t.textDecoration}"`);
if (t.baselineShift) attrs.push(`dy="${-t.baselineShift}"`);
const body = escapeText(t.text);
// Bei baseline-shift den dy nach dem Run zurücksetzen, damit die Grundlinie hält.
if (t.baselineShift) {
return `<tspan ${attrs.join(" ")}>${body}</tspan><tspan dy="${t.baselineShift}">&#8203;</tspan>`;
}
return `<tspan ${attrs.join(" ")}>${body}</tspan>`;
}
+451
View File
@@ -0,0 +1,451 @@
// Rich-Text-Dokumentmodell — die wiederverwendbare Grundlage für TextEntity-
// Annotationen (Text-/Stempel-Bauteil). Bewusst frei von App-Store und React:
// ein reines Datenmodell + Operationen, unit-getestet.
//
// Aufbau (an DOSSIERs text_editor.py angelehnt: Runs mit text/font/bold/italic/
// underline/strike/sup/sub/color, Grössen in Punkt):
//
// RichTextDoc = { version, paragraphs: Paragraph[] }
// Paragraph = { runs: TextRun[], align? }
// TextRun = { text: string, marks: Marks }
// Marks = { bold?, italic?, underline?, strike?, super?, sub?,
// font?, sizePt?, color? }
//
// Absätze trennen Zeilen (harte Umbrüche); innerhalb eines Absatzes tragen die
// Runs die Zeichenformatierung. Ein „Zeichen-Offset" adressiert das Dokument
// linear über alle Absätze hinweg, wobei jeder Absatzumbruch als EIN Zeichen
// zählt (wie „\n"). Das erlaubt Bereichs-Operationen (applyMark/toggleMark) über
// Absatzgrenzen hinweg.
/** Zeichen-Marks eines Runs. Alle optional; fehlend = „nicht gesetzt". */
export interface Marks {
bold?: boolean;
italic?: boolean;
underline?: boolean;
strike?: boolean;
/** Hochgestellt. super und sub schliessen sich gegenseitig aus. */
super?: boolean;
/** Tiefgestellt. */
sub?: boolean;
/** Schriftfamilie (CSS font-family-Wert), z. B. "Helvetica". */
font?: string;
/** Schriftgrösse in typografischen Punkt (pt). */
sizePt?: number;
/** Farbe als "#rrggbb". */
color?: string;
}
/** Ein zusammenhängender Textabschnitt mit einheitlicher Formatierung. */
export interface TextRun {
text: string;
marks: Marks;
}
/** Absatz-Ausrichtung. */
export type Align = "left" | "center" | "right";
/** Ein Absatz: eine Zeile aus Runs plus optionale Ausrichtung. */
export interface Paragraph {
runs: TextRun[];
align?: Align;
}
/** Das gesamte Rich-Text-Dokument. */
export interface RichTextDoc {
version: 1;
paragraphs: Paragraph[];
}
/** Namen der booleschen Marks (togglebar). */
export type BoolMark = "bold" | "italic" | "underline" | "strike" | "super" | "sub";
/** Ein Zeichen-Bereich [start, end) im linearen Dokument-Offset. */
export interface TextRange {
start: number;
end: number;
}
// ── Konstruktoren ──────────────────────────────────────────────────────────
/** Leeres Dokument (ein leerer Absatz). */
export function emptyDoc(): RichTextDoc {
return { version: 1, paragraphs: [{ runs: [] }] };
}
/**
* Dokument aus reinem Text. Zeilenumbrüche ("\n") werden zu Absätzen; die
* übergebenen Marks gelten für den ganzen Text.
*/
export function docFromText(text: string, marks: Marks = {}): RichTextDoc {
const lines = text.split("\n");
const paragraphs: Paragraph[] = lines.map((line) => ({
runs: line.length ? [{ text: line, marks: { ...marks } }] : [],
}));
return { version: 1, paragraphs };
}
/** Erzeugt einen Run (defensive Kopie der Marks). */
export function makeRun(text: string, marks: Marks = {}): TextRun {
return { text, marks: { ...marks } };
}
// ── Extraktion ─────────────────────────────────────────────────────────────
/** Reintext des Dokuments; Absätze werden mit "\n" verbunden. */
export function plainText(doc: RichTextDoc): string {
return doc.paragraphs
.map((p) => p.runs.map((r) => r.text).join(""))
.join("\n");
}
/** Ist das Dokument (nach Reintext) leer? */
export function isEmpty(doc: RichTextDoc): boolean {
return plainText(doc).length === 0;
}
/** Gesamtlänge in Zeichen (inkl. Absatzumbrüche als je 1 Zeichen). */
export function docLength(doc: RichTextDoc): number {
return plainText(doc).length;
}
// ── Marks-Vergleich / Normalisierung ───────────────────────────────────────
const MARK_KEYS: (keyof Marks)[] = [
"bold",
"italic",
"underline",
"strike",
"super",
"sub",
"font",
"sizePt",
"color",
];
/** Normalisiert Marks: entfernt „falsy"/leere Einträge, damit Vergleiche stabil sind. */
export function normalizeMarks(marks: Marks): Marks {
const out: Marks = {};
for (const key of MARK_KEYS) {
const value = marks[key];
if (value === undefined || value === false || value === null) continue;
if ((key === "font" || key === "color") && value === "") continue;
// super und sub sind exklusiv — sub gewinnt nicht über super, wir behalten beide
// Werte nur wenn true; Auflösung geschieht bei toggleMark.
(out as Record<string, unknown>)[key] = value;
}
return out;
}
/** Strukturvergleich zweier Marks-Objekte (nach Normalisierung). */
export function marksEqual(a: Marks, b: Marks): boolean {
const na = normalizeMarks(a);
const nb = normalizeMarks(b);
for (const key of MARK_KEYS) {
if ((na as Record<string, unknown>)[key] !== (nb as Record<string, unknown>)[key]) {
return false;
}
}
return true;
}
/**
* Führt benachbarte Runs mit gleichen Marks zusammen und entfernt leere Runs.
* Idempotent; hält das Dokument nach Editieroperationen kompakt.
*/
export function normalizeDoc(doc: RichTextDoc): RichTextDoc {
const paragraphs: Paragraph[] = doc.paragraphs.map((p) => {
const merged: TextRun[] = [];
for (const run of p.runs) {
if (run.text.length === 0) continue;
const norm = { text: run.text, marks: normalizeMarks(run.marks) };
const last = merged[merged.length - 1];
if (last && marksEqual(last.marks, norm.marks)) {
last.text += norm.text;
} else {
merged.push(norm);
}
}
const out: Paragraph = { runs: merged };
if (p.align && p.align !== "left") out.align = p.align;
return out;
});
if (paragraphs.length === 0) paragraphs.push({ runs: [] });
return { version: 1, paragraphs };
}
// ── Bereichs-Adressierung (linearer Offset ↔ Absatz/Run) ────────────────────
interface ParaSpan {
index: number;
/** Start-Offset dieses Absatzes im linearen Dokument. */
start: number;
/** End-Offset (exklusive Umbruch) = start + Reintextlänge des Absatzes. */
end: number;
}
/** Berechnet Start/End-Offsets je Absatz. */
function paragraphSpans(doc: RichTextDoc): ParaSpan[] {
const spans: ParaSpan[] = [];
let cursor = 0;
doc.paragraphs.forEach((p, index) => {
const len = p.runs.reduce((n, r) => n + r.text.length, 0);
spans.push({ index, start: cursor, end: cursor + len });
cursor += len + 1; // +1 für den Absatzumbruch
});
return spans;
}
/**
* Wendet `fn` auf jeden Run an, der (ganz oder teilweise) im Bereich [start,end)
* liegt; teilt Runs an den Bereichsgrenzen auf. Gibt ein neues, normalisiertes
* Dokument zurück. Die Callback bekommt die aktuellen Marks des Teil-Runs und
* liefert die neuen Marks.
*/
export function mapRunsInRange(
doc: RichTextDoc,
range: TextRange,
fn: (marks: Marks) => Marks,
): RichTextDoc {
const start = Math.max(0, Math.min(range.start, range.end));
const end = Math.max(range.start, range.end);
if (start === end) return normalizeDoc(doc);
const spans = paragraphSpans(doc);
const paragraphs: Paragraph[] = doc.paragraphs.map((p, pIdx) => {
const span = spans[pIdx];
// Absatz komplett ausserhalb des Bereichs?
if (span.end <= start || span.start >= end) return p;
const newRuns: TextRun[] = [];
let runStart = span.start;
for (const run of p.runs) {
const runEnd = runStart + run.text.length;
const selStart = Math.max(start, runStart);
const selEnd = Math.min(end, runEnd);
if (selEnd <= selStart) {
// Run ausserhalb — unverändert übernehmen.
newRuns.push(run);
} else {
const relStart = selStart - runStart;
const relEnd = selEnd - runStart;
if (relStart > 0) {
newRuns.push(makeRun(run.text.slice(0, relStart), run.marks));
}
newRuns.push(makeRun(run.text.slice(relStart, relEnd), fn(run.marks)));
if (relEnd < run.text.length) {
newRuns.push(makeRun(run.text.slice(relEnd), run.marks));
}
}
runStart = runEnd;
}
return { ...p, runs: newRuns };
});
return normalizeDoc({ version: 1, paragraphs });
}
// ── Mark-Operationen ───────────────────────────────────────────────────────
/**
* Setzt ein Mark auf einen Bereich. `value` bei boolschen Marks true/false;
* bei font/sizePt/color der jeweilige Wert (oder undefined zum Entfernen).
* super/sub werden exklusiv gehalten.
*/
export function applyMark<K extends keyof Marks>(
doc: RichTextDoc,
range: TextRange,
mark: K,
value: Marks[K] | undefined,
): RichTextDoc {
return mapRunsInRange(doc, range, (marks) => {
const next: Marks = { ...marks };
if (value === undefined || value === false) {
delete next[mark];
} else {
(next as Record<string, unknown>)[mark] = value;
if (mark === "super" && value) delete next.sub;
if (mark === "sub" && value) delete next.super;
}
return next;
});
}
/**
* Ist ein boolsches Mark über den GESAMTEN Bereich aktiv? (Für den
* Toolbar-Zustand: Fett"-Knopf gedrückt, wenn die ganze Auswahl fett ist.)
*/
export function isMarkActive(doc: RichTextDoc, range: TextRange, mark: BoolMark): boolean {
const start = Math.max(0, Math.min(range.start, range.end));
const end = Math.max(range.start, range.end);
if (start === end) return false;
const spans = paragraphSpans(doc);
let sawAny = false;
for (let pIdx = 0; pIdx < doc.paragraphs.length; pIdx++) {
const span = spans[pIdx];
if (span.end <= start || span.start >= end) continue;
let runStart = span.start;
for (const run of doc.paragraphs[pIdx].runs) {
const runEnd = runStart + run.text.length;
const selStart = Math.max(start, runStart);
const selEnd = Math.min(end, runEnd);
if (selEnd > selStart) {
sawAny = true;
if (!run.marks[mark]) return false;
}
runStart = runEnd;
}
}
return sawAny;
}
/**
* Schaltet ein boolsches Mark über den Bereich um: ist es überall aktiv
* entfernen, sonst überall setzen.
*/
export function toggleMark(doc: RichTextDoc, range: TextRange, mark: BoolMark): RichTextDoc {
const active = isMarkActive(doc, range, mark);
return applyMark(doc, range, mark, active ? undefined : true);
}
/**
* Liefert den gemeinsamen Wert eines Nicht-Bool-Marks über den Bereich, oder
* undefined wenn uneinheitlich/leer. (Für Toolbar-Anzeige von Grösse/Farbe/Font.)
*/
export function commonMarkValue<K extends "font" | "sizePt" | "color">(
doc: RichTextDoc,
range: TextRange,
mark: K,
): Marks[K] | undefined {
const start = Math.max(0, Math.min(range.start, range.end));
const end = Math.max(range.start, range.end);
if (start === end) return undefined;
const spans = paragraphSpans(doc);
let value: Marks[K] | undefined;
let first = true;
for (let pIdx = 0; pIdx < doc.paragraphs.length; pIdx++) {
const span = spans[pIdx];
if (span.end <= start || span.start >= end) continue;
let runStart = span.start;
for (const run of doc.paragraphs[pIdx].runs) {
const runEnd = runStart + run.text.length;
const selStart = Math.max(start, runStart);
const selEnd = Math.min(end, runEnd);
if (selEnd > selStart) {
const v = run.marks[mark];
if (first) {
value = v;
first = false;
} else if (v !== value) {
return undefined;
}
}
runStart = runEnd;
}
}
return value;
}
// ── Serialisierung (stabiles JSON) ─────────────────────────────────────────
/**
* Stabile JSON-Serialisierung: Schlüssel in fester Reihenfolge, damit gleiche
* Dokumente denselben String ergeben (Diff-/Test-freundlich).
*/
export function serialize(doc: RichTextDoc): string {
const norm = normalizeDoc(doc);
const paragraphs = norm.paragraphs.map((p) => {
const runs = p.runs.map((r) => {
const marks: Record<string, unknown> = {};
for (const key of MARK_KEYS) {
const v = (r.marks as Record<string, unknown>)[key];
if (v !== undefined) marks[key] = v;
}
return { text: r.text, marks };
});
const obj: Record<string, unknown> = { runs };
if (p.align && p.align !== "left") obj.align = p.align;
return obj;
});
return JSON.stringify({ version: 1, paragraphs });
}
/**
* Liest ein Dokument aus JSON. Robust gegenüber fehlenden Feldern; unbekannter
* Input ergibt ein leeres Dokument statt eines Fehlers.
*/
export function deserialize(json: string): RichTextDoc {
try {
const raw = JSON.parse(json) as unknown;
return fromJson(raw);
} catch {
return emptyDoc();
}
}
/** Wandelt ein bereits geparstes Objekt in ein valides Dokument. */
export function fromJson(raw: unknown): RichTextDoc {
if (!raw || typeof raw !== "object") return emptyDoc();
const obj = raw as { paragraphs?: unknown };
if (!Array.isArray(obj.paragraphs)) return emptyDoc();
const paragraphs: Paragraph[] = obj.paragraphs.map((p) => {
const pp = (p ?? {}) as { runs?: unknown; align?: unknown };
const runs: TextRun[] = Array.isArray(pp.runs)
? pp.runs.map((r) => {
const rr = (r ?? {}) as { text?: unknown; marks?: unknown };
const text = typeof rr.text === "string" ? rr.text : "";
const marks = normalizeMarks((rr.marks ?? {}) as Marks);
return { text, marks };
})
: [];
const para: Paragraph = { runs };
if (pp.align === "center" || pp.align === "right") para.align = pp.align;
return para;
});
return normalizeDoc({ version: 1, paragraphs });
}
// ── Style-Presets ──────────────────────────────────────────────────────────
/** Ein benannter Stil (Marks-Vorlage), z. B. „Titel". */
export interface TextStylePreset {
/** Stabiler Schlüssel (englisch), z. B. "title". */
id: string;
/** Anzeigename (bereits übersetzt oder Schlüssel für t()). */
label: string;
/** Marks, die der Stil auf die Auswahl (oder das ganze Dokument) legt. */
marks: Marks;
}
/**
* Standard-Presets, an DOSSIERs Textstile angelehnt. Grössen in pt; die
* SVG-/Canvas-Renderer rechnen pt Meter/px selbst um.
*/
export const DEFAULT_PRESETS: TextStylePreset[] = [
{ id: "title", label: "Titel", marks: { bold: true, sizePt: 18 } },
{ id: "subtitle", label: "Untertitel", marks: { italic: true, sizePt: 13 } },
{ id: "label", label: "Beschriftung", marks: { sizePt: 10 } },
{ id: "note", label: "Notiz", marks: { italic: true, sizePt: 8, color: "#8a8580" } },
];
/**
* Wendet ein Preset auf einen Bereich an (setzt jede Mark des Presets). Ein
* leerer Bereich wird auf das ganze Dokument angewendet.
*/
export function applyPreset(
doc: RichTextDoc,
range: TextRange,
preset: TextStylePreset,
): RichTextDoc {
const full: TextRange =
range.start === range.end ? { start: 0, end: Math.max(1, docLength(doc)) } : range;
let out = doc;
for (const key of MARK_KEYS) {
const v = (preset.marks as Record<string, unknown>)[key];
if (v !== undefined) {
out = applyMark(out, full, key as keyof Marks, v as Marks[keyof Marks]);
}
}
return out;
}
+174 -2
View File
@@ -1,9 +1,10 @@
// Konkrete Werkzeuge (Phase 1: select / wall / line) + Registry.
// docs/design/drawing-tools.md §3, §4, §10.
import type { Drawing2D, Project, Vec2, Wall } from "../model/types";
import type { Ceiling, Drawing2D, Project, Vec2, Wall } from "../model/types";
import { wallTypeThickness } from "../model/types";
import { wallCorners } from "../model/geometry";
import { normalizeOutline, ceilingArea } from "../geometry/ceiling";
import type { DraftShape, Tool, ToolContext, ToolDraft, ToolId } from "./types";
import { uniqueId } from "./types";
@@ -137,6 +138,161 @@ const wallTool: Tool = {
},
};
// ── Ceiling ────────────────────────────────────────────────────────────────
// Zeichnet einen GESCHLOSSENEN Umriss (wie eine geschlossene Polylinie) und
// committet daraus eine `Ceiling`. Der reguläre Zeichenpfad läuft über den
// gekoppelten `ceiling`-Befehl (TOOL_COMMAND); dieses Legacy-Tool ist eine
// vollständige, funktionsfähige Alternative (kein Stub).
interface CeilDrawing {
phase: "drawing";
points: Vec2[];
cursor: Vec2 | null;
}
type CeilState = { phase: "idle" } | CeilDrawing;
function ceilDraft(points: Vec2[], cursor: Vec2 | null): ToolDraft {
const all = cursor ? [...points, cursor] : points;
const preview: DraftShape[] = [{ kind: "poly", pts: all, closed: all.length >= 3 }];
const draft: ToolDraft = { preview, vertices: points };
if (cursor && points.length > 0) {
const a = points[points.length - 1];
if (segLen(a, cursor) >= EPS) {
draft.hud = {
at: cursor,
text: `${segLen(a, cursor).toFixed(2)} m · ${Math.abs(
(segAngleDeg(a, cursor) + 360) % 360,
).toFixed(0)}°`,
};
}
}
return draft;
}
function appendCeiling(project: Project, pts: Vec2[], ctx: ToolContext): Project {
const outline = normalizeOutline(pts);
if (!outline || ceilingArea(outline) < 1e-4) return project;
const hasCat = project.layers.some((c) => c.code === "30");
const c: Ceiling = {
id: uniqueId("C"),
type: "ceiling",
floorId: ctx.level.id,
categoryCode: hasCat ? "30" : ctx.defaultCategoryCode,
outline,
wallTypeId: ctx.activeWallTypeId,
};
return { ...project, ceilings: project.ceilings ? [...project.ceilings, c] : [c] };
}
const ceilingTool: Tool = {
id: "ceiling",
labelKey: "tool.ceiling",
floorOnly: true,
hintKey: (s) =>
(s as CeilState).phase === "drawing"
? "tool.ceiling.nextPoint"
: "tool.ceiling.firstPoint",
init: () => ({ phase: "idle" }),
onClick: (state, p, ctx) => {
const s = state as CeilState;
if (s.phase !== "drawing") {
return [{ phase: "drawing", points: [p.point], cursor: p.point }, { draft: ceilDraft([p.point], p.point) }];
}
// Klick auf den Startpunkt schließt den Umriss (ab 3 Punkten) und committet.
if (s.points.length >= 3 && segLen(p.point, s.points[0]) < EPS) {
const pts = s.points;
return [{ phase: "idle" }, { draft: null, done: true, commit: (proj) => appendCeiling(proj, pts, ctx) }];
}
const points = [...s.points, p.point];
return [{ phase: "drawing", points, cursor: p.point }, { draft: ceilDraft(points, p.point) }];
},
onMove: (state, p) => {
const s = state as CeilState;
if (s.phase !== "drawing") return [s, { draft: { preview: [], vertices: [] } }];
return [{ ...s, cursor: p.point }, { draft: ceilDraft(s.points, p.point) }];
},
onCommitGesture: (state, ctx) => {
const s = state as CeilState;
if (s.phase !== "drawing" || s.points.length < 3) {
return [{ phase: "idle" }, { draft: null, done: true }];
}
const pts = s.points;
return [{ phase: "idle" }, { draft: null, done: true, commit: (proj) => appendCeiling(proj, pts, ctx) }];
},
onCancel: () => [{ phase: "idle" }, { draft: null, done: true }],
onUndoPoint: (state) => {
const s = state as CeilState;
if (s.phase === "drawing" && s.points.length > 1) {
const points = s.points.slice(0, -1);
return [{ phase: "drawing", points, cursor: s.cursor }, { draft: ceilDraft(points, s.cursor) }];
}
return [{ phase: "idle" }, { draft: null, done: true }];
},
};
// ── Window / Door (Öffnungen) ─────────────────────────────────────────────────
// Beide sind an den gekoppelten Befehl `fenster`/`tuer` (TOOL_COMMAND) gebunden:
// der interaktive Ablauf (Host-Wand picken → Position → committen) läuft komplett
// in der CommandEngine. Diese Tool-Objekte sind reine Platzhalter (wie `select`);
// der Controller ruft sie nicht auf, sobald der gekoppelte Befehl läuft.
const windowTool: Tool = {
id: "window",
labelKey: "tool.window",
floorOnly: true,
hintKey: () => "tool.window.hint",
init: () => ({ phase: "idle" }),
onClick: (s) => [s, { draft: null }],
onMove: (s) => [s, { draft: null }],
onCommitGesture: (s) => [s, { draft: null, done: true }],
onCancel: (s) => [s, { draft: null, done: true }],
};
const doorTool: Tool = {
id: "door",
labelKey: "tool.door",
floorOnly: true,
hintKey: () => "tool.door.hint",
init: () => ({ phase: "idle" }),
onClick: (s) => [s, { draft: null }],
onMove: (s) => [s, { draft: null }],
onCommitGesture: (s) => [s, { draft: null, done: true }],
onCancel: (s) => [s, { draft: null, done: true }],
};
// ── Stair (Treppe) ───────────────────────────────────────────────────────────
// An den gekoppelten Befehl `stair` (TOOL_COMMAND) gebunden: der interaktive
// Ablauf (Startpunkt → Laufrichtung/Ende → committen) läuft komplett in der
// CommandEngine. Dieses Tool-Objekt ist ein reiner Platzhalter (wie window/door);
// der Controller ruft es nicht auf, sobald der gekoppelte Befehl läuft.
const stairTool: Tool = {
id: "stair",
labelKey: "tool.stair",
floorOnly: true,
hintKey: () => "tool.stair.hint",
init: () => ({ phase: "idle" }),
onClick: (s) => [s, { draft: null }],
onMove: (s) => [s, { draft: null }],
onCommitGesture: (s) => [s, { draft: null, done: true }],
onCancel: (s) => [s, { draft: null, done: true }],
};
// ── Room (Raum) ──────────────────────────────────────────────────────────────
// An den gekoppelten Befehl `room` (TOOL_COMMAND) gebunden: der interaktive
// Ablauf (Klick-innen zur Erkennung ODER manueller Umriss) läuft komplett in der
// CommandEngine. Dieses Tool-Objekt ist ein reiner Platzhalter (wie stair/window);
// der Controller ruft es nicht auf, sobald der gekoppelte Befehl läuft.
const roomTool: Tool = {
id: "room",
labelKey: "tool.room",
floorOnly: true,
hintKey: () => "tool.room.hint",
init: () => ({ phase: "idle" }),
onClick: (s) => [s, { draft: null }],
onMove: (s) => [s, { draft: null }],
onCommitGesture: (s) => [s, { draft: null, done: true }],
onCancel: (s) => [s, { draft: null, done: true }],
};
// ── Line ─────────────────────────────────────────────────────────────────────
// Zwei-Klick-Strecke → Drawing2D{shape:"line"}.
@@ -375,6 +531,11 @@ const rectTool: Tool = {
const TOOLS: Record<ToolId, Tool> = {
select: selectTool,
wall: wallTool,
ceiling: ceilingTool,
window: windowTool,
door: doorTool,
stair: stairTool,
room: roomTool,
line: lineTool,
polyline: polylineTool,
rect: rectTool,
@@ -386,4 +547,15 @@ export function getTool(id: ToolId): Tool {
}
/** Reihenfolge der Werkzeuge in der Werkzeugleiste. */
export const TOOL_ORDER: ToolId[] = ["select", "wall", "line", "polyline", "rect"];
export const TOOL_ORDER: ToolId[] = [
"select",
"wall",
"ceiling",
"window",
"door",
"stair",
"room",
"line",
"polyline",
"rect",
];
+11 -1
View File
@@ -8,7 +8,17 @@
import type { DrawingLevel, Project, Vec2 } from "../model/types";
/** Werkzeug-Identität. */
export type ToolId = "select" | "wall" | "line" | "polyline" | "rect";
export type ToolId =
| "select"
| "wall"
| "ceiling"
| "window"
| "door"
| "stair"
| "room"
| "line"
| "polyline"
| "rect";
// ── Snapping ───────────────────────────────────────────────────────────────
+51 -13
View File
@@ -13,14 +13,20 @@ export interface CommandLineOption {
id: string;
labelKey: string;
value?: string;
/** Aktuell gewählte Option (hervorgehoben)? */
active?: boolean;
/** Tastatur-Kürzel (z. B. „U"), wirkt AUCH bei fokussiertem Eingabefeld. */
key?: string;
}
/** Ein Zahlenfeld des Tab-Feld-Zyklus (§2.7). */
export interface CommandLineField {
id: string;
labelKey: string;
/** Gelockter Wert (oder null = folgt der Maus). */
/** Gelockter ODER live (maus-folgender) Wert; null = (noch) keiner. */
value: number | null;
/** Ist der Wert gelockt (getippt) statt live (folgt der Maus)? */
locked: boolean;
/** Aktuelles Tab-Ziel? */
active: boolean;
}
@@ -73,6 +79,25 @@ export const CommandLine = forwardRef<CommandLineHandle, CommandLineProps>(
};
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
// Options-Kürzel (z. B. U/I/O/P der Kopiermodi): auch bei fokussiertem Feld
// aktiv. Ein einzelner Buchstabe, der einer Option zugeordnet ist, löst die
// Option aus statt in die Zahleneingabe zu fallen (Ziffern bleiben Eingabe).
if (
active &&
e.key.length === 1 &&
!e.ctrlKey &&
!e.metaKey &&
!e.altKey
) {
const opt = options.find(
(o) => o.key && o.key.toLowerCase() === e.key.toLowerCase(),
);
if (opt) {
e.preventDefault();
onOption(opt.id);
return;
}
}
// Tasten NICHT zum globalen App-Handler durchreichen (eigener Eingabefokus).
if (e.key === "Enter") {
e.preventDefault();
@@ -130,12 +155,13 @@ export const CommandLine = forwardRef<CommandLineHandle, CommandLineProps>(
<button
key={o.id}
type="button"
className="cmdline-option"
className={`cmdline-option${o.active ? " active" : ""}`}
onMouseDown={(e) => {
e.preventDefault();
onOption(o.id);
}}
>
{o.key && <span className="cmdline-option-key">{o.key}</span>}
{t(o.labelKey as TranslationKey)}
{o.value != null && <span className="cmdline-option-val">={o.value}</span>}
</button>
@@ -144,18 +170,30 @@ export const CommandLine = forwardRef<CommandLineHandle, CommandLineProps>(
)}
{hasFields && (
<span className="cmdline-fields">
{fields.map((f) => (
<span
key={f.id}
className={`cmdline-field${f.active ? " active" : ""}`}
title={t(f.labelKey as TranslationKey)}
>
<span className="cmdline-field-label">{t(f.labelKey as TranslationKey)}</span>
<span className="cmdline-field-val">
{f.value == null ? "—" : Number(f.value.toFixed(3)).toString()}
{fields.map((f) => {
// Winkelfeld in Grad, sonst Längen in Metern. Gelockte Werte fest
// (mehr Nachkommastellen), live (maus-folgende) Werte gerundet.
const isAngle = f.id === "angle";
const digits = isAngle ? (f.locked ? 1 : 0) : f.locked ? 3 : 2;
const unit = isAngle ? "°" : " m";
const text =
f.value == null
? "—"
: `${Number(f.value.toFixed(digits)).toString()}${unit}`;
return (
<span
key={f.id}
className={
`cmdline-field${f.active ? " active" : ""}` +
(f.locked ? " locked" : f.value != null ? " live" : "")
}
title={t(f.labelKey as TranslationKey)}
>
<span className="cmdline-field-label">{t(f.labelKey as TranslationKey)}</span>
<span className="cmdline-field-val">{text}</span>
</span>
</span>
))}
);
})}
</span>
)}
<input
+306
View File
@@ -0,0 +1,306 @@
// Standort-Import-Dialog (modal). Sucht einen Ort/Adresse (geo.admin
// SearchServer), lässt einen Radius wählen und importiert Gebäude-Grundrisse
// (swisstopo) sowie OSM-Features (Gebäude/Strassen/Wasser/Grün) als Kontext-
// Geometrie ins Projekt.
//
// Muster wie ExportPdfDialog/ImportDialog: abgedunkeltes Overlay, Klick auf den
// Hintergrund + Esc schließen. Der eigentliche Netz-Abruf läuft hier (io/*); das
// Ergebnis wird über `onImport(objs)` nach oben gereicht (SitePanel legt es via
// Host in `project.context` ab). Bezeichner englisch, UI-Text/Kommentare deutsch
// (CONVENTIONS.md).
import { useEffect, useRef, useState } from "react";
import { t } from "../i18n";
import { geocode, fetchBuildings } from "../io/swissTopo";
import type { GeocodeCandidate } from "../io/swissTopo";
import { fetchOsm } from "../io/osm";
import type { OsmSelection } from "../io/osm";
import { featuresToContextObjects } from "../io/geoContext";
import type { GeoFeature } from "../io/geoContext";
import type { GeoOrigin } from "../io/lv95";
import type { ContextObject } from "../model/types";
export interface ContextImportDialogProps {
/** Übernimmt die fertigen Kontext-Objekte (nach erfolgreichem Import). */
onImport: (objs: ContextObject[]) => void;
onClose: () => void;
}
type Sources = {
swissBuildings: boolean;
osmBuildings: boolean;
osmRoads: boolean;
osmWater: boolean;
osmGreen: boolean;
};
const DEFAULT_SOURCES: Sources = {
swissBuildings: true,
osmBuildings: false,
osmRoads: true,
osmWater: true,
osmGreen: true,
};
export function ContextImportDialog({
onImport,
onClose,
}: ContextImportDialogProps) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const [query, setQuery] = useState("");
const [candidates, setCandidates] = useState<GeocodeCandidate[]>([]);
const [selected, setSelected] = useState<GeocodeCandidate | null>(null);
const [radius, setRadius] = useState(200);
const [sources, setSources] = useState<Sources>(DEFAULT_SOURCES);
const [searching, setSearching] = useState(false);
const [importing, setImporting] = useState(false);
const [status, setStatus] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const searchSeq = useRef(0);
const runSearch = async () => {
const q = query.trim();
if (!q) return;
setError(null);
setSearching(true);
const seq = ++searchSeq.current;
try {
const res = await geocode(q, 8);
if (seq !== searchSeq.current) return; // veraltet
setCandidates(res);
setSelected(res[0] ?? null);
if (res.length === 0) setStatus(t("ctxImport.noResults"));
else setStatus(null);
} catch {
if (seq === searchSeq.current) setError(t("ctxImport.searchError"));
} finally {
if (seq === searchSeq.current) setSearching(false);
}
};
const toggle = (key: keyof Sources) =>
setSources((s) => ({ ...s, [key]: !s[key] }));
const anySource =
sources.swissBuildings ||
sources.osmBuildings ||
sources.osmRoads ||
sources.osmWater ||
sources.osmGreen;
const runImport = async () => {
if (!selected || !anySource) return;
setError(null);
setImporting(true);
setStatus(t("ctxImport.working"));
try {
const center = selected.lv95;
let origin: GeoOrigin | undefined;
const all: GeoFeature[] = [];
if (sources.swissBuildings) {
const r = await fetchBuildings(center, radius, origin);
origin = r.origin;
all.push(...r.features);
}
const osmSel: OsmSelection = {
buildings: sources.osmBuildings,
roads: sources.osmRoads,
water: sources.osmWater,
green: sources.osmGreen,
};
if (
osmSel.buildings ||
osmSel.roads ||
osmSel.water ||
osmSel.green
) {
const r = await fetchOsm(center, radius, osmSel, origin);
origin = r.origin;
all.push(...r.features);
}
if (all.length === 0) {
setError(t("ctxImport.empty"));
setStatus(null);
return;
}
const objs = featuresToContextObjects(all, selected.label);
onImport(objs);
setStatus(
t("ctxImport.imported", { count: all.length, sets: objs.length }),
);
onClose();
} catch {
setError(t("ctxImport.importError"));
setStatus(null);
} finally {
setImporting(false);
}
};
return (
<div className="imp-overlay" onClick={onClose}>
<div
className="imp-dialog"
role="dialog"
aria-modal="true"
aria-label={t("ctxImport.title")}
onClick={(e) => e.stopPropagation()}
>
<header className="imp-head">
<span className="imp-head-title">{t("ctxImport.title")}</span>
<button
className="imp-close"
onClick={onClose}
aria-label={t("ctxImport.close")}
>
×
</button>
</header>
<div className="imp-body">
{/* Ortssuche */}
<section className="imp-section">
<div className="imp-section-title">{t("ctxImport.location")}</div>
<div className="cim-search-row">
<input
className="cim-input"
type="text"
value={query}
placeholder={t("ctxImport.searchPlaceholder")}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void runSearch();
}
}}
aria-label={t("ctxImport.location")}
/>
<button
className="imp-btn"
onClick={() => void runSearch()}
disabled={searching || !query.trim()}
>
{searching ? t("ctxImport.searching") : t("ctxImport.search")}
</button>
</div>
{candidates.length > 0 && (
<ul className="cim-results">
{candidates.map((c, i) => (
<li
key={`${c.label}-${i}`}
className={
"cim-result" + (c === selected ? " selected" : "")
}
onClick={() => setSelected(c)}
title={c.label}
>
{c.label}
</li>
))}
</ul>
)}
</section>
{/* Radius */}
<section className="imp-section">
<div className="imp-section-title">{t("ctxImport.radius")}</div>
<div className="cim-radius-row">
<input
className="cim-input cim-input-num"
type="number"
min={20}
max={2000}
step={10}
value={radius}
onChange={(e) =>
setRadius(
Math.max(20, Math.min(2000, Number(e.target.value) || 0)),
)
}
aria-label={t("ctxImport.radius")}
/>
<span className="cim-unit">m</span>
</div>
</section>
{/* Quellen */}
<section className="imp-section">
<div className="imp-section-title">{t("ctxImport.sources")}</div>
<div className="cim-checks">
<label className="cim-check">
<input
type="checkbox"
checked={sources.swissBuildings}
onChange={() => toggle("swissBuildings")}
/>
{t("ctxImport.src.swissBuildings")}
</label>
<label className="cim-check">
<input
type="checkbox"
checked={sources.osmBuildings}
onChange={() => toggle("osmBuildings")}
/>
{t("ctxImport.src.osmBuildings")}
</label>
<label className="cim-check">
<input
type="checkbox"
checked={sources.osmRoads}
onChange={() => toggle("osmRoads")}
/>
{t("ctxImport.src.osmRoads")}
</label>
<label className="cim-check">
<input
type="checkbox"
checked={sources.osmWater}
onChange={() => toggle("osmWater")}
/>
{t("ctxImport.src.osmWater")}
</label>
<label className="cim-check">
<input
type="checkbox"
checked={sources.osmGreen}
onChange={() => toggle("osmGreen")}
/>
{t("ctxImport.src.osmGreen")}
</label>
</div>
</section>
{status && <div className="cim-status">{status}</div>}
{error && <div className="imp-warn">{error}</div>}
</div>
<footer className="imp-foot">
<button className="imp-btn" onClick={onClose}>
{t("ctxImport.cancel")}
</button>
<button
className="imp-btn primary"
onClick={() => void runImport()}
disabled={importing || !selected || !anySource}
>
{importing ? t("ctxImport.importing") : t("ctxImport.confirm")}
</button>
</footer>
</div>
</div>
);
}
+1 -1
View File
@@ -14,7 +14,7 @@
// bzw. den festen `placeholder`.
//
// Reine Darstellung + Callbacks; voll gesteuert. Identifier englisch, UI-Text
// kommt als Props (i18n) herein (CLAUDE.md).
// kommt als Props (i18n) herein (CONVENTIONS.md).
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
+89
View File
@@ -0,0 +1,89 @@
// DXF-Export-Dialog (modal). Fragt „Schraffuren mitexportieren?" ab und weist auf
// die Meter-Einheit hin (DXF ist Modell-Space in echten Metern, 1:1). Muster wie
// ExportPdfDialog: abgedunkeltes Overlay, Klick auf den Hintergrund + Esc schliessen.
//
// Rein gesteuert: hält nur Formular-Zustand und meldet die Entscheidung über
// `onExport` nach oben (App führt den Export aus, da es den Plan erzeugt).
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import { useEffect, useState } from "react";
import { t } from "../i18n";
import { Dropdown } from "./Dropdown";
/** Entscheidung, die der Dialog beim Bestätigen liefert. */
export interface ExportDxfDecision {
includeHatches: boolean;
}
export interface ExportDxfDialogProps {
/** Titel/Geschossname (nur Anzeige im Dialogkopf). */
planTitle: string;
onExport: (decision: ExportDxfDecision) => void;
onClose: () => void;
}
export function ExportDxfDialog({ planTitle, onExport, onClose }: ExportDxfDialogProps) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const [includeHatches, setIncludeHatches] = useState(true);
const onConfirm = () => onExport({ includeHatches });
return (
<div className="imp-overlay" onClick={onClose}>
<div
className="imp-dialog"
role="dialog"
aria-modal="true"
aria-label={t("exportDxf.title")}
onClick={(e) => e.stopPropagation()}
>
<header className="imp-head">
<span className="imp-head-title">{t("exportDxf.title")}</span>
<button className="imp-close" onClick={onClose} aria-label={t("export.close")}>
×
</button>
</header>
<div className="imp-body">
<div className="imp-file">
<span className="imp-file-label">{t("export.plan")}</span>
<span className="imp-file-name" title={planTitle}>
{planTitle}
</span>
</div>
<section className="imp-section">
<div className="imp-section-title">{t("exportDxf.hatches")}</div>
<Dropdown
value={includeHatches ? "yes" : "no"}
onChange={(v) => setIncludeHatches(v === "yes")}
title={t("exportDxf.hatches")}
options={[
{ value: "yes", label: t("exportDxf.hatches.yes") },
{ value: "no", label: t("exportDxf.hatches.no") },
]}
/>
</section>
<div className="imp-note">{t("exportDxf.unitsNote")}</div>
</div>
<footer className="imp-foot">
<button className="imp-btn" onClick={onClose}>
{t("export.cancel")}
</button>
<button className="imp-btn primary" onClick={onConfirm}>
{t("export.confirm")}
</button>
</footer>
</div>
</div>
);
}
+156
View File
@@ -0,0 +1,156 @@
// PDF-Export-Dialog (modal). Fragt Massstab (1:N), Papierformat (A4/A3) und
// Ausrichtung (Hoch/Quer) ab und löst den Vektor-PDF-Export aus. Muster wie
// ImportDialog: abgedunkeltes Overlay, Klick auf den Hintergrund + Esc schließen.
// Themedes Dropdown für die Auswahlfelder.
//
// Rein gesteuert: hält nur Formular-Zustand und meldet die Entscheidung über
// `onExport` nach oben (App führt den eigentlichen Export aus, da es den Plan
// erzeugt). Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import { useEffect, useState } from "react";
import { t } from "../i18n";
import { Dropdown } from "./Dropdown";
import { SCALE_PRESETS } from "./TopBar";
import type { Orientation, PaperFormat } from "../export/exportPdf";
import { pageSizeMm } from "../export/exportPdf";
/** Entscheidung, die der Dialog beim Bestätigen liefert. */
export interface ExportPdfDecision {
scaleDenominator: number;
paper: PaperFormat;
orientation: Orientation;
}
export interface ExportPdfDialogProps {
/** Vorbelegung des Massstabs (aktueller Plan-Massstab). */
initialScale: number;
/** Titel/Geschossname (nur Anzeige im Dialogkopf). */
planTitle: string;
/**
* Massstäbliche Inhalts-Maße des Plans in Metern (Breite/Höhe), für die
* passt aufs Blatt?"-Warnung. Optional.
*/
contentMeters?: { w: number; h: number };
onExport: (decision: ExportPdfDecision) => void;
onClose: () => void;
}
export function ExportPdfDialog({
initialScale,
planTitle,
contentMeters,
onExport,
onClose,
}: ExportPdfDialogProps) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const [scale, setScale] = useState<number>(initialScale);
const [paper, setPaper] = useState<PaperFormat>("a4");
const [orientation, setOrientation] = useState<Orientation>("landscape");
// Passt der Plan im gewählten Massstab aufs Blatt (abzüglich Rand)? Best-effort-
// Hinweis; der Export läuft trotzdem (Plan ragt dann über den Blattrand).
const fits = (() => {
if (!contentMeters) return true;
const { w: pageW, h: pageH } = pageSizeMm(paper, orientation);
const margin = 12; // Rand + etwas Luft fürs Schriftfeld
const k = 1000 / scale; // mm je Meter
return (
contentMeters.w * k <= pageW - margin * 2 &&
contentMeters.h * k <= pageH - margin * 2
);
})();
const onConfirm = () => onExport({ scaleDenominator: scale, paper, orientation });
const scaleOptions = SCALE_PRESETS.map((n) => ({
value: String(n),
label: `1:${n}`,
}));
return (
<div className="imp-overlay" onClick={onClose}>
<div
className="imp-dialog"
role="dialog"
aria-modal="true"
aria-label={t("export.title")}
onClick={(e) => e.stopPropagation()}
>
<header className="imp-head">
<span className="imp-head-title">{t("export.title")}</span>
<button
className="imp-close"
onClick={onClose}
aria-label={t("export.close")}
>
×
</button>
</header>
<div className="imp-body">
<div className="imp-file">
<span className="imp-file-label">{t("export.plan")}</span>
<span className="imp-file-name" title={planTitle}>
{planTitle}
</span>
</div>
<section className="imp-section">
<div className="imp-section-title">{t("export.scale")}</div>
<Dropdown
value={String(scale)}
onChange={(v) => setScale(Number(v))}
title={t("export.scale")}
triggerClassName="tb-dd-mono"
options={scaleOptions}
/>
</section>
<section className="imp-section">
<div className="imp-section-title">{t("export.paper")}</div>
<Dropdown
value={paper}
onChange={(v) => setPaper(v as PaperFormat)}
title={t("export.paper")}
options={[
{ value: "a4", label: "A4" },
{ value: "a3", label: "A3" },
]}
/>
</section>
<section className="imp-section">
<div className="imp-section-title">{t("export.orientation")}</div>
<Dropdown
value={orientation}
onChange={(v) => setOrientation(v as Orientation)}
title={t("export.orientation")}
options={[
{ value: "portrait", label: t("export.orientation.portrait") },
{ value: "landscape", label: t("export.orientation.landscape") },
]}
/>
</section>
{!fits && <div className="imp-warn">{t("export.warnFit")}</div>}
</div>
<footer className="imp-foot">
<button className="imp-btn" onClick={onClose}>
{t("export.cancel")}
</button>
<button className="imp-btn primary" onClick={onConfirm}>
{t("export.confirm")}
</button>
</footer>
</div>
</div>
);
}
+30 -3
View File
@@ -11,14 +11,14 @@
// eigentlichen Store-Mutationen (Ebene anlegen, Drawings anhängen, Kontext)
// führt App in der richtigen Reihenfolge aus.
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CLAUDE.md). Alle sichtbaren
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md). Alle sichtbaren
// Texte über t(...).
import { useEffect, useState } from "react";
import { t } from "../i18n";
import type { TranslationKey } from "../i18n";
import type { DrawingLevelKind, ImportedMesh, Project } from "../model/types";
import type { DxfImportResult } from "../io/dxfParser";
import type { DxfImportResult, ImportDiagnostics } from "../io/dxfParser";
import {
contoursToDrawings,
countContours,
@@ -52,6 +52,21 @@ function kindBadgeKey(kind: DrawingLevelKind): TranslationKey {
}
}
/**
* Formatiert das Entity-Typ-Histogramm der Import-Diagnose zu einer kompakten
* Daten-Zeile, z. B. 120 Entities INSERT×80, ARC×30, LINE×10". Die Entity-
* Typ-Codes sind technische DWG-Bezeichner (keine übersetzbare UI-Prosa); nur
* die Gesamtzahl ist eine reine Zahl. Bei 0 Entities zeigt sie 0 Entities"
* das deutet auf ein Encoding-/Versions-Problem (nicht auf eine Abdeckungslücke).
*/
function formatDiagnostics(d: ImportDiagnostics): string {
const parts = Object.entries(d.entityCounts)
.sort((a, b) => b[1] - a[1])
.map(([type, n]) => `${type}×${n}`);
const head = `${d.total} Entities`;
return parts.length > 0 ? `${head}${parts.join(", ")}` : head;
}
export interface ImportDialogProps {
fileName: string;
/**
@@ -179,7 +194,19 @@ export function ImportDialog({
</li>
</ul>
{!hasDrawings && !hasMeshes && (
<div className="imp-warn">{t("import.summary.empty")}</div>
<div className="imp-warn">
{t("import.summary.empty")}
{/* Diagnose-Histogramm (vom DWG-Parser): macht sichtbar,
WORAN es liegt 0 Entities (Encoding/Version) vs.
Abdeckungslücke (z. B. 500 ARC, 200 INSERT"). Entity-Typ-
Codes + Zahlen sind technische DWG-Bezeichner (Daten,
keine übersetzbare Prosa). */}
{parsed?.diagnostics && (
<div className="imp-diag">
{formatDiagnostics(parsed.diagnostics)}
</div>
)}
</div>
)}
</section>
+475 -3
View File
@@ -16,9 +16,10 @@
// Rendering noch Persistenz. Bezeichner englisch, UI-Text deutsch
// (CONVENTIONS.md).
import { useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import type {
Component,
ComponentMaterial,
HatchPattern,
HatchStyle,
LineStyle,
@@ -27,6 +28,15 @@ import type {
import { PEN_WEIGHTS } from "../model/types";
import { parseLin } from "../io/linParser";
import { parsePat } from "../io/patParser";
import { MATERIAL_LIBRARY } from "../materials/library";
import { materialFromAsset, DEFAULT_TILE_SIZE_M } from "../materials/runtime";
import {
searchMaterials,
fetchMaterialMaps,
AMBIENT_CATEGORIES,
type AmbientMaterial,
type AmbientResolution,
} from "../materials/ambientcg";
import { EyeIcon } from "./EyeIcon";
import { t } from "../i18n";
@@ -269,7 +279,7 @@ function ResCell({
// ── Bauteile (Components) ──────────────────────────────────────────────────
// Spalten: Name | Farbe | Schraffur | Prio | Textur | (löschen).
// Spalten: Name | Farbe | Schraffur | Prio | Textur | Material | (löschen).
const COMPONENT_COLUMNS: ResColumn[] = [
{ titleKey: "resources.col.name" },
{ titleKey: "resources.col.color" },
@@ -280,10 +290,465 @@ const COMPONENT_COLUMNS: ResColumn[] = [
hintKey: "resources.col.prio.hint",
},
{ titleKey: "resources.col.texture" },
{ titleKey: "resources.col.material" },
{ titleKey: "" },
];
const COMPONENT_TEMPLATE =
"minmax(120px, 1fr) auto minmax(120px, 0.8fr) 64px minmax(100px, 0.8fr) 36px";
"minmax(120px, 1fr) auto minmax(120px, 0.8fr) 64px minmax(90px, 0.7fr) minmax(130px, 0.9fr) 36px";
/**
* Material-Zelle der Bauteil-Tabelle: ein Button, der den aktuellen Material-
* Namen zeigt (oder ohne") und ein Auswahl-Modal öffnet. Die Zuweisung läuft
* über `onAssign`/`onClear` (immutabel via onPatchComponent).
*/
function MaterialCell({
material,
onAssign,
onClear,
}: {
material: ComponentMaterial | undefined;
onAssign: (m: ComponentMaterial) => void;
onClear: () => void;
}) {
const [open, setOpen] = useState(false);
const label = materialLabel(material);
return (
<>
<button
className="res-select res-material-btn"
onClick={() => setOpen(true)}
title={t("material.assign")}
>
{material?.color && (
<span
className="res-material-swatch"
style={{ backgroundImage: `url(${material.color})` }}
/>
)}
<span className="res-material-name">{label}</span>
</button>
{open && (
<MaterialPicker
material={material}
onAssign={(m) => {
onAssign(m);
}}
onClear={() => {
onClear();
}}
onClose={() => setOpen(false)}
/>
)}
</>
);
}
/** Anzeigename eines zugewiesenen Materials (Bibliothek → Name, sonst Upload). */
function materialLabel(m: ComponentMaterial | undefined): string {
if (!m) return t("material.none");
if (m.libraryId) {
const asset = MATERIAL_LIBRARY.find((a) => a.id === m.libraryId);
if (asset) return asset.name;
}
return t("material.uploaded");
}
/**
* Material-Auswahl-Modal: oben die eingebaute Bibliothek als Vorschau-Grid
* (Farb-Karte als Thumbnail) Klick weist sofort zu; darunter ein Upload-
* Bereich (Farbe/Normal/Rauheit/Höhe/AO als Bild-Dateien ObjectURL) plus die
* physische Kachelgröße. Zuweisen" übernimmt den Upload; „Material entfernen"
* setzt das Bauteil auf kein Material zurück.
*/
function MaterialPicker({
material,
onAssign,
onClear,
onClose,
}: {
material: ComponentMaterial | undefined;
onAssign: (m: ComponentMaterial) => void;
onClear: () => void;
onClose: () => void;
}) {
// Upload-Zustand: gewählte Karten als ObjectURLs + Kachelgröße. Startwert aus
// einem bereits zugewiesenen Upload-Material (damit Größe/Karten sichtbar sind).
const [upload, setUpload] = useState<ComponentMaterial>(() =>
material && !material.libraryId ? { ...material } : { sizeM: DEFAULT_TILE_SIZE_M },
);
const [sizeM, setSizeM] = useState<number>(material?.sizeM ?? DEFAULT_TILE_SIZE_M);
// Modus des Pickers: Schnellauswahl (lokaler Starter, offline), Live-Browse
// der kompletten ambientCG-Bibliothek, oder Upload eigener Karten.
const [mode, setMode] = useState<"starter" | "browse" | "upload">("starter");
const pickFile = (kind: keyof ComponentMaterial) => (file: File | null) => {
if (!file) return;
const url = URL.createObjectURL(file);
setUpload((u) => ({ ...u, [kind]: url }));
};
const uploadFields: { kind: keyof ComponentMaterial; labelKey: string }[] = [
{ kind: "color", labelKey: "material.upload.color" },
{ kind: "normal", labelKey: "material.upload.normal" },
{ kind: "roughness", labelKey: "material.upload.roughness" },
{ kind: "displacement", labelKey: "material.upload.displacement" },
{ kind: "ao", labelKey: "material.upload.ao" },
];
const applyUpload = () => {
if (!upload.color && !upload.normal && !upload.roughness && !upload.ao && !upload.displacement) {
return;
}
onAssign({ ...upload, libraryId: undefined, sizeM });
onClose();
};
return (
<div className="res-overlay mat-overlay" onClick={onClose}>
<div
className="mat-dialog"
role="dialog"
aria-label={t("material.dialog.title")}
onClick={(e) => e.stopPropagation()}
>
<header className="mat-head">
<span className="mat-title">{t("material.dialog.title")}</span>
<button className="res-close" onClick={onClose} aria-label={t("material.close")}>
×
</button>
</header>
<div className="mat-hint">{t("material.dialog.hint")}</div>
{/* Modus-Umschalter: Schnellauswahl / Bibliothek durchsuchen / Upload. */}
<nav className="mat-modes" role="tablist">
<button
role="tab"
aria-selected={mode === "starter"}
className={`mat-mode-btn${mode === "starter" ? " active" : ""}`}
onClick={() => setMode("starter")}
>
{t("material.browse.starter")}
</button>
<button
role="tab"
aria-selected={mode === "browse"}
className={`mat-mode-btn${mode === "browse" ? " active" : ""}`}
onClick={() => setMode("browse")}
>
{t("material.browse")}
</button>
<button
role="tab"
aria-selected={mode === "upload"}
className={`mat-mode-btn${mode === "upload" ? " active" : ""}`}
onClick={() => setMode("upload")}
>
{t("material.upload")}
</button>
</nav>
{/* Schnellauswahl: lokaler 12er-Starter (offline verfügbar). */}
{mode === "starter" && (
<div className="mat-grid">
{MATERIAL_LIBRARY.map((asset) => (
<button
key={asset.id}
className={`mat-tile${material?.libraryId === asset.id ? " active" : ""}`}
title={asset.name}
onClick={() => {
onAssign(materialFromAsset(asset, sizeM));
onClose();
}}
>
<span
className="mat-thumb"
style={{ backgroundImage: `url(${asset.maps.color})` }}
/>
<span className="mat-tile-name">{asset.name}</span>
</button>
))}
</div>
)}
{/* Live-Browse der kompletten ambientCG-Bibliothek. */}
{mode === "browse" && (
<AmbientBrowser
sizeM={sizeM}
activeId={material?.libraryId}
onAssign={(m) => {
onAssign(m);
onClose();
}}
/>
)}
{/* Upload eigener Karten. */}
{mode === "upload" && (
<>
<div className="mat-hint">{t("material.upload.hint")}</div>
<div className="mat-upload-grid">
{uploadFields.map((f) => (
<label key={f.kind} className="mat-upload-row">
<span className="mat-upload-label">{t(f.labelKey)}</span>
<ImagePickButton onFile={pickFile(f.kind)} />
{upload[f.kind] && <span className="mat-upload-ok"></span>}
</label>
))}
<label className="mat-upload-row">
<span className="mat-upload-label">{t("material.size")}</span>
<input
type="number"
className="res-input mono num"
value={sizeM}
step={0.1}
min={0.05}
onChange={(e) => {
const v = Number(e.target.value);
if (!Number.isNaN(v) && v > 0) setSizeM(v);
}}
/>
</label>
</div>
<div className="mat-actions">
<button className="res-add" onClick={applyUpload}>
{t("material.upload.apply")}
</button>
</div>
</>
)}
{/* Kachelgröße (m) für Schnellauswahl + Browse, plus Material entfernen. */}
{mode !== "upload" && (
<div className="mat-actions mat-actions-browse">
<label className="mat-size-inline">
<span className="mat-upload-label">{t("material.size")}</span>
<input
type="number"
className="res-input mono num"
value={sizeM}
step={0.1}
min={0.05}
onChange={(e) => {
const v = Number(e.target.value);
if (!Number.isNaN(v) && v > 0) setSizeM(v);
}}
/>
</label>
<button
className="res-add mat-danger"
onClick={() => {
onClear();
onClose();
}}
>
{t("material.clear")}
</button>
</div>
)}
</div>
</div>
);
}
/**
* Live-Browse der kompletten ambientCG-Bibliothek: Suchfeld + Kategorie-Filter
* + Auflösungswahl über der Trefferliste, Thumbnail-Grid (aus der API, direkt
* geladen CORS `*`) mit Pagination (mehr laden"). Ein Klick lädt die Textur-
* Karten des Materials herunter (`fetchMaterialMaps`, über den Proxy), entpackt
* sie und weist das entstehende `ComponentMaterial` zu. Lade-/Fehlerzustände
* sauber (Spinner, Offline-/CORS-Hinweis).
*/
function AmbientBrowser({
sizeM,
activeId,
onAssign,
}: {
sizeM: number;
activeId: string | undefined;
onAssign: (m: ComponentMaterial) => void;
}) {
const [query, setQuery] = useState("");
const [committedQuery, setCommittedQuery] = useState("");
const [category, setCategory] = useState("");
const [resolution, setResolution] = useState<AmbientResolution>("1K");
const [items, setItems] = useState<AmbientMaterial[]>([]);
const [total, setTotal] = useState(0);
const [offset, setOffset] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// ID des gerade herunterladenden Materials (Spinner auf der Kachel).
const [downloadingId, setDownloadingId] = useState<string | null>(null);
const LIMIT = 24;
// Suche (bei committedQuery/category-Wechsel neu laden — Seite 0).
useEffect(() => {
const ctrl = new AbortController();
setLoading(true);
setError(null);
searchMaterials({
query: committedQuery,
category,
limit: LIMIT,
offset: 0,
signal: ctrl.signal,
})
.then((res) => {
setItems(res.items);
setTotal(res.total);
setOffset(0);
})
.catch((e) => {
if (ctrl.signal.aborted) return;
setError(t("material.browse.error"));
setItems([]);
setTotal(0);
console.error("[ambientcg] search failed:", e);
})
.finally(() => {
if (!ctrl.signal.aborted) setLoading(false);
});
return () => ctrl.abort();
}, [committedQuery, category]);
const loadMore = () => {
const nextOffset = offset + LIMIT;
setLoading(true);
searchMaterials({ query: committedQuery, category, limit: LIMIT, offset: nextOffset })
.then((res) => {
setItems((prev) => [...prev, ...res.items]);
setTotal(res.total);
setOffset(nextOffset);
})
.catch((e) => {
setError(t("material.browse.error"));
console.error("[ambientcg] load more failed:", e);
})
.finally(() => setLoading(false));
};
const pick = async (id: string) => {
setDownloadingId(id);
setError(null);
try {
const { material } = await fetchMaterialMaps(id, resolution, sizeM);
onAssign(material);
} catch (e) {
setError(t("material.browse.downloadError"));
console.error("[ambientcg] download failed:", e);
} finally {
setDownloadingId(null);
}
};
const submitSearch = () => setCommittedQuery(query);
return (
<div className="mat-browse">
<div className="mat-hint">{t("material.browse.hint")}</div>
<div className="mat-browse-bar">
<input
type="text"
className="res-input mat-search"
value={query}
placeholder={t("material.browse.searchPlaceholder")}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") submitSearch();
}}
/>
<button className="res-add" onClick={submitSearch}>
{t("material.browse.search")}
</button>
<select
className="res-select"
value={category}
onChange={(e) => setCategory(e.target.value)}
>
<option value="">{t("material.browse.category.all")}</option>
{AMBIENT_CATEGORIES.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
<select
className="res-select"
value={resolution}
title={t("material.browse.resolution")}
onChange={(e) => setResolution(e.target.value as AmbientResolution)}
>
<option value="1K">1K</option>
<option value="2K">2K</option>
<option value="4K">4K</option>
</select>
</div>
{error && <div className="mat-browse-error">{error}</div>}
<div className="mat-grid mat-browse-grid">
{items.map((it) => (
<button
key={it.id}
className={`mat-tile${activeId === it.id ? " active" : ""}${
downloadingId === it.id ? " loading" : ""
}`}
title={`${it.name}${it.category ? `${it.category}` : ""}`}
disabled={downloadingId !== null}
onClick={() => pick(it.id)}
>
<span
className="mat-thumb"
style={{ backgroundImage: it.thumbUrl ? `url(${it.thumbUrl})` : undefined }}
/>
{downloadingId === it.id && <span className="mat-tile-spinner" />}
<span className="mat-tile-name">{it.name}</span>
</button>
))}
</div>
{!loading && items.length === 0 && !error && (
<div className="res-empty">{t("material.browse.empty")}</div>
)}
<div className="mat-browse-foot">
{loading && <span className="mat-browse-status">{t("material.browse.loading")}</span>}
{!loading && items.length > 0 && (
<span className="mat-browse-status">
{t("material.browse.count", { shown: items.length, total })}
</span>
)}
{!loading && items.length < total && (
<button className="res-add" onClick={loadMore}>
{t("material.browse.more")}
</button>
)}
</div>
</div>
);
}
/** Versteckter Bild-Datei-Picker mit sichtbarem Button. */
function ImagePickButton({ onFile }: { onFile: (file: File | null) => void }) {
const ref = useRef<HTMLInputElement | null>(null);
return (
<>
<button className="res-add mat-pick" onClick={() => ref.current?.click()}>
{t("material.upload.choose")}
</button>
<input
ref={ref}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={(e) => {
onFile(e.target.files?.[0] ?? null);
e.target.value = "";
}}
/>
</>
);
}
function ComponentRow({
component,
@@ -335,6 +800,13 @@ function ComponentRow({
mono
/>
</ResCell>
<ResCell>
<MaterialCell
material={component.material}
onAssign={(material) => onPatch({ material })}
onClear={() => onPatch({ material: undefined })}
/>
</ResCell>
<ResCell>
<DeleteButton
onClick={onDelete}
+74
View File
@@ -0,0 +1,74 @@
// Schwebendes Dialog-Fenster zum Bearbeiten eines Rich-Text-Dokuments (Raum-
// Stempel / Textobjekt). Doppelklick auf einen Stempel öffnet es (nicht den
// Footer). Der Body ist der bestehende RichTextEditor (mit eigener Mini-Toolbar,
// hier ok, weil eigenes Fenster). „Übernehmen" schreibt das bearbeitete Dokument
// zurück, „Abbrechen" verwirft. Bezeichner englisch, UI-Text deutsch.
import { useState } from "react";
import { t } from "../i18n";
import { RichTextEditor } from "../text/RichTextEditor";
import type { RichTextDoc } from "../text/richText";
export interface TextEditorDialogProps {
/** Ausgangsdokument (wird beim Öffnen kopiert; Editor arbeitet lokal). */
value: RichTextDoc;
/** „Übernehmen" — das bearbeitete Dokument zurückschreiben. */
onApply: (doc: RichTextDoc) => void;
/** „Abbrechen"/Schliessen — ohne Übernahme. */
onCancel: () => void;
}
export function TextEditorDialog({ value, onApply, onCancel }: TextEditorDialogProps) {
// Lokaler Arbeitszustand — erst „Übernehmen" schreibt zurück (echtes
// Abbrechen möglich). Start = übergebenes Dokument.
const [draft, setDraft] = useState<RichTextDoc>(value);
return (
<div
className="texteditor-overlay"
role="dialog"
aria-modal="true"
aria-label={t("text.editTitle")}
onMouseDown={(e) => {
if (e.target === e.currentTarget) onCancel();
}}
onKeyDown={(e) => {
if (e.key === "Escape") onCancel();
}}
>
<div className="texteditor-dialog">
<div className="texteditor-head">
<span className="texteditor-title">{t("text.editTitle")}</span>
<button
type="button"
className="texteditor-x"
onClick={onCancel}
title={t("text.cancel")}
aria-label={t("text.cancel")}
>
<span className="material-symbols-outlined" aria-hidden="true">
close
</span>
</button>
</div>
<div className="texteditor-body">
<RichTextEditor value={draft} onChange={setDraft} autoFocus />
</div>
<div className="texteditor-foot">
<button type="button" className="texteditor-btn" onClick={onCancel}>
{t("text.cancel")}
</button>
<button
type="button"
className="texteditor-btn texteditor-btn-primary"
onClick={() => onApply(draft)}
>
{t("text.apply")}
</button>
</div>
</div>
</div>
);
}
export default TextEditorDialog;
+566 -70
View File
@@ -14,6 +14,21 @@ import { t } from "../i18n";
import { useEffect, useRef, useState } from "react";
import { Dropdown } from "./Dropdown";
import type { DropdownItem, DropdownOption } from "./Dropdown";
import {
applyMark,
applyPreset,
commonMarkValue,
DEFAULT_PRESETS,
docLength,
isMarkActive,
toggleMark,
} from "../text/richText";
import type {
Align,
BoolMark,
RichTextDoc,
TextRange,
} from "../text/richText";
/** Ansichtstyp eines Geschosses (wie in App). */
export type ViewType = "grundriss" | "perspektive";
@@ -27,9 +42,16 @@ export type DetailLevel = "grob" | "mittel" | "fein";
/**
* Render-/Anzeigemodus der 3D-Ansicht: schattiert, einheitlich weiss (Clay-Look),
* Drahtgitter, Verdeckte Kanten (Hidden-Line). Wirkt nur in der Perspektive.
* texturiert (echte PBR-Materialien je Bauteil), Drahtgitter, Verdeckte Kanten
* (Hidden-Line). Wirkt nur in der Perspektive. Der Slot section" (Schnitt-
* schraffur, Welle B) ist bewusst freigelassen.
*/
export type RenderMode = "shaded" | "white" | "wireframe" | "hidden";
export type RenderMode =
| "shaded"
| "white"
| "textured"
| "wireframe"
| "hidden";
/**
* Kanonischer Blickwinkel der 3D-Ansicht (Vectorworks/DOSSIER-Stil). front",
@@ -97,6 +119,10 @@ export interface TopBarProps {
onFit: () => void;
onFitSelection: () => void;
onZoom100: () => void;
/** Öffnet den PDF-Export-Dialog (nur im Grundriss sinnvoll). */
onExportPdf: () => void;
/** Öffnet den DXF-Export-Dialog (nur im Grundriss sinnvoll). */
onExportDxf: () => void;
// Darstellungsart — kontextabhängig: Perspektive nutzt `renderMode`,
// Grundriss nutzt `planColorMode`. Genau einer der beiden ist je View aktiv.
@@ -131,6 +157,30 @@ export interface TopBarProps {
/** Layout-Menü (von App geliefert, damit dessen Logik dort bleibt). */
layoutMenu: ReactNode;
// ── Text-Gruppe (Stempel-/Text-Formatierung) ───────────────────────────────
/**
* Ziel der Text-Formatierung: `null` = nichts Text-artiges selektiert (die
* Controls setzen nur Defaults für neuen Text). Sonst das aktuell selektierte
* Rich-Text-Dokument (z. B. Raumstempel) samt optionalem Bereich und einem
* Setter. Ist `range` `null`, wirken Aktionen auf das GANZE Doc.
*/
textTarget: TextTarget | null;
/**
* Startet das Text-Werkzeug (neues Textobjekt platzieren). `null`, solange das
* Werkzeug noch nicht existiert der + Text"-Knopf ist dann deaktiviert.
*/
onAddText: (() => void) | null;
}
/**
* Beschreibt das aktuell formatierbare Rich-Text-Ziel der Oberleiste. `apply`
* schreibt das neue Dokument zurück in den App-State (z. B. via setRoomStampDoc).
*/
export interface TextTarget {
doc: RichTextDoc;
range: TextRange | null;
apply: (doc: RichTextDoc) => void;
}
/**
@@ -155,6 +205,19 @@ function Sep() {
return <span className="tb-sep" aria-hidden="true" />;
}
/**
* Material-Symbols-Icon für die Oberleiste (gleiche Icon-Font wie ContextMenu/
* MenuIcon). Rein dekorativ der sprechende Text steckt im `title`/aria-label
* des umgebenden Buttons.
*/
function TbIcon({ name }: { name: string }) {
return (
<span className="material-symbols-outlined tb-ico" aria-hidden="true">
{name}
</span>
);
}
/**
* Kleines Kombinations-Menü (Vectorworks-Stil) gespiegelt von `LayoutMenu`:
* aktuelle Sichtbarkeit unter einem Namen sichern, eine gespeicherte Kombination
@@ -392,6 +455,376 @@ function ViewIcon({ name }: { name: View3d | "camera" | "grundriss" }) {
}
}
/** Eine Zelle einer Segmentpille. */
interface SegmentCell {
/** Stabiler Schlüssel. */
key: string;
/** Material-Symbols-Icon-Name. */
icon: string;
/** Tooltip/aria-label (Klartext). */
title: string;
/** Aktiver Zustand (Akzentfüllung). */
active?: boolean;
onClick: () => void;
disabled?: boolean;
}
/**
* Segmentpille (DOSSIER-Look): ein Aussencontainer (radius 999, overflow hidden)
* mit gleich hohen Zellen; interne Trenner über `border-left`. Aktive Zelle in
* Akzentfarbe. Genutzt für Zoom-Aktionen, B/I/U und L/C/R. `accent` setzt einen
* Akzent-Rand am Container (Auswahl-Bewusstsein der Text-Gruppe).
*/
function Segment({
cells,
ariaLabel,
accent,
style,
}: {
cells: SegmentCell[];
ariaLabel: string;
accent?: boolean;
style?: React.CSSProperties;
}) {
return (
<div
className={`tb-seg${accent ? " tb-seg-accent" : ""}`}
role="group"
aria-label={ariaLabel}
style={style}
>
{cells.map((c) => (
<button
key={c.key}
type="button"
className={`tb-seg-cell${c.active ? " active" : ""}`}
onClick={c.onClick}
disabled={c.disabled}
aria-pressed={c.active}
title={c.title}
aria-label={c.title}
>
<span className="material-symbols-outlined tb-ico" aria-hidden="true">
{c.icon}
</span>
</button>
))}
</div>
);
}
/** Systemfont-Auswahl der Text-Gruppe. */
const TEXT_FONTS: string[] = [
"Helvetica",
"Arial",
"Inter",
"Times New Roman",
"Georgia",
"Courier New",
];
/** Grössen-Presets (pt) der Text-Gruppe. */
const TEXT_SIZES: number[] = [8, 9, 10, 11, 12, 14, 18, 24, 36, 48];
/**
* Text-Gruppe der Oberleiste (immer sichtbar, 3×2-Raster). Setzt Defaults für
* neuen Text (lokaler State) UND formatiert das aktuelle `textTarget` live. Ist
* `textTarget` gesetzt, tragen die Zeile-2-Pillen einen Akzent-Rand und alle
* Aktionen wirken auf `textTarget.doc` via `textTarget.apply`.
*/
function TextGroup({
textTarget,
onAddText,
}: {
textTarget: TextTarget | null;
onAddText: (() => void) | null;
}) {
// Defaults für neuen Text, solange nichts Text-artiges selektiert ist. Bei
// aktivem Ziel spiegeln die Controls die gemeinsamen Werte des Ziels wider.
const [defFont, setDefFont] = useState<string>("Helvetica");
const [defSize, setDefSize] = useState<number>(12);
const [defAlign, setDefAlign] = useState<Align>("left");
const [defBold, setDefBold] = useState(false);
const [defItalic, setDefItalic] = useState(false);
const [defUnderline, setDefUnderline] = useState(false);
// Effektiver Bereich am Ziel: expliziter Bereich oder — ohne Bereich — das
// ganze Dokument (mind. 1 Zeichen, damit isMarkActive/applyMark greifen).
const targetRange = (doc: RichTextDoc, range: TextRange | null): TextRange =>
range && range.start !== range.end
? range
: { start: 0, end: Math.max(1, docLength(doc)) };
const active = textTarget !== null;
// Aktueller Anzeige-Zustand: vom Ziel abgeleitet, sonst aus den Defaults.
const rng = textTarget ? targetRange(textTarget.doc, textTarget.range) : null;
const isBold = textTarget && rng ? isMarkActive(textTarget.doc, rng, "bold") : defBold;
const isItalic = textTarget && rng ? isMarkActive(textTarget.doc, rng, "italic") : defItalic;
const isUnderline =
textTarget && rng ? isMarkActive(textTarget.doc, rng, "underline") : defUnderline;
const curFont =
textTarget && rng ? commonMarkValue(textTarget.doc, rng, "font") ?? "" : defFont;
const curSize =
textTarget && rng ? commonMarkValue(textTarget.doc, rng, "sizePt") ?? 0 : defSize;
// Ausrichtung: gemeinsamer Wert der berührten Absätze, sonst Default.
const curAlign: Align = textTarget
? commonAlign(textTarget.doc, rng) ?? "left"
: defAlign;
// ── Aktions-Helfer (wirken auf das Ziel oder setzen Defaults) ──────────────
const toggle = (mark: BoolMark, setDefault: (v: boolean) => void, cur: boolean) => {
if (textTarget) {
const r = targetRange(textTarget.doc, textTarget.range);
textTarget.apply(toggleMark(textTarget.doc, r, mark));
} else {
setDefault(!cur);
}
};
const setFontValue = (font: string) => {
if (textTarget) {
const r = targetRange(textTarget.doc, textTarget.range);
textTarget.apply(applyMark(textTarget.doc, r, "font", font));
} else {
setDefFont(font);
}
};
const setSizeValue = (size: number) => {
if (!Number.isFinite(size) || size <= 0) return;
if (textTarget) {
const r = targetRange(textTarget.doc, textTarget.range);
textTarget.apply(applyMark(textTarget.doc, r, "sizePt", size));
} else {
setDefSize(size);
}
};
const setAlignValue = (align: Align) => {
if (textTarget) {
textTarget.apply(applyAlign(textTarget.doc, textTarget.range, align));
} else {
setDefAlign(align);
}
};
const applyPresetValue = (id: string) => {
const preset = DEFAULT_PRESETS.find((p) => p.id === id);
if (!preset) return;
if (textTarget) {
const r = textTarget.range ?? { start: 0, end: 0 };
textTarget.apply(applyPreset(textTarget.doc, r, preset));
} else {
// Ohne Ziel: Preset-Defaults übernehmen (Font bleibt, Marks spiegeln).
if (preset.marks.sizePt != null) setDefSize(preset.marks.sizePt);
setDefBold(!!preset.marks.bold);
setDefItalic(!!preset.marks.italic);
}
};
// ── Options-Listen ─────────────────────────────────────────────────────────
const styleOptions: DropdownOption[] = [
{ value: "__none__", label: t("text.style.none") },
...DEFAULT_PRESETS.map((p) => ({ value: p.id, label: p.label })),
];
const fontOptions: DropdownOption[] = TEXT_FONTS.map((f) => ({
value: f,
label: f,
}));
const sizeInPresets = TEXT_SIZES.includes(curSize);
const sizeOptions: DropdownOption[] = [
...(sizeInPresets || curSize <= 0
? []
: [{ value: "__current__", label: `${curSize} pt` }]),
...TEXT_SIZES.map((n) => ({ value: String(n), label: `${n} pt` })),
{ value: "__custom__", label: t("text.size.custom") },
];
const onSizeChange = (value: string) => {
if (value === "__custom__") {
const input = window.prompt(
t("text.size.customPrompt"),
String(curSize > 0 ? curSize : 12),
);
const n = input == null ? NaN : Math.round(Number(input.trim()));
if (Number.isFinite(n) && n > 0) setSizeValue(n);
return;
}
if (value === "__current__") return;
setSizeValue(Number(value));
};
return (
<div className="tb-group tb-textgroup" role="group" aria-label={t("text.group")}>
{/* Zeile 1: Stil-Preset · Schrift · Grösse. */}
<div className="tb-textgroup-row">
<Dropdown
value="__none__"
onChange={applyPresetValue}
title={t("text.style")}
width={110}
options={styleOptions}
/>
<Dropdown
value={curFont || "__none__"}
onChange={setFontValue}
title={t("text.font")}
width={130}
options={
curFont && !TEXT_FONTS.includes(curFont)
? [{ value: curFont, label: curFont }, ...fontOptions]
: fontOptions
}
/>
<Dropdown
value={
curSize <= 0
? "__custom__"
: sizeInPresets
? String(curSize)
: "__current__"
}
onChange={onSizeChange}
title={t("text.size")}
width={80}
triggerClassName="tb-dd-mono"
options={sizeOptions}
/>
</div>
{/* Zeile 2: B/I/U · L/C/R · „+ Text". */}
<div className="tb-textgroup-row">
<Segment
ariaLabel={t("text.group")}
accent={active}
style={{ width: 110 }}
cells={[
{
key: "bold",
icon: "format_bold",
title: t("text.bold"),
active: !!isBold,
onClick: () => toggle("bold", setDefBold, defBold),
},
{
key: "italic",
icon: "format_italic",
title: t("text.italic"),
active: !!isItalic,
onClick: () => toggle("italic", setDefItalic, defItalic),
},
{
key: "underline",
icon: "format_underlined",
title: t("text.underline"),
active: !!isUnderline,
onClick: () => toggle("underline", setDefUnderline, defUnderline),
},
]}
/>
<Segment
ariaLabel={t("text.group")}
accent={active}
style={{ width: 130 }}
cells={[
{
key: "left",
icon: "format_align_left",
title: t("text.align.left"),
active: curAlign === "left",
onClick: () => setAlignValue("left"),
},
{
key: "center",
icon: "format_align_center",
title: t("text.align.center"),
active: curAlign === "center",
onClick: () => setAlignValue("center"),
},
{
key: "right",
icon: "format_align_right",
title: t("text.align.right"),
active: curAlign === "right",
onClick: () => setAlignValue("right"),
},
]}
/>
<button
type="button"
className="tb-addtext"
disabled={onAddText == null}
onClick={() => onAddText?.()}
title={onAddText ? t("text.add") : t("text.add.hint")}
aria-label={t("text.add")}
>
<span className="material-symbols-outlined tb-ico" aria-hidden="true">
add
</span>
<span className="tb-addtext-label">{t("text.add")}</span>
</button>
</div>
</div>
);
}
/**
* Gemeinsame Absatz-Ausrichtung der vom Bereich berührten Absätze; `undefined`
* bei uneinheitlicher/leerer Auswahl. Ohne Bereich Ausrichtung des ersten
* Absatzes (das ganze Doc als Ziel).
*/
function commonAlign(doc: RichTextDoc, range: TextRange | null): Align | undefined {
const paras = touchedParagraphs(doc, range);
if (paras.length === 0) return doc.paragraphs[0]?.align ?? "left";
let value: Align | undefined;
let first = true;
for (const idx of paras) {
const a = doc.paragraphs[idx]?.align ?? "left";
if (first) {
value = a;
first = false;
} else if (a !== value) {
return undefined;
}
}
return value;
}
/**
* Setzt die Absatz-Ausrichtung auf alle vom Bereich berührten Absätze. Ohne
* Bereich (null) auf ALLE Absätze des Dokuments.
*/
function applyAlign(doc: RichTextDoc, range: TextRange | null, align: Align): RichTextDoc {
const touched = new Set(touchedParagraphs(doc, range));
const all = range == null;
return {
version: 1,
paragraphs: doc.paragraphs.map((p, i) =>
all || touched.has(i) ? { ...p, align } : p,
),
};
}
/** Indizes der Absätze, die der Bereich [start,end) berührt. */
function touchedParagraphs(doc: RichTextDoc, range: TextRange | null): number[] {
if (range == null || range.start === range.end) {
return doc.paragraphs.map((_, i) => i);
}
const start = Math.min(range.start, range.end);
const end = Math.max(range.start, range.end);
const out: number[] = [];
let cursor = 0;
doc.paragraphs.forEach((p, i) => {
const len = p.runs.reduce((n, r) => n + r.text.length, 0);
const pStart = cursor;
const pEnd = cursor + len;
if (!(pEnd < start || pStart > end)) out.push(i);
cursor += len + 1;
});
return out;
}
export function TopBar({
project,
viewToggleEnabled,
@@ -412,6 +845,8 @@ export function TopBar({
onFit,
onFitSelection,
onZoom100,
onExportPdf,
onExportDxf,
renderMode,
onRenderMode,
renderModeEnabled,
@@ -427,6 +862,8 @@ export function TopBar({
resourcesOpen,
onToggleResources,
layoutMenu,
textTarget,
onAddText,
}: TopBarProps) {
// Dezente Scrollleiste: die Oberleiste scrollt horizontal (overflow-x). Statt
// der prägnanten Standard-Leiste blenden wir eine eigene, sehr ruhige Leiste
@@ -578,8 +1015,10 @@ export function TopBar({
{/* Detailgrad (grob/mittel/fein) wirkt im Grundriss auf Symbole +
Schraffuren/Linien. In der Perspektive (noch) ohne Wirkung dort
deaktiviert mit Tooltip statt stiller No-Op. */}
<div className="tb-group" role="group" aria-label={t("topbar.detailGroup")}>
<label className="tb-field">
{/* Detailgrad + Massstab gestapelt eine Spalte, platzsparend (wie die
Ebenen-/Zeichnungs-Kombis). */}
<div className="tb-group tb-stack" role="group" aria-label={t("topbar.detailGroup")}>
<label className="tb-field tb-field-row">
<span className="tb-label">{t("topbar.detail")}</span>
<Dropdown
value={detail}
@@ -597,16 +1036,7 @@ export function TopBar({
]}
/>
</label>
</div>
<Sep />
{/* Massstab 1:N" (echter Papier-Massstab) + Zoom-Buttons. Das Dropdown
zeigt den gewählten/angewandten Massstab; rechts steht der LIVE 1:N
aus der View-Transform (ändert sich beim Zoomen). Nur aktiv, wenn ein
Plan gezeichnet wird (Perspektive zeigt "). */}
<div className="tb-group" role="group" aria-label={t("topbar.scaleGroup")}>
<label className="tb-field">
<label className="tb-field tb-field-row">
<span className="tb-label">{t("topbar.scale")}</span>
<Dropdown
value={inPresets ? String(scaleDenominator) : "__current__"}
@@ -617,42 +1047,103 @@ export function TopBar({
options={scaleOptions}
/>
</label>
<span className="tb-live-scale" title={t("topbar.scale.live")}>
{zoomEnabled && liveScale != null ? `1:${Math.round(liveScale)}` : "—"}
</span>
<div className="tb-btns">
<button
className="tb-btn"
onClick={onZoom100}
disabled={!zoomEnabled}
title={t("topbar.zoom100.hint", { n: scaleDenominator })}
>
{t("topbar.zoom100")}
</button>
<button
className="tb-btn"
onClick={onFit}
disabled={!zoomEnabled}
title={t("topbar.fit.hint")}
>
{t("topbar.fit")}
</button>
<button
className="tb-btn"
onClick={onFitSelection}
disabled={!zoomEnabled}
title={t("topbar.fitSelection.hint")}
>
{t("topbar.fitSelection")}
</button>
</div>
<span className="tb-zoom" title={t("topbar.zoom.current")}>
{zoomEnabled ? `${Math.round(zoomPercent)}%` : "—"}
</span>
</div>
<Sep />
{/* Massstab/Zoom-Cluster (DOSSIER, 2×2). Links über beide Zeilen die
kombinierte Stat-Pille (Live-Massstab 1:N oben / Zoom% unten); rechts
oben das Massstab-Dropdown, rechts unten die Zoom-Segmentpille. am
Massstab" (Live-Massstab == gewählter Massstab) hebt die Stat-Pille im
Akzent hervor. */}
<div className="tb-group tb-zoomcluster" role="group" aria-label={t("topbar.scaleGroup")}>
<div
className={`tb-stat${
zoomEnabled && liveScale != null && Math.round(liveScale) === scaleDenominator
? " tb-stat-atscale"
: ""
}`}
title={`${t("topbar.scale.live")} · ${t("topbar.zoom.current")}`}
>
<span className="tb-stat-scale">
{zoomEnabled && liveScale != null ? `1:${Math.round(liveScale)}` : "—"}
</span>
<span className="tb-stat-zoom">
{zoomEnabled ? `${Math.round(zoomPercent)}%` : "—"}
</span>
</div>
<div className="tb-zoomcluster-right">
<Dropdown
value={inPresets ? String(scaleDenominator) : "__current__"}
disabled={!zoomEnabled}
onChange={onScaleChange}
title={t("topbar.scale.apply")}
triggerClassName="tb-dd-mono"
width={140}
options={scaleOptions}
/>
<Segment
ariaLabel={t("topbar.scaleGroup")}
cells={[
{
key: "zoom100",
icon: "straighten",
title: t("topbar.zoom100.hint", { n: scaleDenominator }),
onClick: onZoom100,
disabled: !zoomEnabled,
},
{
key: "fit",
icon: "fit_screen",
title: t("topbar.fit.hint"),
onClick: onFit,
disabled: !zoomEnabled,
},
{
key: "fitSelection",
icon: "center_focus_strong",
title: t("topbar.fitSelection.hint"),
onClick: onFitSelection,
disabled: !zoomEnabled,
},
]}
/>
</div>
</div>
{/* Export als eigene kleine Pillen-Reihe (nicht mehr Teil des Zoom-Blocks,
damit der Cluster ruhig bleibt). */}
<div className="tb-group tb-btns" role="group" aria-label={t("topbar.exportPdf")}>
<button
type="button"
className="tb-iconbtn"
onClick={onExportPdf}
disabled={!zoomEnabled}
title={t("topbar.exportPdf.hint")}
aria-label={t("topbar.exportPdf")}
>
<TbIcon name="picture_as_pdf" />
</button>
<button
type="button"
className="tb-iconbtn"
onClick={onExportDxf}
disabled={!zoomEnabled}
title={t("topbar.exportDxf.hint")}
aria-label={t("topbar.exportDxf")}
>
<TbIcon name="download" />
</button>
</div>
<Sep />
{/* Text-Gruppe (immer sichtbar) Stil/Schrift/Grösse + B/I/U · L/C/R +
+ Text". Formatiert das aktuelle Text-Ziel (Raumstempel) live. */}
<TextGroup textTarget={textTarget} onAddText={onAddText} />
<Sep />
{/* Darstellungsart (Vectorworks-Stil) EIN Dropdown, dessen Optionen vom
Ansichtstyp abhängen: Grundriss Farbig/Schwarz-Weiss (planColorMode);
Perspektive Schattiert/Weiss/Drahtgitter/Kanten (renderMode). Der
@@ -669,6 +1160,7 @@ export function TopBar({
options={[
{ value: "shaded", label: t("display.style.shaded") },
{ value: "white", label: t("display.style.white") },
{ value: "textured", label: t("display.style.textured") },
{ value: "wireframe", label: t("display.style.wireframe") },
{ value: "hidden", label: t("display.style.hidden") },
]}
@@ -693,54 +1185,58 @@ export function TopBar({
Achslinien im Grundriss. Nur im Grundriss sinnvoll sonst deaktiviert. */}
<div className="tb-group" role="group" aria-label={t("topbar.referenceLinesGroup")}>
<button
className={`tb-toggle${referenceLines ? " active" : ""}`}
className={`view-icon${referenceLines ? " active" : ""}`}
onClick={() => onReferenceLines(!referenceLines)}
disabled={!referenceLinesEnabled}
aria-pressed={referenceLines}
aria-label={t("topbar.referenceLines")}
title={
referenceLinesEnabled
? t("topbar.referenceLines.hint")
? `${t("topbar.referenceLines")}${t("topbar.referenceLines.hint")}`
: t("topbar.referenceLines.hintDisabled")
}
>
{t("topbar.referenceLines")}
<TbIcon name="straighten" />
</button>
</div>
<Sep />
{/* Linien-Darstellung: Display (alle Haarlinien) Print (echte mm-
Strichstärken). Nur im Grundriss sinnvoll. */}
Strichstärken). Eine Einfach-Auswahl als Dropdown (Trigger-Pill +
Häkchen-Liste, wie Detailgrad/Massstab), nicht mehr als Segment-Pillen.
Optionen tragen ein Icon plus Klartext. Nur im Grundriss sinnvoll. */}
<div className="tb-group" role="group" aria-label={t("topbar.lineModeGroup")}>
<div className="view-toggle">
<button
className={`view-btn${lineMode === "display" ? " active" : ""}`}
onClick={() => onLineMode("display")}
<label className="tb-field">
<span className="tb-label">{t("topbar.lineModeGroup")}</span>
<Dropdown
value={lineMode}
disabled={!lineModeEnabled}
title={t("topbar.lineMode.display.hint")}
>
{t("topbar.lineMode.display")}
</button>
<button
className={`view-btn${lineMode === "print" ? " active" : ""}`}
onClick={() => onLineMode("print")}
disabled={!lineModeEnabled}
title={t("topbar.lineMode.print.hint")}
>
{t("topbar.lineMode.print")}
</button>
</div>
onChange={(v) => onLineMode(v as "display" | "print")}
title={
lineModeEnabled
? `${t("topbar.lineMode.display.hint")} · ${t("topbar.lineMode.print.hint")}`
: t("topbar.lineModeGroup")
}
options={[
{ value: "display", label: t("topbar.lineMode.display") },
{ value: "print", label: t("topbar.lineMode.print") },
]}
/>
</label>
</div>
{/* Rechte Gruppe: Layout-Menü · Ressourcen · Projektname. */}
<div className="topbar-right">
{layoutMenu}
<button
className={`res-trigger${resourcesOpen ? " active" : ""}`}
className={`view-icon${resourcesOpen ? " active" : ""}`}
onClick={onToggleResources}
title={t("topbar.resources.hint")}
aria-pressed={resourcesOpen}
aria-label={t("topbar.resources")}
title={`${t("topbar.resources")}${t("topbar.resources.hint")}`}
>
{t("topbar.resources")}
<TbIcon name="widgets" />
</button>
<span className="project-name">{project.name}</span>
</div>
-65
View File
@@ -1,65 +0,0 @@
// Modusleiste der aktiven Transformation (Bewegen/Spiegeln/Drehen), im Stil von
// Vectorworks. Zeigt die Operation, die Vervielfältigungs-Modi U/I/O/P und einen
// kontextuellen Klick-Hinweis. Tasten U/I/O/P schalten die Modi (in App); ein
// Klick auf die Schaltflächen tut dasselbe. Rein gesteuert (controlled).
import { t } from "../i18n";
import type { CopyMode, TransformOp } from "../tools/transform";
const MODES: { key: string; mode: CopyMode; labelKey: string }[] = [
{ key: "U", mode: "move", labelKey: "transform.mode.move" },
{ key: "I", mode: "copy", labelKey: "transform.mode.copy" },
{ key: "O", mode: "array", labelKey: "transform.mode.array" },
{ key: "P", mode: "distribute", labelKey: "transform.mode.distribute" },
];
/** Kontextueller Hinweis je Operation + Anzahl bereits gesetzter Punkte. */
function hintKey(op: TransformOp, pointsLen: number): string {
return `transform.hint.${op}.${Math.min(pointsLen, op === "rotate" ? 2 : 1)}`;
}
export function TransformBar({
op,
copyMode,
count,
pointsLen,
onMode,
onCancel,
}: {
op: TransformOp;
copyMode: CopyMode;
count: number;
pointsLen: number;
onMode: (mode: CopyMode) => void;
onCancel: () => void;
}) {
return (
<div className="transform-bar" role="group" aria-label={t("transform.bar")}>
<span className="tf-op">{t(`transform.${op}`)}</span>
<span className="tf-sep" />
<div className="tf-modes">
{MODES.map((m) => (
<button
key={m.mode}
className={`tf-mode${copyMode === m.mode ? " active" : ""}`}
onClick={() => onMode(m.mode)}
title={t(m.labelKey)}
>
<span className="tf-key">{m.key}</span>
<span className="tf-mode-label">
{t(m.labelKey)}
{(m.mode === "array" || m.mode === "distribute") && copyMode === m.mode
? ` ×${count}`
: ""}
</span>
</button>
))}
</div>
<span className="tf-sep" />
<span className="tf-hint">{t(hintKey(op, pointsLen))}</span>
<button className="tf-cancel" onClick={onCancel} title={t("transform.cancel")}>
</button>
</div>
);
}
File diff suppressed because it is too large Load Diff