Akkumulierten grünen Arbeitsstand landen (Basis für Weiterarbeit)

Bündelt den über mehrere Sessions gewachsenen, uncommitteten Stand in
einem Basis-Commit, damit Folge-Features isoliert darauf aufsetzen.
Verifikation: tsc --noEmit sauber, vitest 600/600 grün.

Enthalten (Details in PENDENZEN.md -Liste / HANDOVER.md):
- truck-Integration: Profil-Extrusion + Verjüngung + Boolean-CSG (csgrs),
  Crate src-tauri/trucksolid, Werkzeug `extrude`, ExtrudedSolid-Modell.
- kernel2d-Port nach Rust/WASM (Phasen 1–5, Diff-Harness).
- render3d 3D-Live-Schnitt = 2D-Schnitt: geschichteter Bodenaufbau,
  Prioritäts-Verschneidung (section_boolean.rs), einstellbare
  Schichttrennlinien, per-Hatch-Strichstärke, relativeToWall-Orientierung.
- Interop-Export IFC4/STL/OBJ (Loch-Ausschnitt wallMeshCut), Schnellexport.
- Projektdatei .obp + OS-Lock (lock.rs, LockConflictDialog).
- Layout-Blätter (Modell/Editor/Panel/PDF), Ausschnitte, Override-Engine,
  Tragwerk-Stützen (Column), BIM-Tree-Panel.
- Bauteil-Typsystem (Tür/Fenster/Treppe-Typen), Betontreppe mit schräger
  Laufplatte, Text-/Textbox-Werkzeug, Mess-Werkzeug, 2D/3D-Griffe für
  Öffnungen/Treppen, Snap-Symbol-Restyle.
This commit is contained in:
2026-07-09 00:57:29 +02:00
parent 889cbb2c12
commit 35299307d6
131 changed files with 26501 additions and 852 deletions
+1488 -70
View File
File diff suppressed because it is too large Load Diff
+152
View File
@@ -0,0 +1,152 @@
// Stütze (Column) als Engine-Befehl — DOSSIER-Audit A4 Tragwerk. Ablauf:
// 1) „Stütze setzen:" → Klick setzt den Einfügepunkt (Profil-Mitte) → commit.
//
// Optionen (togglebar, IMMER sichtbar): Profil Rechteck/Rund. Eine getippte Zahl
// setzt das Leitmass (Rechteck: Seitenlänge → quadratisch; Rund: Durchmesser)
// PERSISTENT über Aufrufe — analog `offset`/`extrude`, wo der pick-Schritt auch
// eine Zahl annimmt. Höhe = Geschosshöhe des aktiven Geschosses (Default).
//
// Herkunft der Stützen-Felder (CommandContext):
// • floorId ← ctx.level.id (aktives Geschoss)
// • categoryCode← "50" (Tragwerk; Fallback ctx.defaultCategoryCode)
// • height ← Geschosshöhe (ctx.level.floorHeight), Fallback DEF_HEIGHT
//
// Bezeichner englisch, sichtbarer Text via t() (CONVENTIONS.md).
import type { Column, ColumnProfile } from "../../model/types";
import { uniqueId } from "../../tools/types";
import { columnFootprint } from "../../geometry/column";
import type {
Command,
CommandContext,
CommandResult,
CommandState,
CmdOption,
Project,
Vec2,
} from "../types";
/** Default-Ebene (Kategorie) für Stützen: „50" Tragwerk (Fallback: aktive). */
const COLUMN_CATEGORY = "50";
/** Default-Kantenlänge eines Rechteckprofils (Meter). */
const DEF_SIZE = 0.3;
/** Fallback-Höhe, falls das Geschoss keine floorHeight trägt (Meter). */
const DEF_HEIGHT = 2.6;
// ── Persistente Defaults (modulweit, leben über Befehls-Instanzen hinweg) ────
const columnDefaults: { kind: "rect" | "round"; size: number } = {
kind: "rect",
size: DEF_SIZE,
};
/** Baut das aktuelle Default-Profil aus `columnDefaults`. */
function currentProfile(): ColumnProfile {
if (columnDefaults.kind === "round") {
return { kind: "round", radius: columnDefaults.size / 2 };
}
return { kind: "rect", width: columnDefaults.size, depth: columnDefaults.size };
}
/** Fügt die Stütze immutabel an `project.columns` an. */
function appendColumn(
p: Project,
position: Vec2,
ctx: CommandContext,
): Project {
const height =
ctx.level.floorHeight != null && ctx.level.floorHeight > 0
? ctx.level.floorHeight
: DEF_HEIGHT;
const column: Column = {
id: uniqueId("column"),
type: "column",
floorId: ctx.level.id,
categoryCode: COLUMN_CATEGORY,
position,
profile: currentProfile(),
rotation: 0,
height,
};
return { ...p, columns: [...(p.columns ?? []), column] };
}
interface ColState extends CommandState {
phase: "place" | "done";
}
const finish = (): [CommandState, CommandResult] => [
{ phase: "done", lastPoint: null } as ColState,
{ draft: null, done: true },
];
/** Footprint-Vorschau des aktuellen Default-Profils am Cursor. */
function previewAt(pt: Vec2): CommandResult {
const ghost: Column = {
id: "_preview",
type: "column",
floorId: "",
categoryCode: COLUMN_CATEGORY,
position: pt,
profile: currentProfile(),
rotation: 0,
height: 0,
};
const size =
columnDefaults.kind === "round"
? `Ø${(columnDefaults.size * 100).toFixed(0)}`
: `${(columnDefaults.size * 100).toFixed(0)}×${(columnDefaults.size * 100).toFixed(0)}`;
return {
draft: {
preview: [{ kind: "poly", pts: columnFootprint(ghost), closed: true }],
vertices: [],
hud: { at: pt, text: size },
},
};
}
export const columnCommand: Command = {
name: "column",
labelKey: "cmd.column.label",
prompt: () => "cmd.column.place",
accepts: () => ["point", "number", "option"],
options: (): CmdOption[] => [
{
id: "profile",
labelKey: "cmd.column.profile",
value: columnDefaults.kind === "round" ? "round" : "rect",
},
],
floorOnly: true,
init: (): ColState => ({ phase: "place", lastPoint: null }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as ColState;
if (s.phase === "done") return finish();
if (input.kind === "number") {
const v = Math.abs(input.value);
if (v > 1e-4) columnDefaults.size = v;
return [s, { draft: null }];
}
if (input.kind === "option" && input.id === "profile") {
columnDefaults.kind = columnDefaults.kind === "round" ? "rect" : "round";
return [s, { draft: null }];
}
if (input.kind === "point") {
return [
{ phase: "done", lastPoint: null } as ColState,
{ draft: null, done: true, commit: (p) => appendColumn(p, input.point, ctx) },
];
}
return [s, { draft: null }];
},
onMove: (state, point): [CommandState, CommandResult] => {
const s = state as ColState;
if (s.phase !== "place") return [s, { draft: null }];
return [s, previewAt(point)];
},
onConfirm: (): [CommandState, CommandResult] => finish(),
onCancel: (): [CommandState, CommandResult] => finish(),
};
+12 -2
View File
@@ -42,10 +42,20 @@ function targetFromFields(base: Vec2, locks: Record<string, number>, cursor: Vec
}
function hasSelection(sel: CommandSelection): boolean {
return sel.wallIds.length > 0 || sel.drawingId !== null;
return (
sel.wallIds.length > 0 ||
sel.drawingId !== null ||
!!sel.extrudedSolidId ||
!!sel.columnId
);
}
function toTransformSel(sel: CommandSelection): TransformSelection {
return { wallIds: sel.wallIds, drawingId: sel.drawingId };
return {
wallIds: sel.wallIds,
drawingId: sel.drawingId,
extrudedSolidId: sel.extrudedSolidId,
columnId: sel.columnId,
};
}
interface CopyBase extends CommandState {
+287
View File
@@ -0,0 +1,287 @@
// Extrudieren — truck-Integration Phase 3 (docs/design/truck-plan.md, PENDENZEN
// "truck-Integration"). Erzeugt aus einer geschlossenen 2D-Kurve (Polylinie-Ring,
// Rechteck oder Kreis) einen 3D-Körper: Schritte:
// 1) Profil bestimmen: ist genau EIN passendes geschlossenes Drawing2D
// selektiert, wird es genommen; sonst „Profil wählen:" → Klick auf einen Ring.
// 2) „Höhe:" → Zahl tippen (PERSISTENT als Default über Aufrufe) ODER Enter
// bestätigt den zuletzt genutzten Default.
// 3) „Verjüngung:" → Zahl 0..1 tippen (0=Prisma/Default, 1=Spitze — Kegel bei
// Kreis-, Pyramide bei Polygon-Profil) ODER Enter für den zuletzt
// genutzten Default (persistent, wie Höhe). → commit neues ExtrudedSolid.
//
// Die eigentliche Extrusion (truck-WASM) läuft asynchron erst in toWalls3d.ts
// (emitExtrudedSolids); dieser Befehl legt nur die Rohdaten (Punkte + Höhe +
// Verjüngung) im Projekt ab.
import type { Drawing2D, ExtrudedSolid } from "../../model/types";
import { uniqueId } from "../../tools/types";
import { polylineEdges, pointSegmentDistance } from "../../geometry/kernel2d";
import type {
Command,
CommandContext,
CommandResult,
CommandSelection,
CommandState,
Project,
Vec2,
} from "../types";
const HIT_TOL = 0.25; // Modell-Meter: Pick-Toleranz für die Profil-Wahl
/** Persistente Extrusionshöhe über Aufrufe hinweg (Default: Geschosshöhe). */
let lastHeight = 2.6;
/** Persistente Verjüngung über Aufrufe hinweg (Default: Prisma, kein Kegel/Pyramide). */
let lastTaper = 0;
// ── Profil-Abstraktion: geschlossene polyline/rect/circle → Punktliste ─────
/** Kreis-Tessellierung für Footprint/Auswahl/bbox (48-Eck, wie circle.ts). */
function circlePts(center: Vec2, r: number): Vec2[] {
const N = 48;
const pts: Vec2[] = [];
for (let i = 0; i < N; i++) {
const t = (i / N) * Math.PI * 2;
pts.push({ x: center.x + r * Math.cos(t), y: center.y + r * Math.sin(t) });
}
return pts;
}
/** Ein extrudierfähiges Profil: Tessellierung + optional die echte Kreis-Geometrie. */
interface Profile {
pts: Vec2[];
circle?: { center: Vec2; r: number };
}
/** Wandelt ein extrudierfähiges (geschlossenes) Drawing2D in ein Profil. */
function geomToProfile(d: Drawing2D): Profile | null {
const g = d.geom;
if (g.shape === "polyline" && g.closed && g.pts.length >= 3) return { pts: g.pts };
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 }] };
}
if (g.shape === "circle") {
return { pts: circlePts(g.center, g.r), circle: { center: g.center, r: g.r } };
}
return null;
}
/** Genau ein passendes Drawing2D vorselektiert? Dann liefere sein Profil. */
function singleSelectedProfile(
project: Project,
sel: CommandSelection,
): ({ d: Drawing2D } & Profile) | null {
if (!sel.drawingId || sel.wallIds.length > 0) return null;
const d = project.drawings2d.find((x) => x.id === sel.drawingId);
if (!d) return null;
const profile = geomToProfile(d);
return profile ? { d, ...profile } : null;
}
/** Nächstes extrudierfähige Drawing2D zum Klickpunkt (innerhalb Toleranz). */
function pickProfileAt(
project: Project,
levelId: string,
pt: Vec2,
): ({ d: Drawing2D } & Profile) | null {
let best: ({ d: Drawing2D } & Profile) | null = null;
let bestD = HIT_TOL;
for (const d of project.drawings2d) {
if (d.levelId !== levelId) continue;
const profile = geomToProfile(d);
if (!profile) continue;
for (const [a, b] of polylineEdges(profile.pts, true)) {
const dd = pointSegmentDistance(pt, a, b);
if (dd < bestD) {
bestD = dd;
best = { d, ...profile };
}
}
}
return best;
}
/**
* Hängt den neuen Körper an UND entfernt das Quell-Profil (`sourceId`) aus
* `drawings2d` — die Polylinie/das Rechteck WIRD zur Extrusion, sie bleibt
* nicht als unabhängiges Duplikat liegen (sonst zwei Objekte am selben Ort,
* eines davon im Grundriss unsichtbar verwaist).
*/
function appendExtrudedSolid(
p: Project,
profile: Profile,
height: number,
taper: number,
sourceId: string,
ctx: CommandContext,
): Project {
const solid: ExtrudedSolid = {
id: uniqueId("extrude"),
type: "extrudedSolid",
levelId: ctx.level.id,
points: profile.pts,
height,
circle: profile.circle,
...(taper > 0 ? { taper } : {}),
};
return {
...p,
drawings2d: p.drawings2d.filter((d) => d.id !== sourceId),
extrudedSolids: [...(p.extrudedSolids ?? []), solid],
};
}
// ── Zustand ───────────────────────────────────────────────────────────────────
interface ExPick extends CommandState {
phase: "pick";
}
interface ExHeight extends CommandState {
phase: "height";
profile: Profile;
/** Id des Quell-Drawing2D, das beim Commit ersetzt (entfernt) wird. */
sourceId: string;
cursor: Vec2 | null;
}
interface ExTaper extends CommandState {
phase: "taper";
profile: Profile;
sourceId: string;
height: number;
cursor: Vec2 | null;
}
interface ExDone extends CommandState {
phase: "done";
}
type ExState = ExPick | ExHeight | ExTaper | ExDone;
const finish = (): [CommandState, CommandResult] => [
{ phase: "done", lastPoint: null } as ExDone,
{ draft: null, done: true },
];
/** Höhe entgegengenommen → Verjüngungs-Schritt betreten (HUD zeigt Default). */
const enterTaperPhase = (
profile: Profile,
height: number,
sourceId: string,
): [CommandState, CommandResult] => [
{ phase: "taper", profile, sourceId, height, cursor: null } as ExTaper,
{ draft: null },
];
const commitTaper = (
profile: Profile,
height: number,
taper: number,
sourceId: string,
ctx: CommandContext,
): [CommandState, CommandResult] => [
{ phase: "done", lastPoint: null } as ExDone,
{ draft: null, done: true, commit: (p) => appendExtrudedSolid(p, profile, height, taper, sourceId, ctx) },
];
export const extrudeCommand: Command = {
name: "extrude",
labelKey: "cmd.extrude.label",
prompt: (s) => {
const phase = (s as ExState).phase;
if (phase === "height") return "cmd.extrude.height";
if (phase === "taper") return "cmd.extrude.taper";
return "cmd.extrude.pick";
},
// Auch der „pick"-Schritt nimmt eine Zahl an, damit eine getippte Höhe sofort
// greift, wenn ein Profil vorselektiert ist (dann wird „pick" live wie
// „height" behandelt — analog offset.ts).
accepts: () => ["point", "number"],
options: () => [],
floorOnly: true,
init: (): ExPick => ({ phase: "pick", lastPoint: null }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as ExState;
if (s.phase === "done") return finish();
if (s.phase === "pick") {
const pre = singleSelectedProfile(ctx.project, ctx.selection);
if (pre) {
if (input.kind === "number") {
const h = Math.abs(input.value);
if (h < 1e-6) return [s, { draft: null }];
lastHeight = h;
return enterTaperPhase(pre, h, pre.d.id);
}
// Punkt im vorselektierten Fall: Höhen-Schritt betreten (HUD zeigt Default).
const ns: ExHeight = { phase: "height", profile: pre, sourceId: pre.d.id, cursor: null, lastPoint: null };
return [ns, { draft: null }];
}
if (input.kind === "point") {
const hit = pickProfileAt(ctx.project, ctx.level.id, input.point);
if (!hit) return [s, { draft: null }];
const ns: ExHeight = { phase: "height", profile: hit, sourceId: hit.d.id, cursor: null, lastPoint: null };
return [ns, { draft: null }];
}
return [s, { draft: null }];
}
if (s.phase === "height") {
if (input.kind === "number") {
const h = Math.abs(input.value);
if (h < 1e-6) return [s, { draft: null }];
lastHeight = h;
return enterTaperPhase(s.profile, h, s.sourceId);
}
return [s, { draft: null }];
}
// phase === "taper"
const ts = s as ExTaper;
if (input.kind === "number") {
const t = Math.min(1, Math.max(0, input.value));
lastTaper = t;
return commitTaper(ts.profile, ts.height, t, ts.sourceId, ctx);
}
return [ts, { draft: null }];
},
onMove: (state, point): [CommandState, CommandResult] => {
const s = state as ExState;
if (s.phase === "height") {
const ns: ExHeight = { ...s, cursor: point };
return [
ns,
{
draft: {
preview: [{ kind: "poly", pts: s.profile.pts, closed: true }],
vertices: [],
hud: { at: point, text: `${lastHeight.toFixed(2)} m` },
},
},
];
}
if (s.phase === "taper") {
const ns: ExTaper = { ...s, cursor: point };
return [
ns,
{
draft: {
preview: [{ kind: "poly", pts: s.profile.pts, closed: true }],
vertices: [],
hud: { at: point, text: lastTaper.toFixed(2) },
},
},
];
}
return [s, { draft: null }];
},
// Enter/Rechtsklick: Höhen-Schritt → Default-Höhe übernehmen + Verjüngungs-
// Schritt betreten; Verjüngungs-Schritt → Default-Verjüngung übernehmen + committen.
onConfirm: (state, ctx): [CommandState, CommandResult] => {
const s = state as ExState;
if (s.phase === "height") return enterTaperPhase(s.profile, lastHeight, s.sourceId);
if (s.phase === "taper") return commitTaper(s.profile, s.height, lastTaper, s.sourceId, ctx);
return finish();
},
onCancel: (): [CommandState, CommandResult] => finish(),
};
+74
View File
@@ -0,0 +1,74 @@
// Messen — schnelles, unverbindliches Distanz-/Winkel-Messwerkzeug (kein Element
// wird erzeugt). Schritte:
// 1) „Messen von:" → Punkt (Startpunkt)
// 2) „bis:" → Live-Vorschau (Messlinie + Distanz·Winkel im HUD); Klick
// setzt einen neuen Startpunkt (fortlaufend messen).
// Esc/Enter beendet. Rein visuell: `commit` bleibt immer leer, das Modell ändert
// sich nie — nur der Entwurf (Linie + HUD) zeigt das Mass.
import type {
Command,
CommandResult,
CommandState,
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;
interface MeasureStart extends CommandState {
phase: "start";
}
interface MeasureTo extends CommandState {
phase: "to";
from: Vec2;
cursor: Vec2 | null;
}
type MeasureState = MeasureStart | MeasureTo;
/** Vorschau: Messlinie from→cursor + Distanz·Winkel im HUD am Cursor. */
function measureDraft(from: Vec2, cursor: Vec2): ToolDraft {
const dist = segLen(from, cursor);
const ang = ((segAngleDeg(from, cursor) % 360) + 360) % 360;
return {
preview: [{ kind: "line", a: from, b: cursor }],
vertices: [from, cursor],
hud: { at: cursor, text: `${dist.toFixed(3)} m · ${ang.toFixed(1)}°` },
};
}
const idle = (): [CommandState, CommandResult] => [
{ phase: "start", lastPoint: null } as MeasureStart,
{ draft: null, done: true },
];
export const measureCommand: Command = {
name: "measure",
labelKey: "cmd.measure.label",
prompt: (s) => ((s as MeasureState).phase === "to" ? "cmd.measure.to" : "cmd.measure.start"),
accepts: () => ["point"],
options: () => [],
init: (): MeasureStart => ({ phase: "start", lastPoint: null }),
onInput: (state, input): [CommandState, CommandResult] => {
const s = state as MeasureState;
if (input.kind !== "point") {
return [s, { draft: s.phase === "to" && s.cursor ? measureDraft(s.from, s.cursor) : null }];
}
// Jeder Klick setzt den (neuen) Startpunkt — fortlaufendes Messen, bis Esc/Enter.
const ns: MeasureTo = { phase: "to", from: input.point, cursor: input.point, lastPoint: input.point };
return [ns, { draft: measureDraft(input.point, input.point) }];
},
onMove: (state, point): [CommandState, CommandResult] => {
const s = state as MeasureState;
if (s.phase !== "to") return [s, { draft: null }];
const ns: MeasureTo = { ...s, cursor: point };
return [ns, { draft: measureDraft(s.from, point) }];
},
onConfirm: (): [CommandState, CommandResult] => idle(),
onCancel: (): [CommandState, CommandResult] => idle(),
};
+12 -2
View File
@@ -32,12 +32,22 @@ const EPS = 1e-6;
/** Hat die Auswahl überhaupt etwas Spiegelbares? */
function hasSelection(sel: CommandSelection): boolean {
return sel.wallIds.length > 0 || sel.drawingId !== null;
return (
sel.wallIds.length > 0 ||
sel.drawingId !== null ||
!!sel.extrudedSolidId ||
!!sel.columnId
);
}
/** Auswahl → TransformSelection (gleiche Form). */
function toTransformSel(sel: CommandSelection): TransformSelection {
return { wallIds: sel.wallIds, drawingId: sel.drawingId };
return {
wallIds: sel.wallIds,
drawingId: sel.drawingId,
extrudedSolidId: sel.extrudedSolidId,
columnId: sel.columnId,
};
}
// Zustand: erst ersten Achsenpunkt setzen, dann den zweiten (mit Auswahl-Snapshot).
+12 -2
View File
@@ -49,12 +49,22 @@ function targetFromFields(base: Vec2, locks: Record<string, number>, cursor: Vec
/** Hat die Auswahl überhaupt etwas Verschiebbares? */
function hasSelection(sel: CommandSelection): boolean {
return sel.wallIds.length > 0 || sel.drawingId !== null;
return (
sel.wallIds.length > 0 ||
sel.drawingId !== null ||
!!sel.extrudedSolidId ||
!!sel.columnId
);
}
/** Auswahl → TransformSelection (gleiche Form). */
function toTransformSel(sel: CommandSelection): TransformSelection {
return { wallIds: sel.wallIds, drawingId: sel.drawingId };
return {
wallIds: sel.wallIds,
drawingId: sel.drawingId,
extrudedSolidId: sel.extrudedSolidId,
columnId: sel.columnId,
};
}
// Zustand: erst Basispunkt setzen, dann Ziel (mit Auswahl-Snapshot vom Start).
+76
View File
@@ -0,0 +1,76 @@
// Text — modellverankerter Einzeltext als 2D-Element. Schritte:
// 1) „Ankerpunkt:" → Punkt (Position im Modell)
// 2) „Text:" → getippte Zeile (das Label) → commit Drawing2D {shape:"text"}
//
// Der Text-Schritt nimmt Freitext an (accepts:["text"]) — die Engine reicht die
// komplette getippte Zeile 1:1 durch (auch Zahlen/Kommas). Gerendert wird der
// Text in generatePlan als `drawingText` (SVG), analog zum DXF-Import.
import type { Drawing2D } from "../../model/types";
import { uniqueId } from "../../tools/types";
import type {
Command,
CommandContext,
CommandResult,
CommandState,
Project,
Vec2,
} from "../types";
/** Default-Schrifthöhe eines frei platzierten Texts in Modell-Metern. */
const DEFAULT_TEXT_HEIGHT_M = 0.25;
interface TextIdle extends CommandState {
phase: "point";
}
interface TextLabel extends CommandState {
phase: "label";
at: Vec2;
}
type TextState = TextIdle | TextLabel;
function appendText(p: Project, at: Vec2, text: string, ctx: CommandContext): Project {
const label = text.trim();
if (label === "") return p;
const d: Drawing2D = {
id: uniqueId("dr2d"),
type: "drawing2d",
levelId: ctx.level.id,
categoryCode: ctx.defaultCategoryCode,
geom: { shape: "text", at, text: label, height: DEFAULT_TEXT_HEIGHT_M, angle: 0 },
};
return { ...p, drawings2d: [...p.drawings2d, d] };
}
const idle = (): [CommandState, CommandResult] => [
{ phase: "point", lastPoint: null },
{ draft: null, done: true },
];
export const textCommand: Command = {
name: "text",
labelKey: "cmd.text.label",
prompt: (s) => ((s as TextState).phase === "label" ? "cmd.text.enter" : "cmd.text.point"),
accepts: (s) => ((s as TextState).phase === "label" ? ["text"] : ["point"]),
options: () => [],
init: (): TextIdle => ({ phase: "point", lastPoint: null }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as TextState;
if (s.phase !== "label") {
if (input.kind !== "point") return [s, { draft: null }];
const ns: TextLabel = { phase: "label", at: input.point, lastPoint: input.point };
return [ns, { draft: null }];
}
if (input.kind !== "text") return [s, { draft: null }];
const at = s.at;
return [
{ phase: "point", lastPoint: null },
{ draft: null, done: true, commit: (p) => appendText(p, at, input.text, ctx) },
];
},
onMove: (state): [CommandState, CommandResult] => [state, { draft: null }],
onConfirm: (): [CommandState, CommandResult] => idle(),
onCancel: (): [CommandState, CommandResult] => idle(),
};
+127
View File
@@ -0,0 +1,127 @@
// Textspalte (Absatztext) — wie das Text-Werkzeug, aber mit einer Spaltenbreite:
// der Text bricht beim Rendern wortweise auf diese Breite um. Schritte:
// 1) „Ankerpunkt:" → Punkt (oben-links der Spalte)
// 2) „Spaltenbreite:" → Punkt (die horizontale Distanz zum Anker = Breite;
// Live-Vorschau der Breitenlinie)
// 3) „Text:" → getippte Zeile → commit Drawing2D {shape:"text", width}
//
// Reuse des bestehenden {shape:"text"}-Elements (mit `width`) — so erben Selektion,
// Verschieben und Griff automatisch vom Einzeltext; nur die Breite kommt hinzu.
import type { Drawing2D } from "../../model/types";
import { uniqueId } from "../../tools/types";
import type {
Command,
CommandContext,
CommandResult,
CommandState,
Project,
ToolDraft,
Vec2,
} from "../types";
/** Default-Schrifthöhe einer Textspalte in Modell-Metern. */
const DEFAULT_TEXT_HEIGHT_M = 0.25;
/** Kleinste sinnvolle Spaltenbreite (Meter). */
const MIN_WIDTH_M = 0.2;
interface TbPoint extends CommandState {
phase: "point";
}
interface TbWidth extends CommandState {
phase: "width";
at: Vec2;
cursor: Vec2 | null;
}
interface TbLabel extends CommandState {
phase: "label";
at: Vec2;
width: number;
}
type TbState = TbPoint | TbWidth | TbLabel;
/** Horizontale Spaltenbreite aus Anker + Cursor (Betrag der x-Differenz, geklemmt). */
function widthOf(at: Vec2, cursor: Vec2): number {
return Math.max(MIN_WIDTH_M, Math.abs(cursor.x - at.x));
}
/** Vorschau: waagrechte Breitenlinie am Anker (zeigt die Spaltenbreite). */
function widthDraft(at: Vec2, cursor: Vec2): ToolDraft {
const w = widthOf(at, cursor);
const b: Vec2 = { x: at.x + w, y: at.y };
return {
preview: [{ kind: "line", a: at, b }],
vertices: [at, b],
hud: { at: b, text: `${w.toFixed(2)} m` },
};
}
function appendTextbox(
p: Project,
at: Vec2,
width: number,
text: string,
ctx: CommandContext,
): Project {
const label = text.trim();
if (label === "") return p;
const d: Drawing2D = {
id: uniqueId("dr2d"),
type: "drawing2d",
levelId: ctx.level.id,
categoryCode: ctx.defaultCategoryCode,
geom: { shape: "text", at, text: label, height: DEFAULT_TEXT_HEIGHT_M, angle: 0, width },
};
return { ...p, drawings2d: [...p.drawings2d, d] };
}
const idle = (): [CommandState, CommandResult] => [
{ phase: "point", lastPoint: null } as TbPoint,
{ draft: null, done: true },
];
export const textboxCommand: Command = {
name: "textbox",
labelKey: "cmd.textbox.label",
prompt: (s) => {
const ts = s as TbState;
if (ts.phase === "label") return "cmd.textbox.enter";
if (ts.phase === "width") return "cmd.textbox.width";
return "cmd.textbox.point";
},
accepts: (s) => ((s as TbState).phase === "label" ? ["text"] : ["point"]),
options: () => [],
init: (): TbPoint => ({ phase: "point", lastPoint: null }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as TbState;
if (s.phase === "point") {
if (input.kind !== "point") return [s, { draft: null }];
const ns: TbWidth = { phase: "width", at: input.point, cursor: input.point, lastPoint: input.point };
return [ns, { draft: widthDraft(input.point, input.point) }];
}
if (s.phase === "width") {
if (input.kind !== "point") return [s, { draft: s.cursor ? widthDraft(s.at, s.cursor) : null }];
const width = widthOf(s.at, input.point);
const ns: TbLabel = { phase: "label", at: s.at, width, lastPoint: input.point };
return [ns, { draft: null }];
}
// label
if (input.kind !== "text") return [s, { draft: null }];
const { at, width } = s;
return [
{ phase: "point", lastPoint: null } as TbPoint,
{ draft: null, done: true, commit: (p) => appendTextbox(p, at, width, input.text, ctx) },
];
},
onMove: (state, point): [CommandState, CommandResult] => {
const s = state as TbState;
if (s.phase !== "width") return [s, { draft: null }];
const ns: TbWidth = { ...s, cursor: point };
return [ns, { draft: widthDraft(s.at, point) }];
},
onConfirm: (): [CommandState, CommandResult] => idle(),
onCancel: (): [CommandState, CommandResult] => idle(),
};
+8
View File
@@ -328,6 +328,14 @@ export class CommandEngine {
}
}
// Freitext-Schritt (z. B. Text-Platzierung): der Schritt nimmt beliebigen
// Text als Label — die komplette getippte Zeile geht 1:1 durch (auch Zahlen/
// Kommas), bevor die numerische/Koordinaten-Auflösung greift.
if (accepts.includes("text")) {
this.feed({ kind: "text", text });
return;
}
const parsed = parseInput(text);
// Tab-Feld-Modus (§2.7): eine nackte Zahl LOCKT das aktive Feld (statt als
+16
View File
@@ -10,18 +10,23 @@ import { wallCommand } from "./cmds/wall";
import { ceilingCommand } from "./cmds/ceiling";
import { openingCommand, fensterCommand, tuerCommand } from "./cmds/opening";
import { stairCommand } from "./cmds/stair";
import { columnCommand } from "./cmds/column";
import { roomCommand } from "./cmds/room";
import { rectCommand } from "./cmds/rect";
import { circleCommand } from "./cmds/circle";
import { arcCommand } from "./cmds/arc";
import { textCommand } from "./cmds/text";
import { textboxCommand } from "./cmds/textbox";
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 { extrudeCommand } from "./cmds/extrude";
import { trimCommand } from "./cmds/trim";
import { importCommand } from "./cmds/import";
import { terrainCommand } from "./cmds/terrain";
import { measureCommand } from "./cmds/measure";
/** Alle bekannten Befehle, Schlüssel = Befehlsname (lowercase). */
export const COMMANDS: Record<string, Command> = {
@@ -31,20 +36,25 @@ export const COMMANDS: Record<string, Command> = {
fenster: fensterCommand,
tuer: tuerCommand,
stair: stairCommand,
column: columnCommand,
room: roomCommand,
line: lineCommand,
polyline: polylineCommand,
rect: rectCommand,
circle: circleCommand,
arc: arcCommand,
text: textCommand,
textbox: textboxCommand,
move: moveCommand,
mirror: mirrorCommand,
join: joinCommand,
copy: copyCommand,
offset: offsetCommand,
extrude: extrudeCommand,
trim: trimCommand,
import: importCommand,
terrain: terrainCommand,
measure: measureCommand,
};
/**
@@ -63,6 +73,8 @@ export const ALIASES: Record<string, string> = {
oe: "opening",
treppe: "stair",
tp: "stair",
stuetze: "column",
stütze: "column",
raum: "room",
rm: "room",
l: "line",
@@ -71,6 +83,9 @@ export const ALIASES: Record<string, string> = {
c: "circle",
a: "arc",
bogen: "arc",
tx: "text",
txt: "text",
beschriftung: "text",
m: "move",
s: "mirror",
spiegeln: "mirror",
@@ -78,6 +93,7 @@ export const ALIASES: Record<string, string> = {
verbinden: "join",
cp: "copy",
o: "offset",
ex: "extrude",
tr: "trim",
imp: "import",
ter: "terrain",
+5 -1
View File
@@ -19,7 +19,7 @@ export type { DraftShape, SnapResult, ToolDraft } from "../tools/types";
// ── Eingabe-Arten ────────────────────────────────────────────────────────────
/** Arten von Eingaben, die ein Schritt annehmen kann. */
export type AcceptKind = "point" | "number" | "option" | "selection";
export type AcceptKind = "point" | "number" | "option" | "text" | "selection";
/**
* Geparste, noch nicht aufgelöste Roh-Eingabe aus der Command-Line bzw. der
@@ -67,6 +67,10 @@ export interface CommandSelection {
* `drawingId` (falls gesetzt) als einziges Element.
*/
drawingIds?: string[];
/** Gewählter extrudierter Körper (truck-Integration), falls einer selektiert ist. */
extrudedSolidId?: string | null;
/** Gewählte Stütze (Tragwerk), falls eine selektiert ist. */
columnId?: string | null;
}
/** Live-Kontext, den ein Befehl bei jedem Schritt erhält (analog ToolContext). */
+70
View File
@@ -0,0 +1,70 @@
let modulePromise: Promise<any> | null = null;
async function getModule(): Promise<any> {
if (!modulePromise) {
modulePromise = import("./pkgTruck/trucksolid.js").then(async (m: any) => {
await m.default();
return m;
});
}
return modulePromise;
}
export interface ExtrudedMesh {
positions: number[];
indices: number[];
}
/** `taper` 0 (Prisma, Default) … 1 (Spitze/Kegel-Pyramide) — linear zum
* Profil-Schwerpunkt skaliert. */
export async function extrudePolygon(
points: number[],
height: number,
taper = 0,
): Promise<ExtrudedMesh> {
const m = await getModule();
const json = m.extrude_polygon(JSON.stringify({ points, height, taper }));
return JSON.parse(json) as ExtrudedMesh;
}
export async function extrudeCircle(
cx: number,
cy: number,
r: number,
height: number,
taper = 0,
): Promise<ExtrudedMesh> {
const m = await getModule();
const json = m.extrude_circle(JSON.stringify({ cx, cy, r, height, taper }));
return JSON.parse(json) as ExtrudedMesh;
}
export type BooleanOp = "union" | "difference" | "intersection";
export interface BooleanMeshResult {
positions: number[];
indices: number[];
empty: boolean;
}
/** Boolesche Operation zweier Dreiecks-Meshes (Mesh-Ebenen-CSG via csgrs,
* truck-Integration Phase 4). Beide Meshes müssen konsistent nach außen
* gewundene Dreiecke haben (Rechte-Hand-Regel) — sonst liefert csgrs ein
* falsches Ergebnis, ohne das zu erkennen. */
export async function booleanMesh(
a: ExtrudedMesh,
b: ExtrudedMesh,
op: BooleanOp,
): Promise<BooleanMeshResult> {
const m = await getModule();
const json = m.boolean_mesh(
JSON.stringify({
a_positions: a.positions,
a_indices: a.indices,
b_positions: b.positions,
b_indices: b.indices,
op,
}),
);
return JSON.parse(json) as BooleanMeshResult;
}
+10
View File
@@ -28,6 +28,10 @@ const LAYER_HATCH = "HATCH";
const LAYER_SYMBOLS = "SYMBOLS";
const LAYER_CONTEXT = "CONTEXT";
const LAYER_DEFAULT = "PLAN";
/** Sammel-Layer für Extrusions-Footprints (truck-Integration) — die Quell-
* Drawing2D wird beim Extrudieren entfernt, es gibt also keine categoryCode
* mehr nachzuschlagen; eigener Layer statt Absturz in LAYER_DEFAULT. */
const LAYER_EXTRUSION = "EXTRUSION";
/**
* Baut aus einem Plan + Projekt einen vollständigen DXF-String (Meter-Modell-Space).
@@ -60,6 +64,8 @@ export function buildPlanDxf(
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 });
// Warmes Orange, identisch zum 3D-/Grundriss-Footprint der Extrusionen (EXTRUSION_STROKE/EXTRUSION_RGB).
dxf.addLayer({ name: LAYER_EXTRUSION, color: aciFromHex("#d98c40"), trueColor: rgbFromHex("#d98c40"), lineWeight: 13 });
// 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-
@@ -68,11 +74,15 @@ export function buildPlanDxf(
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 codeByColumn = new Map<string, string>();
for (const c of project.columns ?? []) codeByColumn.set(c.id, c.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;
if (p.columnId) return layerNameByCode.get(codeByColumn.get(p.columnId) ?? "") ?? LAYER_DEFAULT;
if (p.extrudedSolidId) return LAYER_EXTRUSION;
return LAYER_DEFAULT;
}
if (p.kind === "line") {
+327
View File
@@ -0,0 +1,327 @@
// Unit-Tests für den IFC4-Export (exportIfcSpf) — erste vollständige Scheibe.
// • Strukturelle Validität: KEINE dangling references (jede #N-Referenz ist
// definiert), KEINE doppelten Entity-IDs — der wichtigste Test.
// • Header/FILE_SCHEMA('IFC4') vorhanden.
// • Je "floor"-Geschoss genau ein IfcBuildingStorey.
// • Wandanzahl → IfcWall-Anzahl.
// • Eine Öffnung ⇒ IfcOpeningElement + IfcRelVoidsElement (+ IfcDoor/
// IfcWindow + IfcRelFillsElement).
// • GUID-Format (22 Zeichen, gültiger Zeichensatz), deterministisch.
// • Leeres Projekt ⇒ valider Minimal-IFC (Project/Site/Building, kein Crash).
//
// Fixture-Muster gespiegelt von exportSchedule.test.ts.
import { describe, it, expect } from "vitest";
import { exportIfcSpf, ifcGuid } from "./exportIfc";
import type {
Ceiling,
ExtrudedSolid,
Opening,
Project,
Stair,
Wall,
} from "../model/types";
/**
* Minimalprojekt: 2 Geschosse (EG + OG, EG auch ein "section"-Level, das
* NICHT zu einem Storey werden darf) + 2 Wände (Wandtyp T=0.4) + 1 Decke
* (Deckentyp T=0.2) + 1 Tür + 1 Fenster (an W1 gehostet) + 1 Treppe +
* 1 Extrusion.
*/
function fixtureProject(): Project {
const walls: Wall[] = [
{
id: "W1",
type: "wall",
floorId: "eg",
categoryCode: "20",
start: { x: 0, y: 0 },
end: { x: 5, y: 0 },
wallTypeId: "aw",
height: 2.6,
},
{
id: "W2",
type: "wall",
floorId: "eg",
categoryCode: "20",
start: { x: 5, y: 0 },
end: { x: 5, y: 4 },
wallTypeId: "aw",
height: 2.6,
},
];
const ceilings: Ceiling[] = [
{
id: "D1",
type: "ceiling",
floorId: "eg",
categoryCode: "30",
outline: [
{ x: 0, y: 0 },
{ x: 5, y: 0 },
{ x: 5, y: 4 },
{ x: 0, y: 4 },
],
wallTypeId: "dt",
ceilingTypeId: "dt",
},
];
const openings: Opening[] = [
{
id: "T1",
type: "opening",
hostWallId: "W1",
categoryCode: "21",
kind: "door",
position: 1,
width: 0.9,
height: 2.1,
sillHeight: 0,
},
{
id: "F1",
type: "opening",
hostWallId: "W2",
categoryCode: "21",
kind: "window",
position: 1,
width: 1.2,
height: 1.5,
sillHeight: 0.9,
},
];
const stairs: Stair[] = [
{
id: "S1",
type: "stair",
floorId: "eg",
categoryCode: "40",
shape: "straight",
start: { x: 0, y: 0 },
dir: { x: 1, y: 0 },
runLength: 3,
width: 1.2,
totalRise: 2.6,
stepCount: 16,
},
];
const extrudedSolids: ExtrudedSolid[] = [
{
id: "E1",
type: "extrudedSolid",
levelId: "eg",
points: [
{ x: 0, y: 0 },
{ x: 2, y: 0 },
{ x: 2, y: 3 },
{ x: 0, y: 3 },
],
height: 2.5,
},
];
return {
id: "t",
name: "Testprojekt",
lineStyles: [],
hatches: [],
components: [{ id: "c", name: "C", color: "#ccc", hatchId: "none", joinPriority: 10 }],
wallTypes: [{ id: "aw", name: "Aussenwand", layers: [{ componentId: "c", thickness: 0.4 }] }],
ceilingTypes: [{ id: "dt", name: "Betondecke", layers: [{ componentId: "c", thickness: 0.2 }] }],
drawingLevels: [
{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0, baseElevation: 0 },
{ id: "og", name: "OG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0, baseElevation: 2.6 },
{ id: "schnitt-a", name: "Schnitt A", kind: "section", visible: true, locked: false, linePoints: [{ x: 0, y: 0 }, { x: 1, y: 0 }], directionSign: 1 },
],
layers: [{ code: "20", name: "Wände", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }],
walls,
doors: [],
openings,
ceilings,
stairs,
extrudedSolids,
rooms: [],
drawings2d: [],
context: [],
} as Project;
}
/** Sammelt alle definierten Entity-IDs (`#N=`) und alle referenzierten `#N`. */
function collectIds(spf: string): { defined: Set<number>; referenced: Set<number>; duplicates: number[] } {
const defined = new Set<number>();
const duplicates: number[] = [];
const referenced = new Set<number>();
for (const line of spf.split("\n")) {
const defMatch = /^#(\d+)=/.exec(line);
if (defMatch) {
const id = Number(defMatch[1]);
if (defined.has(id)) duplicates.push(id);
defined.add(id);
}
const refs = line.matchAll(/#(\d+)/g);
for (const r of refs) {
// Der erste Treffer je Zeile ist ggf. die Definition selbst — trotzdem
// harmlos mitgezählt, da sie ja in `defined` steht (Selbstreferenz-Check
// unten prüft nur: JEDE referenzierte ID muss iRGENDWO definiert sein).
referenced.add(Number(r[1]));
}
}
return { defined, referenced, duplicates };
}
describe("exportIfcSpf — IFC4-Export", () => {
it("erzeugt keine dangling references und keine doppelten Entity-IDs", () => {
const spf = exportIfcSpf(fixtureProject());
const { defined, referenced, duplicates } = collectIds(spf);
expect(duplicates).toEqual([]);
const dangling = [...referenced].filter((id) => !defined.has(id));
expect(dangling).toEqual([]);
expect(defined.size).toBeGreaterThan(0);
});
it("trägt einen gültigen IFC4-Header", () => {
const spf = exportIfcSpf(fixtureProject());
expect(spf).toContain("ISO-10303-21;");
expect(spf).toContain("FILE_SCHEMA(('IFC4'));");
expect(spf).toContain("END-ISO-10303-21;");
});
it("erzeugt je 'floor'-Geschoss genau ein IfcBuildingStorey (Schnitte NICHT)", () => {
const spf = exportIfcSpf(fixtureProject());
const storeyLines = spf.split("\n").filter((l) => l.includes("=IFCBUILDINGSTOREY("));
expect(storeyLines).toHaveLength(2); // EG + OG, NICHT "Schnitt A"
expect(storeyLines.some((l) => l.includes("'EG'"))).toBe(true);
expect(storeyLines.some((l) => l.includes("'OG'"))).toBe(true);
});
it("bildet jede Wand auf genau ein IfcWall ab", () => {
const spf = exportIfcSpf(fixtureProject());
const wallLines = spf.split("\n").filter((l) => l.includes("=IFCWALL("));
expect(wallLines).toHaveLength(2);
});
it("Tür/Fenster bleiben eigene Objekte (IfcDoor/IfcWindow); das Loch steckt im Wand-Mesh (kein IfcOpeningElement/Void/Fill)", () => {
const spf = exportIfcSpf(fixtureProject());
const lines = spf.split("\n");
// Tür + Fenster als eigene Objekte erhalten.
expect(lines.filter((l) => l.includes("=IFCDOOR("))).toHaveLength(1);
expect(lines.filter((l) => l.includes("=IFCWINDOW("))).toHaveLength(1);
// Bewusste Abwägung: Wand ist jetzt ein Face-Set mit ausgeschnittenem Loch —
// die frühere IfcOpeningElement-Void/Fill-Semantik entfällt (siehe Dateikopf).
expect(lines.filter((l) => l.includes("=IFCOPENINGELEMENT("))).toHaveLength(0);
expect(lines.filter((l) => l.includes("=IFCRELVOIDSELEMENT("))).toHaveLength(0);
expect(lines.filter((l) => l.includes("=IFCRELFILLSELEMENT("))).toHaveLength(0);
});
it("bildet Wände als IfcTriangulatedFaceSet (IfcCartesianPointList3D + CoordIndex) statt Profil-Extrusion ab", () => {
const spf = exportIfcSpf(fixtureProject());
const lines = spf.split("\n");
// Genau ein Face-Set + eine Punktliste je Wand (2 Wände).
expect(lines.filter((l) => l.includes("=IFCTRIANGULATEDFACESET("))).toHaveLength(2);
expect(lines.filter((l) => l.includes("=IFCCARTESIANPOINTLIST3D("))).toHaveLength(2);
// Wand-Shape ist als Tessellation deklariert (nicht mehr SweptSolid).
expect(spf).toContain("'Tessellation'");
// Face-Set-CoordIndex referenziert 1-basierte Punkt-Indizes (Tripel-Listen).
const fs = lines.find((l) => l.includes("=IFCTRIANGULATEDFACESET("));
expect(fs).toMatch(/\(\(\d+,\d+,\d+\)/);
});
it("bildet Decke, Treppe und Extrusion auf die erwarteten Entity-Typen ab", () => {
const spf = exportIfcSpf(fixtureProject());
const lines = spf.split("\n");
expect(lines.filter((l) => l.includes("=IFCSLAB("))).toHaveLength(1);
expect(lines.filter((l) => l.includes("=IFCSTAIR("))).toHaveLength(1);
expect(lines.filter((l) => l.includes("=IFCBUILDINGELEMENTPROXY("))).toHaveLength(1);
});
it("das Wand-Face-Set enthält das ausgeschnittene Fenster-Loch (Loch-Vertices + volle Dreieckszahl, keine dangling refs)", () => {
// W2 hat das Fenster F1 (position 1, width 1.2, sill 0.9, height 1.5) → das
// Loch liegt voll im Wand-Inneren; das Face-Set der Wand hat exakt die
// Rust-Zerlegung: 8 Langseiten-Teilrechtecke ×2×2 + 8 (Deckel/Boden/Kappen)
// + 8 (4 Laibungen) = 48 Dreiecke, 144 Punkte.
const spf = exportIfcSpf(fixtureProject());
const lines = spf.split("\n");
const faceSets = lines.filter((l) => l.includes("=IFCTRIANGULATEDFACESET("));
// Das Loch-Face-Set hat 48 CoordIndex-Tripel.
const triCounts = faceSets.map((l) => (l.match(/\(\d+,\d+,\d+\)/g) ?? []).length);
expect(triCounts).toContain(48);
// Zugehörige Punktliste hat 144 Punkte (3er-Koordinaten-Tupel).
const pointLists = lines.filter((l) => l.includes("=IFCCARTESIANPOINTLIST3D("));
const ptCounts = pointLists.map((l) => (l.match(/\([^()]*,[^()]*,[^()]*\)/g) ?? []).length);
expect(ptCounts).toContain(144);
// Keine dangling refs (der Kern-Invarianten-Check gilt auch mit Face-Sets).
const { defined, referenced } = collectIds(spf);
expect([...referenced].filter((id) => !defined.has(id))).toEqual([]);
});
it("Wand-Face-Sets sind NACH AUSSEN orientiert (positives Volumen) — Regression gegen die Reflexions-Wicklung, die die Wand hohl machte", () => {
// Der IFC-Achsen-Swap (x,y,z)→(x,z,y) ist eine Reflexion und kehrte die
// Dreiecks-Wicklung um → Normalen zeigten nach INNEN → Viewer cullten die
// Vorderseiten → Wand wirkte oben/unten offen. Nach dem Wicklungs-Ausgleich
// muss das signierte Volumen jedes Wandkörpers POSITIV sein (aussen orientiert).
const spf = exportIfcSpf(fixtureProject());
const lines = spf.split("\n");
const byId = new Map<number, string>();
for (const l of lines) {
const m = /^#(\d+)=/.exec(l);
if (m) byId.set(Number(m[1]), l);
}
const parsePoints = (line: string): number[][] =>
[...line.matchAll(/\(([-\d.]+),([-\d.]+),([-\d.]+)\)/g)].map((m) => [
Number(m[1]),
Number(m[2]),
Number(m[3]),
]);
const faceSets = lines.filter((l) => l.includes("=IFCTRIANGULATEDFACESET("));
expect(faceSets.length).toBeGreaterThan(0);
let closedCount = 0;
for (const fs of faceSets) {
const ptListId = Number(/=IFCTRIANGULATEDFACESET\(#(\d+),/.exec(fs)![1]);
const closed = /=IFCTRIANGULATEDFACESET\(#\d+,\$,([^,]+),/.exec(fs)![1];
if (closed === ".T.") closedCount++;
const pts = parsePoints(byId.get(ptListId)!);
const tris = [...fs.matchAll(/\((\d+),(\d+),(\d+)\)/g)].map((m) => [
Number(m[1]) - 1,
Number(m[2]) - 1,
Number(m[3]) - 1,
]);
// 6× signiertes Volumen Σ v0·(v1×v2): > 0 ⇒ Normalen zeigen nach aussen.
let vol6 = 0;
for (const [a, b, c] of tris) {
const [ax, ay, az] = pts[a];
const [bx, by, bz] = pts[b];
const [cx, cy, cz] = pts[c];
vol6 += ax * (by * cz - bz * cy) + ay * (bz * cx - bx * cz) + az * (bx * cy - by * cx);
}
expect(vol6).toBeGreaterThan(0);
}
// Beide Wände sind geschlossene Prisma-Körper (Fenster = Durchgangsloch,
// Tür = umlaufende П-Kerbe) → Closed=.T. bei beiden.
expect(closedCount).toBe(2);
});
it("GUIDs sind 22 Zeichen lang, nutzen den gültigen IFC-Zeichensatz und sind deterministisch", () => {
const guid = ifcGuid("W1");
expect(guid).toHaveLength(22);
expect(guid).toMatch(/^[0-9A-Za-z_$]{22}$/);
expect(ifcGuid("W1")).toBe(guid); // stabil über Re-Export
expect(ifcGuid("W2")).not.toBe(guid); // unterschiedliche IDs → unterschiedliche GUIDs
});
it("leeres Projekt ⇒ valider Minimal-IFC (Project/Site/Building, kein Crash)", () => {
const proj = fixtureProject();
proj.walls = [];
proj.ceilings = [];
proj.openings = [];
proj.stairs = [];
proj.extrudedSolids = [];
proj.drawingLevels = [];
const spf = exportIfcSpf(proj);
expect(spf).toContain("=IFCPROJECT(");
expect(spf).toContain("=IFCSITE(");
expect(spf).toContain("=IFCBUILDING(");
const { defined, referenced, duplicates } = collectIds(spf);
expect(duplicates).toEqual([]);
expect([...referenced].filter((id) => !defined.has(id))).toEqual([]);
});
});
+687
View File
@@ -0,0 +1,687 @@
// IFC4-Export (STEP Physical File / ISO-10303-21) des semantischen Modells.
// Reiner Rechen-/Serialisierungskern: keine UI, kein Datei-IO, kein WASM, keine
// neue Dependency — IFC wird direkt als Text geschrieben (analog exportDxf.ts/
// exportSchedule.ts). Der Download (Blob+Anchor) passiert im App-Layer.
//
// Abbildung (erste vollständige Scheibe):
// Project → IfcProject → IfcSite → IfcBuilding → je "floor"-Geschoss ein
// IfcBuildingStorey (IfcRelAggregates-Kette). Bauteile hängen über
// IfcRelContainedInSpatialStructure am jeweiligen Geschoss (verwaiste
// Geschossreferenzen fallen defensiv auf IfcBuilding zurück).
//
// Decken/Treppen/Extrusionen als IfcExtrudedAreaSolid (unser Modell IST
// Extrusion): Profil = IfcArbitraryClosedProfileDef(IfcPolyline) in der
// XY-Ebene, extrudiert entlang +Z. Die horizontale Objekt-Platzierungskette
// (Site/Building/Storey/Element) trägt bewusst NUR die Z-Verschiebung
// (Geschoss-Elevation); die Profilpunkte tragen direkt die Welt-X/Y-Koordinaten.
//
// • Wand → IfcWall mit ÖFFNUNGSGENAUEM Dreiecks-Mesh (IfcTriangulatedFace
// Set, IFC4: IfcCartesianPointList3D + CoordIndex) statt einer Profil-
// Extrusion. Gespeist aus DEMSELBEN Loch-Ausschnitt-Mesh wie STL/OBJ
// (`pickGeometry` → `plan/wallMeshCut.ts`): Joins/Gehrungen UND ausgeschnittene
// Fenster/Türen (inkl. Laibungen) sind im Körper enthalten. ABWÄGUNG (bewusst,
// Nutzer-Priorität "so wie im 3D"): dadurch verliert die Wand die parametrische
// IfcWall-Profil-Extrusion + IfcOpeningElement-Void-Semantik zugunsten
// VISUELLER PARITÄT in JEDEM Viewer (der Loch schon im Mesh sieht, ohne eine
// Boolean-Subtraktion ausführen zu müssen — genau der Bug des Nutzers: "das
// Fenster ist als Objekt da im IFC, aber die Löcher sind nicht da").
// • Decke → IfcSlab (outline-Polygon, PredefinedType FLOOR).
// • Öffnung → KEIN IfcOpeningElement/Void mehr (das Loch steckt im Wand-Mesh);
// Tür/Fenster bleiben als eigenes Objekt IfcDoor/IfcWindow mit eigener Box-
// Geometrie erhalten (füllt das ausgeschnittene Loch, "sieht aus wie 3D").
// • Extrusion → IfcBuildingElementProxy aus points+height.
// • Treppe → IfcStair, GEOMETRISCH bewusst vereinfacht auf einen
// extrudierten Bounding-Footprint (Lauf-Rechteck bei "straight"; Achsen-
// ausgerichtete Bounding-Box der Kontrollpunkte bei "L"/"spiral") — die
// echte Stufengeometrie ist ausgelassen (siehe stairFootprint()).
//
// Material-Layer (IfcMaterialLayerSet/-Usage) sind NICHT enthalten — die
// korrekte Direction/Offset-Semantik von IfcMaterialLayerSetUsage ließ sich
// ohne Gegenprüfung an einem echten Viewer nicht mit ausreichender Sicherheit
// umsetzen; Geometrie/Hierarchie hatten Vorrang (siehe Bericht/PENDENZEN).
//
// GUIDs: deterministisch aus der Element-ID über einen 128-Bit-Hash (zwei
// FNV-1a-64-Läufe) + Standard-IFC-GUID-Kompression (Base64-Variante,
// Zeichensatz 0-9,A-Z,a-z,_,$) — stabil über Re-Exporte hinweg.
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). Einheit: METER.
import type {
Opening,
Project,
Stair,
Vec2,
Wall,
} from "../model/types";
import {
getCeilingType,
getWallType,
openingLabel,
wallTypeThickness,
} from "../model/types";
import {
ceilingVerticalExtent,
stairVerticalExtent,
wallReferenceOffset,
wallVerticalExtent,
} from "../model/wall";
import { pickGeometry } from "../plan/toWalls3d";
import type { RWall } from "../plan/toWalls3d";
import { isWatertight, wallCutMesh } from "../plan/wallMeshCut";
// ── IFC-GUID (Base64-Kompression, 22 Zeichen) ───────────────────────────────
// Standard-Kompressionsalgorithmus (IfcOpenShell guid.compress): das erste
// Byte des 128-Bit-Werts wird auf 2 Zeichen abgebildet, die restlichen 15
// Byte in 5 Dreiergruppen zu je 4 Zeichen — macht 2 + 5×4 = 22 Zeichen.
const IFC_GUID_CHARS =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_$";
/** Kodiert `v` big-endian in `len` IFC-GUID-Zeichen (Basis 64). */
function ifcGuidB64(v: number, len: number): string {
let out = "";
for (let i = len - 1; i >= 0; i--) {
out += IFC_GUID_CHARS[Math.floor(v / 64 ** i) % 64];
}
return out;
}
/** Komprimiert einen 128-Bit-Wert (32 Hex-Zeichen) zur 22-stelligen IFC-GUID. */
function compressGuidHex(hex32: string): string {
const bytes: number[] = [];
for (let i = 0; i < 32; i += 2) bytes.push(parseInt(hex32.slice(i, i + 2), 16));
let out = ifcGuidB64(bytes[0], 2);
for (let i = 1; i < 16; i += 3) {
const v = (bytes[i] << 16) + (bytes[i + 1] << 8) + bytes[i + 2];
out += ifcGuidB64(v, 4);
}
return out;
}
/** FNV-1a-64 (BigInt) — reines Determinismus-/Streuungs-Werkzeug, keine Kryptografie. */
function fnv1a64(str: string, seed: bigint): bigint {
const prime = 0x100000001b3n;
const mask = 0xffffffffffffffffn;
let hash = seed & mask;
for (let i = 0; i < str.length; i++) {
hash ^= BigInt(str.charCodeAt(i));
hash = (hash * prime) & mask;
}
return hash;
}
/** Leitet aus einer stabilen Element-ID einen deterministischen 128-Bit-Hex-Wert ab. */
function idToHex32(id: string): string {
const h1 = fnv1a64(id, 0xcbf29ce484222325n);
const h2 = fnv1a64(`${id}salt`, 0x9e3779b97f4a7c15n);
return h1.toString(16).padStart(16, "0") + h2.toString(16).padStart(16, "0");
}
/** Deterministische 22-stellige IFC-GUID aus einer beliebigen Element-ID. */
export function ifcGuid(id: string): string {
return compressGuidHex(idToHex32(id));
}
// ── STEP-Formatierung ───────────────────────────────────────────────────────
/** STEP-String-Literal ('…', Apostroph verdoppelt, Backslash verdoppelt). */
function S(s: string): string {
const escaped = s.replace(/\\/g, "\\\\").replace(/'/g, "''");
return `'${escaped}'`;
}
/** STEP-REAL-Literal — immer mit Dezimalpunkt, ohne unnötige Nachkommastellen. */
function R(x: number): string {
const v = Object.is(x, -0) ? 0 : x;
let s = v.toFixed(6);
s = s.replace(/0+$/, "");
if (s.endsWith(".")) s += "0";
if (!s.includes(".")) s += ".0";
return s;
}
/** STEP-Enumerationswert `.WERT.`. */
function ENUM(v: string): string {
return `.${v}.`;
}
/** STEP-Liste `(a,b,c)`. */
function LIST(items: string[]): string {
return `(${items.join(",")})`;
}
// ── STEP-Writer ──────────────────────────────────────────────────────────────
class StepWriter {
private lines: string[] = [];
private nextId = 1;
/** Schreibt eine neue Entity-Zeile und liefert ihre `#id`. */
add(type: string, params: string): number {
const id = this.nextId++;
this.lines.push(`#${id}=${type}(${params});`);
return id;
}
get entityLines(): readonly string[] {
return this.lines;
}
}
// ── Geometrie-Helfer (reines 2D-Vec2-Rechnen, Welt-Meter) ───────────────────
function sub(a: Vec2, b: Vec2): Vec2 {
return { x: a.x - b.x, y: a.y - b.y };
}
function normalize(v: Vec2): Vec2 {
const len = Math.hypot(v.x, v.y) || 1;
return { x: v.x / len, y: v.y / len };
}
function leftNormal(u: Vec2): Vec2 {
return { x: -u.y, y: u.x };
}
function addScaled(p: Vec2, d: Vec2, s: number): Vec2 {
return { x: p.x + d.x * s, y: p.y + d.y * s };
}
/** Rechteck-Footprint einer Öffnung im Wandloch (volle Wanddicke tief), CCW. */
function openingFootprint(project: Project, wall: Wall, opening: Opening): Vec2[] {
const u = normalize(sub(wall.end, wall.start));
const n = leftNormal(u);
const t = wallTypeThickness(getWallType(project, wall));
const off = wallReferenceOffset(wall, t);
const inner = -t / 2 + off;
const outer = t / 2 + off;
const a = addScaled(wall.start, u, opening.position);
const b = addScaled(wall.start, u, opening.position + opening.width);
return [
addScaled(a, n, inner),
addScaled(b, n, inner),
addScaled(b, n, outer),
addScaled(a, n, outer),
];
}
/**
* Vereinfachter Bounding-Footprint einer Treppe (bewusst NICHT die echte
* Stufen-/Podestkontur, siehe Dateikopf):
* • "straight" — echtes, ausgerichtetes Lauf-Rechteck (Länge × Breite).
* • "L"/"spiral" — achsenausgerichtete Bounding-Box der Kontrollpunkte
* (Start/Eckpunkt/Ende bzw. Wendel-Zentrum±Radius), um die halbe
* Laufbreite erweitert.
*/
function stairFootprint(stair: Stair): Vec2[] {
const halfW = Math.max(stair.width, 0) / 2;
if (stair.shape === "straight") {
const u = normalize(stair.dir);
const n = leftNormal(u);
const end = addScaled(stair.start, u, stair.runLength);
return [
addScaled(stair.start, n, -halfW),
addScaled(end, n, -halfW),
addScaled(end, n, halfW),
addScaled(stair.start, n, halfW),
];
}
const pts: Vec2[] = [stair.start];
const u = normalize(stair.dir);
const corner = addScaled(stair.start, u, stair.runLength);
pts.push(corner);
if (stair.shape === "L" && stair.run2Length && stair.turn) {
const n = leftNormal(u);
const turnDir: Vec2 = { x: n.x * stair.turn, y: n.y * stair.turn };
pts.push(addScaled(corner, turnDir, stair.run2Length));
}
if (stair.shape === "spiral" && stair.center) {
const r = (stair.radius ?? 0) + halfW;
const c = stair.center;
return [
{ x: c.x - r, y: c.y - r },
{ x: c.x + r, y: c.y - r },
{ x: c.x + r, y: c.y + r },
{ x: c.x - r, y: c.y + r },
];
}
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let 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);
}
minX -= halfW;
minY -= halfW;
maxX += halfW;
maxY += halfW;
return [
{ x: minX, y: minY },
{ x: maxX, y: minY },
{ x: maxX, y: maxY },
{ x: minX, y: maxY },
];
}
// ── IFC4-Export ──────────────────────────────────────────────────────────────
interface StoreyRef {
entityId: number;
placementId: number;
baseElevation: number;
}
interface Structure {
entityId: number;
placementId: number;
baseElevation: number;
}
/**
* Baut aus einem Projekt einen vollständigen IFC4-SPF-String (STEP Physical
* File). Reiner Rechenkern — kein Datei-IO. Leeres Projekt ⇒ valider Minimal-
* IFC (Project/Site/Building, kein Crash).
*/
export function exportIfcSpf(project: Project): string {
const w = new StepWriter();
// Geteilte Grundgeometrie: Ursprung, Z-Extrusionsrichtung, Identitäts-
// Placement (Position aller ExtrudedAreaSolid — Profile tragen direkt
// Welt-X/Y, siehe Dateikopf).
const originPoint = w.add("IFCCARTESIANPOINT", LIST([R(0), R(0), R(0)]));
const extrudeDir = w.add("IFCDIRECTION", LIST([R(0), R(0), R(1)]));
const identityAxis = w.add("IFCAXIS2PLACEMENT3D", `#${originPoint},$,$`);
// Owner-History (minimal, aber vorhanden — manche Importer verlangen sie).
const org = w.add("IFCORGANIZATION", `$,${S("dossier")},$,$,$`);
const person = w.add(
"IFCPERSON",
`${S("dossier")},$,$,$,$,$,$,$`,
);
const personOrg = w.add("IFCPERSONANDORGANIZATION", `#${person},#${org},$`);
const app = w.add(
"IFCAPPLICATION",
`#${org},${S("1.0")},${S("dossier")},${S("dossier")}`,
);
const ownerHistory = w.add(
"IFCOWNERHISTORY",
`#${personOrg},#${app},$,${ENUM("ADDED")},$,$,$,${Math.floor(Date.now() / 1000)}`,
);
// Einheiten (Meter, Radiant, m², m³).
const lenUnit = w.add("IFCSIUNIT", `*,${ENUM("LENGTHUNIT")},$,${ENUM("METRE")}`);
const areaUnit = w.add("IFCSIUNIT", `*,${ENUM("AREAUNIT")},$,${ENUM("SQUARE_METRE")}`);
const volUnit = w.add("IFCSIUNIT", `*,${ENUM("VOLUMEUNIT")},$,${ENUM("CUBIC_METRE")}`);
const angleUnit = w.add(
"IFCSIUNIT",
`*,${ENUM("PLANEANGLEUNIT")},$,${ENUM("RADIAN")}`,
);
const unitAssignment = w.add(
"IFCUNITASSIGNMENT",
LIST([`#${lenUnit}`, `#${areaUnit}`, `#${volUnit}`, `#${angleUnit}`]),
);
// Geometrischer Kontext (3D, Precision 1e-5).
const context = w.add(
"IFCGEOMETRICREPRESENTATIONCONTEXT",
`$,${S("Model")},3,${R(0.00001)},#${identityAxis},$`,
);
// Räumliche Hierarchie: Project → Site → Building → Storeys.
const siteAxis = w.add("IFCAXIS2PLACEMENT3D", `#${originPoint},$,$`);
const sitePlacement = w.add("IFCLOCALPLACEMENT", `$,#${siteAxis}`);
const buildingAxis = w.add("IFCAXIS2PLACEMENT3D", `#${originPoint},$,$`);
const buildingPlacement = w.add(
"IFCLOCALPLACEMENT",
`#${sitePlacement},#${buildingAxis}`,
);
const projectId = w.add(
"IFCPROJECT",
`${S(ifcGuid(`${project.id}:project`))},#${ownerHistory},${S(project.name || "Projekt")},$,$,$,$,${LIST([`#${context}`])},#${unitAssignment}`,
);
const siteId = w.add(
"IFCSITE",
`${S(ifcGuid(`${project.id}:site`))},#${ownerHistory},${S("Standort")},$,$,#${sitePlacement},$,$,${ENUM("ELEMENT")},$,$,$,$,$`,
);
const buildingId = w.add(
"IFCBUILDING",
`${S(ifcGuid(`${project.id}:building`))},#${ownerHistory},${S(project.name || "Gebäude")},$,$,#${buildingPlacement},$,$,${ENUM("ELEMENT")},$,$,$`,
);
w.add(
"IFCRELAGGREGATES",
`${S(ifcGuid(`${project.id}:agg-site`))},#${ownerHistory},$,$,#${projectId},${LIST([`#${siteId}`])}`,
);
w.add(
"IFCRELAGGREGATES",
`${S(ifcGuid(`${project.id}:agg-building`))},#${ownerHistory},$,$,#${siteId},${LIST([`#${buildingId}`])}`,
);
// Je "floor"-Geschoss ein IfcBuildingStorey (Elevation = baseElevation).
const floors = project.drawingLevels.filter((l) => l.kind === "floor");
const storeyByFloorId = new Map<string, StoreyRef>();
const storeyEntityIds: number[] = [];
for (const floor of floors) {
const base = floor.baseElevation ?? 0;
const pt = w.add("IFCCARTESIANPOINT", LIST([R(0), R(0), R(base)]));
const axis = w.add("IFCAXIS2PLACEMENT3D", `#${pt},$,$`);
const placementId = w.add("IFCLOCALPLACEMENT", `#${buildingPlacement},#${axis}`);
const entityId = w.add(
"IFCBUILDINGSTOREY",
`${S(ifcGuid(`${floor.id}:storey`))},#${ownerHistory},${S(floor.name)},$,$,#${placementId},$,$,${ENUM("ELEMENT")},${R(base)}`,
);
storeyByFloorId.set(floor.id, { entityId, placementId, baseElevation: base });
storeyEntityIds.push(entityId);
}
if (storeyEntityIds.length > 0) {
w.add(
"IFCRELAGGREGATES",
`${S(ifcGuid(`${project.id}:agg-storeys`))},#${ownerHistory},$,$,#${buildingId},${LIST(storeyEntityIds.map((id) => `#${id}`))}`,
);
}
/** Geschoss → Trägerstruktur (Storey), oder defensiv das Gebäude (verwaiste floorId). */
const resolveStructure = (floorId: string): Structure => {
const s = storeyByFloorId.get(floorId);
if (s) return { entityId: s.entityId, placementId: s.placementId, baseElevation: s.baseElevation };
return { entityId: buildingId, placementId: buildingPlacement, baseElevation: 0 };
};
// Räumliche Eingliederung sammelt sich je Trägerstruktur (Storey/Building)
// und wird am Ende in EINE IfcRelContainedInSpatialStructure je Struktur
// gebündelt (Öffnungen NICHT — die hängen nur über RelVoidsElement an ihrer
// Wand, wie in IFC üblich).
const containment = new Map<number, number[]>();
const addToContainment = (structureId: number, elementId: number): void => {
const arr = containment.get(structureId);
if (arr) arr.push(elementId);
else containment.set(structureId, [elementId]);
};
/** Baut Profil+Extrusion+Shape+Placement für einen geschlossenen Footprint. */
const emitBoxProduct = (
footprint: Vec2[],
zBottomRel: number,
depth: number,
placementRelTo: number,
): { placementId: number; shapeId: number } => {
const closed = [...footprint, footprint[0]];
const ptIds = closed.map((p) => w.add("IFCCARTESIANPOINT", LIST([R(p.x), R(p.y)])));
const polylineId = w.add("IFCPOLYLINE", LIST(ptIds.map((id) => `#${id}`)));
const profileId = w.add(
"IFCARBITRARYCLOSEDPROFILEDEF",
`${ENUM("AREA")},$,#${polylineId}`,
);
const solidId = w.add(
"IFCEXTRUDEDAREASOLID",
`#${profileId},#${identityAxis},#${extrudeDir},${R(Math.max(depth, 0.001))}`,
);
const shapeRepId = w.add(
"IFCSHAPEREPRESENTATION",
`#${context},${S("Body")},${S("SweptSolid")},${LIST([`#${solidId}`])}`,
);
const shapeId = w.add("IFCPRODUCTDEFINITIONSHAPE", `$,$,${LIST([`#${shapeRepId}`])}`);
const elemOrigin = w.add("IFCCARTESIANPOINT", LIST([R(0), R(0), R(zBottomRel)]));
const elemAxis = w.add("IFCAXIS2PLACEMENT3D", `#${elemOrigin},$,$`);
const placementId = w.add("IFCLOCALPLACEMENT", `#${placementRelTo},#${elemAxis}`);
return { placementId, shapeId };
};
/**
* Baut aus einem Dreiecks-Mesh (`positions` flach x,y,z in IFC-Koordinaten —
* bereits Z-up und relativ zur `placementRelTo`-Herkunft, `indices` je 3 =
* 1-basiert-1 CoordIndex-Tripel) ein IfcTriangulatedFaceSet + Shape + Placement.
* IFC4-Tessellierung: IfcCartesianPointList3D (CoordList) + IfcTriangulatedFace
* Set (CoordIndex, 1-basiert). Das Element-Placement sitzt im Ursprung der
* Trägerstruktur (die Punkte tragen die Geometrie bereits absolut in deren Frame).
*/
const emitTriangulatedProduct = (
positions: number[],
indices: number[],
placementRelTo: number,
closed: boolean,
): { placementId: number; shapeId: number } => {
const coords: string[] = [];
for (let i = 0; i < positions.length; i += 3) {
coords.push(`(${R(positions[i])},${R(positions[i + 1])},${R(positions[i + 2])})`);
}
const pointListId = w.add("IFCCARTESIANPOINTLIST3D", `(${coords.join(",")})`);
const tris: string[] = [];
for (let i = 0; i < indices.length; i += 3) {
tris.push(`(${indices[i] + 1},${indices[i + 1] + 1},${indices[i + 2] + 1})`);
}
// Closed=.T. NUR wenn das Mesh nachweislich ein dichtes, aussen orientiertes
// Volumen ist (siehe isWatertight): Wände sind extrudierte Querschnitts-
// Polygone (Fenster = Durchgangsloch, Tür = umlaufende П-Kerbe) → geschlossene
// Körper → `.T.` (Viewer rendern sie als Solid statt als offene Fläche). Der
// Guard fängt echte Defekte ab (invertierte Wicklung → Volumen < 0 → `$`).
// Normals=$ (Viewer leitet sie aus der — jetzt aussen orientierten — Wicklung ab).
const closedFlag = closed ? ".T." : "$";
const faceSetId = w.add("IFCTRIANGULATEDFACESET", `#${pointListId},$,${closedFlag},(${tris.join(",")}),$`);
const shapeRepId = w.add(
"IFCSHAPEREPRESENTATION",
`#${context},${S("Body")},${S("Tessellation")},${LIST([`#${faceSetId}`])}`,
);
const shapeId = w.add("IFCPRODUCTDEFINITIONSHAPE", `$,$,${LIST([`#${shapeRepId}`])}`);
const elemOrigin = w.add("IFCCARTESIANPOINT", LIST([R(0), R(0), R(0)]));
const elemAxis = w.add("IFCAXIS2PLACEMENT3D", `#${elemOrigin},$,$`);
const placementId = w.add("IFCLOCALPLACEMENT", `#${placementRelTo},#${elemAxis}`);
return { placementId, shapeId };
};
/** Baut ein IfcBuildingElement-Subtyp mit dem üblichen 9-Attribut-Flatten. */
const emitBuildingElement = (
type: string,
guid: string,
name: string,
placementId: number,
shapeId: number,
predefinedType: string | null,
): number =>
w.add(
type,
`${S(guid)},#${ownerHistory},${S(name)},$,$,#${placementId},#${shapeId},$,${predefinedType ?? "$"}`,
);
// ── Wände (öffnungsgenaues Dreiecks-Mesh statt Profil-Extrusion) ────────
// Die Wand-Schicht-Bänder kommen aus DEMSELBEN geflachten Modell wie STL/OBJ
// (`pickGeometry`, Joins/Gehrungen + Öffnungs-`holes` bereits aufgelöst). Alle
// Bänder einer Wand-Id werden zu EINEM Face-Set vereint. IFC-Koordinaten: das
// Mesh liegt in Welt (Modell-x, Höhe, Modell-y) mit Y-up → IFC (x, y, z=Höhe)
// mit Z-up, also (mx, mz, my); Z relativ zur Geschoss-UK, damit die Storey-
// Placement-Elevation nicht doppelt zählt.
//
// WICHTIG — WICKLUNG: der Achsen-Swap (x,y,z)→(x,z,y) ist eine REFLEXION
// (Determinante 1) und KEHRT die Dreiecks-Wicklung UM → aus aussen orientierten
// würden innen orientierte Normalen, der Viewer cullt dann die Vorderseiten und
// die Wand wirkt HOHL/offen (genau der gemeldete Bug). Deshalb wird beim Swap
// die Wicklung jedes Dreiecks umgedreht (i0,i2,i1), damit die Aussen-Normalen
// aussen bleiben. Watertightness (isWatertight) misst das anschliessend am
// fertigen IFC-Mesh → treibt das Closed-Flag des Face-Sets.
const bandsByWallId = new Map<string, RWall[]>();
for (const band of pickGeometry(project).walls) {
const list = bandsByWallId.get(band.wallId);
if (list) list.push(band);
else bandsByWallId.set(band.wallId, [band]);
}
const wallEntityIdByWallId = new Map<string, number>();
for (const wall of project.walls ?? []) {
const bands = bandsByWallId.get(wall.id);
if (!bands || bands.length === 0) continue; // degenerierte Wand / keine Geometrie
const structure = resolveStructure(wall.floorId);
const positions: number[] = [];
const indices: number[] = [];
for (const band of bands) {
const cut = wallCutMesh(band);
const base = positions.length / 3;
for (let i = 0; i < cut.positions.length; i += 3) {
positions.push(
cut.positions[i],
cut.positions[i + 2],
cut.positions[i + 1] - structure.baseElevation,
);
}
// Wicklung umkehren (Reflexions-Ausgleich, s. o.): (a,b,c) → (a,c,b).
for (let i = 0; i < cut.indices.length; i += 3) {
indices.push(base + cut.indices[i], base + cut.indices[i + 2], base + cut.indices[i + 1]);
}
}
if (indices.length === 0) continue;
const closed = isWatertight({ positions, indices });
const { placementId, shapeId } = emitTriangulatedProduct(positions, indices, structure.placementId, closed);
let name = wall.id;
try {
name = getWallType(project, wall).name;
} catch {
/* verwaister Wandtyp — Roh-ID als Name */
}
const entityId = emitBuildingElement("IFCWALL", ifcGuid(wall.id), name, placementId, shapeId, null);
wallEntityIdByWallId.set(wall.id, entityId);
addToContainment(structure.entityId, entityId);
}
// ── Decken ─────────────────────────────────────────────────────────────
for (const ceiling of project.ceilings ?? []) {
if (ceiling.outline.length < 3) continue;
const { zBottom, zTop } = ceilingVerticalExtent(project, ceiling);
const structure = resolveStructure(ceiling.floorId);
const { placementId, shapeId } = emitBoxProduct(
ceiling.outline,
zBottom - structure.baseElevation,
zTop - zBottom,
structure.placementId,
);
let name: string = ceiling.id;
try {
name = getCeilingType(project, ceiling).name;
} catch {
/* verwaister Deckentyp — Roh-ID als Name */
}
const entityId = emitBuildingElement(
"IFCSLAB",
ifcGuid(ceiling.id),
name,
placementId,
shapeId,
ENUM("FLOOR"),
);
addToContainment(structure.entityId, entityId);
}
// ── Fenster/Türen als eigene Objekte (IfcDoor/IfcWindow) ────────────────
// KEIN IfcOpeningElement/IfcRelVoidsElement/IfcRelFillsElement mehr: das Loch
// steckt bereits im Wand-Face-Set (s. o.). Eine Void-Relation beschriebe eine
// Boolean-Subtraktion gegen einen Swept-Solid, den es nicht mehr gibt — sie
// brächte in den Viewern nur Verwirrung (der Nutzer-Bug war genau, dass die
// Void nicht subtrahiert wurde). Tür/Fenster bleiben als EIGENES Objekt mit
// eigener Box-Geometrie erhalten, die das ausgeschnittene Loch füllt ("sieht
// aus wie 3D"): kein Blatt-/Rahmendetail (bewusste Vereinfachung).
for (const opening of project.openings ?? []) {
const wall = (project.walls ?? []).find((wl) => wl.id === opening.hostWallId);
if (!wall) continue; // verwaiste Wirtswand — keine Geometrie ableitbar
if (!wallEntityIdByWallId.has(wall.id)) continue; // Wirtswand übersprungen (degeneriert)
const wallExtent = wallVerticalExtent(project, wall);
const structure = resolveStructure(wall.floorId);
const footprint = openingFootprint(project, wall, opening);
const zSillAbs = wallExtent.zBottom + opening.sillHeight;
const { placementId: fillPlacement, shapeId: fillShape } = emitBoxProduct(
footprint,
zSillAbs - structure.baseElevation,
opening.height,
structure.placementId,
);
const fillGuid = ifcGuid(`${opening.id}:fill`);
const fillName = openingLabel(opening);
const fillEntityId =
opening.kind === "door"
? w.add(
"IFCDOOR",
`${S(fillGuid)},#${ownerHistory},${S(fillName)},$,$,#${fillPlacement},#${fillShape},$,${R(opening.height)},${R(opening.width)},${ENUM("DOOR")},$,$`,
)
: w.add(
"IFCWINDOW",
`${S(fillGuid)},#${ownerHistory},${S(fillName)},$,$,#${fillPlacement},#${fillShape},$,${R(opening.height)},${R(opening.width)},${ENUM("WINDOW")},$,$`,
);
addToContainment(structure.entityId, fillEntityId);
}
// ── Treppen (vereinfachter Bounding-Footprint, siehe Dateikopf) ────────
for (const stair of project.stairs ?? []) {
const footprint = stairFootprint(stair);
if (footprint.length < 3) continue;
const { zBottom, zTop } = stairVerticalExtent(project, stair);
const structure = resolveStructure(stair.floorId);
const { placementId, shapeId } = emitBoxProduct(
footprint,
zBottom - structure.baseElevation,
zTop - zBottom,
structure.placementId,
);
const predefinedType =
stair.shape === "straight"
? ENUM("STRAIGHT_RUN_STAIR")
: stair.shape === "spiral"
? ENUM("SPIRAL_STAIR")
: ENUM("QUARTER_TURN_STAIR");
const entityId = emitBuildingElement(
"IFCSTAIR",
ifcGuid(stair.id),
"Treppe",
placementId,
shapeId,
predefinedType,
);
addToContainment(structure.entityId, entityId);
}
// ── Extrudierte Körper (truck-Integration) → IfcBuildingElementProxy ───
for (const solid of project.extrudedSolids ?? []) {
if (solid.points.length < 3) continue;
const floor = floors.find((f) => f.id === solid.levelId);
const base = floor?.baseElevation ?? 0;
const structure = resolveStructure(solid.levelId);
const { placementId, shapeId } = emitBoxProduct(
solid.points,
base - structure.baseElevation,
solid.height,
structure.placementId,
);
const entityId = emitBuildingElement(
"IFCBUILDINGELEMENTPROXY",
ifcGuid(solid.id),
"Extrusion",
placementId,
shapeId,
null,
);
addToContainment(structure.entityId, entityId);
}
// ── Räumliche Eingliederung (gebündelt je Trägerstruktur) ──────────────
for (const [structureId, elementIds] of containment) {
w.add(
"IFCRELCONTAINEDINSPATIALSTRUCTURE",
`${S(ifcGuid(`contain:${structureId}`))},#${ownerHistory},$,$,${LIST(elementIds.map((id) => `#${id}`))},#${structureId}`,
);
}
// ── Kopf + Zusammenbau ───────────────────────────────────────────────────
const iso = new Date().toISOString().replace(/\.\d+Z$/, "");
const fileName = `${project.name || "modell"}.ifc`;
const header = [
"ISO-10303-21;",
"HEADER;",
`FILE_DESCRIPTION(${LIST([S("")])},${S("2;1")});`,
`FILE_NAME(${S(fileName)},${S(iso)},${LIST([S("dossier")])},${LIST([S("dossier")])},${S("dossier")},${S("dossier")},${S("")});`,
"FILE_SCHEMA(('IFC4'));",
"ENDSEC;",
"",
"DATA;",
];
const footer = ["ENDSEC;", "END-ISO-10303-21;"];
return [...header, ...w.entityLines, ...footer].join("\n") + "\n";
}
+306
View File
@@ -0,0 +1,306 @@
// Unit-Tests für den STL-/OBJ-Mesh-Export (reines Modul, siehe exportMesh.ts).
// • OBJ: ≥1 v/f, alle f-Indizes innerhalb der Vertexzahl, alle Koordinaten endlich.
// • STL: solid/endsolid-Rahmen, Facettenzahl = Dreieckszahl, je Facette genau 3 vertex-Zeilen.
// • Dreieckszahl-Plausibilität: isolierte Wand-Box (12) und isoliertes N-Eck-Prisma (4N-4).
// • leeres Projekt ⇒ gültige leere Datei (kein Crash).
import { describe, it, expect } from "vitest";
import { exportObj, exportStl } from "./exportMesh";
import type { Project, Wall, Ceiling, ExtrudedSolid, Opening } from "../model/types";
/** Gemeinsame Ressourcen-Basis (Komponente/Wandtyp/Deckentyp/Geschoss/Ebene). */
function baseProject(): Project {
return {
id: "t",
name: "T",
lineStyles: [],
hatches: [],
components: [{ id: "c", name: "C", color: "#ccc", hatchId: "none", joinPriority: 10 }],
wallTypes: [{ id: "aw", name: "Aussenwand", layers: [{ componentId: "c", thickness: 0.4 }] }],
ceilingTypes: [{ id: "dt", name: "Betondecke", layers: [{ componentId: "c", thickness: 0.2 }] }],
drawingLevels: [
{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0, baseElevation: 0 },
],
layers: [{ code: "20", name: "Wände", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }],
walls: [],
doors: [],
openings: [],
ceilings: [],
stairs: [],
extrudedSolids: [],
rooms: [],
drawings2d: [],
context: [],
} as Project;
}
/** Eine einzelne, frei stehende Wand (kein Nachbar ⇒ kein Gehrungs-/Anschlussschnitt). */
function projectWithOneWall(): Project {
const p = baseProject();
const wall: Wall = {
id: "W1",
type: "wall",
floorId: "eg",
categoryCode: "20",
start: { x: 0, y: 0 },
end: { x: 5, y: 0 },
wallTypeId: "aw",
height: 2.6,
};
p.walls = [wall];
return p;
}
/**
* Eine frei stehende Wand mit EINER Öffnung (Fenster ODER Tür). Das Fenster
* (sillHeight>0) liegt vollständig im Wand-Inneren (vier Laibungen); die Tür
* (sillHeight 0) berührt die Wand-UK (keine untere Laibung).
*/
function projectWithOpening(kind: "window" | "door"): Project {
const p = projectWithOneWall();
const opening: Opening = {
id: kind === "window" ? "F1" : "T1",
type: "opening",
hostWallId: "W1",
categoryCode: "21",
kind,
position: 2,
width: 1,
height: kind === "window" ? 1.5 : 2.1,
sillHeight: kind === "window" ? 0.9 : 0,
};
p.openings = [opening];
return p;
}
/** Eine einzelne Extrusion mit N-Eck-Profil (kein Wand-/Deckenkontext). */
function projectWithPolygonExtrusion(pts: { x: number; y: number }[], height = 2.5): Project {
const p = baseProject();
const solid: ExtrudedSolid = {
id: "E1",
type: "extrudedSolid",
levelId: "eg",
points: pts,
height,
};
p.extrudedSolids = [solid];
return p;
}
/** Kombiniertes Fixture-Projekt: 2 Wände (Eckstoss), 1 Decke, 1 Extrusion. */
function fixtureProject(): Project {
const p = baseProject();
const walls: Wall[] = [
{
id: "W1",
type: "wall",
floorId: "eg",
categoryCode: "20",
start: { x: 0, y: 0 },
end: { x: 5, y: 0 },
wallTypeId: "aw",
height: 2.6,
},
{
id: "W2",
type: "wall",
floorId: "eg",
categoryCode: "20",
start: { x: 5, y: 0 },
end: { x: 5, y: 4 },
wallTypeId: "aw",
height: 2.6,
},
];
const ceilings: Ceiling[] = [
{
id: "D1",
type: "ceiling",
floorId: "eg",
categoryCode: "30",
outline: [
{ x: 0, y: 0 },
{ x: 5, y: 0 },
{ x: 5, y: 4 },
{ x: 0, y: 4 },
],
wallTypeId: "dt",
ceilingTypeId: "dt",
},
];
const extrudedSolids: ExtrudedSolid[] = [
{
id: "E1",
type: "extrudedSolid",
levelId: "eg",
points: [
{ x: 0, y: 0 },
{ x: 2, y: 0 },
{ x: 2, y: 3 },
{ x: 0, y: 3 },
],
height: 2.5,
},
];
p.walls = walls;
p.ceilings = ceilings;
p.extrudedSolids = extrudedSolids;
return p;
}
/** Parst die `v`-Zeilen eines OBJ-Strings zu Koordinaten-Tripeln. */
function parseObjVertices(obj: string): number[][] {
return obj
.split("\n")
.filter((l) => l.startsWith("v "))
.map((l) => l.slice(2).trim().split(/\s+/).map(Number));
}
/** Parst die `f`-Zeilen eines OBJ-Strings zu 1-basierten Index-Tripeln. */
function parseObjFaces(obj: string): number[][] {
return obj
.split("\n")
.filter((l) => l.startsWith("f "))
.map((l) => l.slice(2).trim().split(/\s+/).map(Number));
}
describe("exportObj — Wavefront-OBJ-Export", () => {
it("liefert ≥1 v und ≥1 f, alle f-Indizes innerhalb der Vertexzahl, alle Koordinaten endlich", () => {
const obj = exportObj(fixtureProject());
const verts = parseObjVertices(obj);
const faces = parseObjFaces(obj);
expect(verts.length).toBeGreaterThan(0);
expect(faces.length).toBeGreaterThan(0);
for (const v of verts) {
expect(v).toHaveLength(3);
for (const c of v) expect(Number.isFinite(c)).toBe(true);
}
for (const f of faces) {
expect(f).toHaveLength(3);
for (const idx of f) {
expect(idx).toBeGreaterThanOrEqual(1);
expect(idx).toBeLessThanOrEqual(verts.length);
}
}
});
it("gruppiert Bauteil-Vorkommen als o-Objekte (Wand/Decke/Extrusion)", () => {
const obj = exportObj(fixtureProject());
expect(obj).toContain("o Wand_W1");
expect(obj).toContain("o Wand_W2");
expect(obj).toContain("o Decke_D1");
expect(obj).toContain("o Extrusion_E1");
});
it("leeres Projekt ⇒ gültige Datei ohne v/f (kein Crash)", () => {
const obj = exportObj(baseProject());
expect(obj.split("\n").some((l) => l.startsWith("v "))).toBe(false);
expect(obj.split("\n").some((l) => l.startsWith("f "))).toBe(false);
expect(obj.length).toBeGreaterThan(0);
});
});
describe("exportStl — ASCII-STL-Export", () => {
it("hat solid/endsolid-Rahmen, je Facette genau 3 vertex-Zeilen, Facettenzahl = Dreieckszahl", () => {
const stl = exportStl(fixtureProject());
const lines = stl.split("\n").filter((l) => l.length > 0);
expect(lines[0]).toBe("solid dossier");
expect(lines[lines.length - 1]).toBe("endsolid dossier");
const facetCount = lines.filter((l) => l.startsWith("facet normal")).length;
const loopCount = lines.filter((l) => l === "outer loop").length;
const endloopCount = lines.filter((l) => l === "endloop").length;
const endfacetCount = lines.filter((l) => l === "endfacet").length;
const vertexCount = lines.filter((l) => l.startsWith("vertex ")).length;
expect(facetCount).toBeGreaterThan(0);
expect(loopCount).toBe(facetCount);
expect(endloopCount).toBe(facetCount);
expect(endfacetCount).toBe(facetCount);
expect(vertexCount).toBe(facetCount * 3);
});
it("leeres Projekt ⇒ gültiges leeres solid-Gerüst (kein Crash)", () => {
const stl = exportStl(baseProject());
expect(stl).toBe("solid dossier\nendsolid dossier\n");
});
it("Dreieckszahl-Plausibilität: eine frei stehende Wand ergibt genau 1 Box (12 Dreiecke, 8 Ecken)", () => {
const stl = exportStl(projectWithOneWall());
const facetCount = stl.split("\n").filter((l) => l.startsWith("facet normal")).length;
expect(facetCount).toBe(12);
const obj = exportObj(projectWithOneWall());
expect(parseObjVertices(obj)).toHaveLength(8);
});
it("Dreieckszahl-Plausibilität: ein N-Eck-Profil ergibt ein Prisma mit 4N-4 Dreiecken (2N Ecken)", () => {
// Rechteck (N=4): 2 Kappen à 2 Dreiecke + 4 Seitenquads à 2 Dreiecke = 12 = 4·44.
const rectStl = exportStl(
projectWithPolygonExtrusion([
{ x: 0, y: 0 },
{ x: 2, y: 0 },
{ x: 2, y: 1 },
{ x: 0, y: 1 },
]),
);
expect(rectStl.split("\n").filter((l) => l.startsWith("facet normal")).length).toBe(12);
// Konvexes Fünfeck (N=5): 4·54 = 16 Dreiecke, 10 Ecken.
const pentagon = [
{ x: 0, y: 0 },
{ x: 2, y: 0 },
{ x: 2.5, y: 1.5 },
{ x: 1, y: 2.5 },
{ x: -0.5, y: 1.5 },
];
const pentaStl = exportStl(projectWithPolygonExtrusion(pentagon));
expect(pentaStl.split("\n").filter((l) => l.startsWith("facet normal")).length).toBe(16);
const pentaObj = exportObj(projectWithPolygonExtrusion(pentagon));
expect(parseObjVertices(pentaObj)).toHaveLength(10);
});
it("Fenster wird als echtes Loch ausgeschnitten (Gitter-Zerlegung + 4 Laibungen, 48 Dreiecke statt 12)", () => {
// Fenster [x 2..3] × [y 0.9..2.4] vollständig im Wand-Inneren. Erwartung
// (= Rust-Zerlegung `extrude_layer_segment_with_holes`): 8 solide Langseiten-
// Teilrechtecke (3×3-Gitter minus Loch) × 2 Seiten × 2 Dreiecke = 32,
// + Deckel/Boden/2 Stirnkappen = 8, + 4 Laibungen × 2 = 8 → 48 Dreiecke.
const stl = exportStl(projectWithOpening("window"));
const facetCount = stl.split("\n").filter((l) => l.startsWith("facet normal")).length;
expect(facetCount).toBe(48);
// Die volle Box hätte NUR Ecken bei y∈{0,2.6}, x∈{0,5}. Der Ausschnitt
// führt echte Vertices an den Loch-Rändern ein — Beweis, dass das Loch
// wirklich in der Fläche steckt (nicht bloss eine übergelegte Scheibe).
const verts = parseObjVertices(exportObj(projectWithOpening("window")));
const near = (a: number, b: number) => Math.abs(a - b) < 1e-6;
expect(verts.some((v) => near(v[1], 0.9))).toBe(true); // Brüstungs-OK
expect(verts.some((v) => near(v[1], 2.4))).toBe(true); // Sturz-UK
expect(verts.some((v) => near(v[0], 2))).toBe(true); // linke Loch-Kante
expect(verts.some((v) => near(v[0], 3))).toBe(true); // rechte Loch-Kante
// Deutlich mehr Vertices als die 8-Ecken-Vollbox.
expect(verts.length).toBe(144);
});
it("Tür berührt die Wand-UK: keine untere Laibung (36 Dreiecke, keine Brüstungs-Zerlegung)", () => {
// Tür [x 2..3] × [y 0..2.1]: Boden bekommt die Lücke [2,3] (statt einer
// unteren Laibung) → 5 Langseiten-Teilrechtecke × 2 × 2 = 20, Deckel(1)=2,
// Boden(2 Streifen)=4, 2 Stirnkappen=4, 3 Laibungen (links/rechts/oben)=6 → 36.
const stl = exportStl(projectWithOpening("door"));
const facetCount = stl.split("\n").filter((l) => l.startsWith("facet normal")).length;
expect(facetCount).toBe(36);
const verts = parseObjVertices(exportObj(projectWithOpening("door")));
const near = (a: number, b: number) => Math.abs(a - b) < 1e-6;
// Sturz-UK bei y=2.1 vorhanden, aber KEIN Brüstungsvertex (Tür sitzt am Boden).
expect(verts.some((v) => near(v[1], 2.1))).toBe(true);
expect(verts.every((v) => v[1] >= -1e-6 && v[1] <= 2.6 + 1e-6)).toBe(true);
});
it("facet-Normalen sind Einheitsvektoren aus dem Kreuzprodukt der Dreiecksecken", () => {
const stl = exportStl(projectWithOneWall());
const facetLines = stl.split("\n").filter((l) => l.startsWith("facet normal"));
for (const line of facetLines) {
const [nx, ny, nz] = line.slice("facet normal ".length).trim().split(/\s+/).map(Number);
const len = Math.hypot(nx, ny, nz);
expect(len).toBeGreaterThan(0.99);
expect(len).toBeLessThan(1.01);
}
});
});
+353
View File
@@ -0,0 +1,353 @@
// OBJ-/STL-Export des 3D-Gebäudemodells als reines Dreiecks-Mesh. Pures Modul
// (kein Datei-IO, kein WASM) — Architektur wie exportDxf.ts/exportSchedule.ts:
// buildGroups() liest das Projekt und liefert reine Zahlen-Arrays, exportObj()/
// exportStl() serialisieren sie synchron zu Text.
//
// TRIANGEL-QUELLE (bewusste v1-Entscheidung, siehe PENDENZEN):
// • Wände: pickGeometry() aus toWalls3d.ts liefert dieselben Schicht-Bänder,
// die auch der 3D-Viewer zeichnet (EIN RWall je Materiallage/Achsen-
// Teilstück, bereits mit End-Cuts/Gehrungen/Decken-Dominanz aufgelöst).
// Bänder mit Öffnungs-`holes` werden über den gemeinsamen Loch-Ausschnitt-
// Generator (`plan/wallMeshCut.ts`, TS-Port von render3ds Gitter-Zerlegung
// `extrude_layer_segment_with_holes`) ÖFFNUNGSGENAU trianguliert — die
// Fenster/Türen sind sichtbar ausgestanzt inkl. Laibungsflächen, genau wie
// im 3D-Viewer. Lochlose Bänder bleiben die klassische geteilte 8-Ecken-Box.
// • Decken: pickGeometry() liefert den Umriss + zBottom/zTop je Decke;
// der Umriss wird mit dem vorhandenen Ear-Clipping-Triangulator
// (`triangulate` aus plan/glPlan/glPlanCompile.ts, bereits getestet und
// konkav-fähig) zu Boden-/Deckenkappen trianguliert, dazwischen ein
// Seitenmantel — ein einfaches Umriss-Prisma.
// • ExtrudedSolids (truck-Profile): `src/engine/truckSolid.ts` liefert zwar
// "echte" truck-Dreiecke, ABER nur asynchron über WASM
// (`extrudePolygon`/`extrudeCircle` geben Promises zurück). Das würde die
// geforderte SYNCHRONE, WASM-freie Signatur `exportObj(project): string`
// brechen. Deshalb nutzt dieser Export stattdessen DIESELBE Prisma-
// Triangulierung wie die Decken (Umriss aus `solid.points` — bei
// Kreisprofilen bereits eine 48-Eck-Tessellierung, siehe
// `ExtrudedSolid.points`-Doc) inkl. `taper`-Verjüngung (linear zum
// Schwerpunkt skaliert, wie die truck-Extrusion es tut). Das ist eine
// bewusste Abweichung vom ursprünglichen Plan ("truckSolid-Dreiecke") zu-
// gunsten der pure-Modul-Architektur — ehrlich dokumentiert, kein
// Stillschweigen.
//
// ACHSEN-KONVENTION: identisch zur bestehenden 3D-Pick-/Highlight-Geometrie
// (siehe `selectionHighlightLines` in toWalls3d.ts): Modell (x, y) → Welt
// (x, Höhe, y), Y-UP, rechtshändig. Ein Weltvertex (vx,vy,vz) entspricht also
// (Modell-x, Meter-Höhe, Modell-y) — exakt wie im 3D-Viewer.
import type { Project, Vec2 } from "../model/types";
import { getFloor } from "../model/types";
import { pickGeometry } from "../plan/toWalls3d";
import type { RWall } from "../plan/toWalls3d";
import { wallCutMesh } from "../plan/wallMeshCut";
import { triangulate } from "../plan/glPlan/glPlanCompile";
/**
* Eine Mesh-Gruppe (ein Bauteil-Vorkommen: Wand/Decke/Extrusion) mit LOKALEN
* (0-basierten) Vertex-/Dreiecks-Indizes. `exportObj` verschiebt die Indizes
* beim Serialisieren auf die globale, 1-basierte OBJ-Nummerierung; `exportStl`
* braucht gar keine Indizes über Gruppen hinweg (STL ist unindiziert).
*/
interface MeshGroup {
name: string;
/** Flaches Weltkoordinaten-Array (x,y,z, x,y,z, …), Meter, Y-up. */
positions: number[];
/** Dreiecks-Indizes (3 je Dreieck), 0-basiert relativ zu `positions`. */
indices: number[];
}
function newGroup(name: string): MeshGroup {
return { name, positions: [], indices: [] };
}
/** OBJ-Gruppennamen dürfen keine Whitespaces/Sonderzeichen enthalten. */
function sanitizeName(id: string): string {
return id.replace(/[^A-Za-z0-9_-]/g, "_") || "x";
}
/**
* Die 6 Quader-Seiten als Eckpunkt-Quadrupel (Index-Bits: bit0=Achse /+,
* bit1=Höhe /+, bit2=Dicke /+ — dieselbe Konvention wie `emitOpeningGlass`/
* `selectionHighlightLines` in toWalls3d.ts), in nach AUSSEN gewundener
* Reihenfolge (für ein rechtshändiges Dreibein Achse×Höhe=Dicke-Normale, wie
* es die Wandbox-Konstruktion unten aufspannt). Jedes Quadrupel wird zu 2
* Dreiecken (0,1,2)+(0,2,3).
*/
const BOX_QUADS: Array<[number, number, number, number]> = [
[4, 6, 7, 5], // Dicke +
[1, 3, 2, 0], // Dicke
[2, 3, 7, 6], // Höhe + (Wandkopf)
[4, 5, 1, 0], // Höhe (Wand-UK)
[1, 5, 7, 3], // Achse + (Wandende)
[2, 6, 4, 0], // Achse (Wandanfang)
];
/**
* Hängt EIN Wand-Schicht-Band (`RWall`, siehe toWalls3d.ts) an eine Mesh-Gruppe
* an: hat das Band echte Öffnungs-`holes`, wird das öffnungsgenaue Loch-Ausschnitt-
* Mesh (siehe {@link wallCutMesh}) verwendet — die Fenster/Türen sind sichtbar
* ausgestanzt inkl. Laibungen, exakt wie im 3D-Viewer. Ohne Löcher bleibt es die
* klassische, geteilte 8-Ecken-Box ({@link pushWallBox}) — bitgleich zum
* bisherigen Verhalten (Joins/Gehrungen stecken schon in `start`/`end`).
*/
function pushWall(group: MeshGroup, w: RWall): void {
if (w.holes && w.holes.length > 0) {
const cut = wallCutMesh(w);
const base = group.positions.length / 3;
for (const p of cut.positions) group.positions.push(p);
for (const idx of cut.indices) group.indices.push(base + idx);
return;
}
pushWallBox(group, w);
}
/**
* Hängt EINEN vollen Wand-Schicht-Quader (`RWall`) an eine Mesh-Gruppe an — Box
* über Achse×Höhe×Dicke, OHNE Öffnungs-Ausschnitt (nur für lochlose Bänder, siehe
* {@link pushWall}). Degenerierte Bänder (Länge/Höhe/Dicke ≤ 0) werden übersprungen.
*/
function pushWallBox(group: MeshGroup, w: RWall): void {
const [sx, sy] = w.start;
const [ex, ey] = w.end;
const dx = ex - sx;
const dz = ey - sy;
const len = Math.hypot(dx, dz);
if (len < 1e-9 || w.height <= 1e-9 || w.thickness <= 1e-9) return;
const ax = dx / len;
const az = dz / len;
// Normale = Welt-Hoch × Achse (rechtshändig), dieselbe Konvention wie
// `selectionHighlightLines`: normal = (az, 0, ax).
const nx = az;
const nz = -ax;
const halfLen = len / 2;
const halfHeight = w.height / 2;
const halfThick = w.thickness / 2;
const cx = (sx + ex) / 2;
const cz = (sy + ey) / 2;
const cy = w.baseElevation + w.height / 2;
const base = group.positions.length / 3;
for (let i = 0; i < 8; i++) {
const sL = i & 1 ? 1 : -1;
const sH = i & 2 ? 1 : -1;
const sT = i & 4 ? 1 : -1;
group.positions.push(
cx + sL * halfLen * ax + sT * halfThick * nx,
cy + sH * halfHeight,
cz + sL * halfLen * az + sT * halfThick * nz,
);
}
for (const [a, b, c, d] of BOX_QUADS) {
group.indices.push(base + a, base + b, base + c, base + a, base + c, base + d);
}
}
/** Signierte Fläche eines Modell-XY-Umrisses (Shoelace); ≥0 = CCW. */
function signedAreaXY(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;
}
/**
* Hängt ein extrudiertes Umriss-Prisma (Decke ODER truck-Profil-Extrusion,
* siehe Moduldoc) an eine Mesh-Gruppe an: Boden-/Deckkappe (Ear-Clipping-
* Triangulierung via `triangulate`) + Seitenmantel. `taper` (0..1) verjüngt
* den OBEREN Ring linear zum Flächenschwerpunkt — dieselbe Semantik wie
* `ExtrudedSolid.taper`/die truck-Extrusion (0 = Prisma, 1 = Spitze/Pyramide).
* Der Umriss wird intern auf CCW (Modell-XY) normalisiert, damit die
* Seitenmantel-Normalen unabhängig von der Eingabe-Wicklung nach außen zeigen.
*/
function pushPrism(
group: MeshGroup,
outline: Vec2[],
zBottom: number,
zTop: number,
taper = 0,
): void {
if (outline.length < 3 || zTop - zBottom <= 1e-9) return;
const ccw = signedAreaXY(outline) >= 0 ? outline : [...outline].reverse();
const n = ccw.length;
let cx = 0;
let cyModel = 0;
for (const p of ccw) {
cx += p.x;
cyModel += p.y;
}
cx /= n;
cyModel /= n;
const t = Math.min(1, Math.max(0, taper));
const base = group.positions.length / 3;
// Unterer Ring (base + 0..n-1), oberer Ring (base + n..2n-1).
for (const p of ccw) group.positions.push(p.x, zBottom, p.y);
for (const p of ccw) {
const tx = cx + (p.x - cx) * (1 - t);
const ty = cyModel + (p.y - cyModel) * (1 - t);
group.positions.push(tx, zTop, ty);
}
// Kappen: `triangulate` liefert bei CCW-Eingabe stets CCW-in-Modell-XY-
// Dreiecke — eingebettet in die Weltebene (x, const, y) entspricht das einer
// nach UNTEN (Y) zeigenden Flächennormale (Kreuzprodukt-Herleitung siehe
// Moduldoc-Kommentar oben). Die Bodenkappe nutzt diese Wicklung direkt
// (Normale zeigt nach unten = nach außen), die Deckkappe braucht die
// umgekehrte Wicklung (Normale nach oben).
const capTris = triangulate(ccw);
for (let i = 0; i < capTris.length; i += 3) {
const a = capTris[i];
const b = capTris[i + 1];
const c = capTris[i + 2];
group.indices.push(base + a, base + b, base + c);
group.indices.push(base + n + a, base + n + c, base + n + b);
}
// Seitenmantel: je Umrisskante ein Quad (2 Dreiecke), Wicklung so, dass die
// Normale nach außen zeigt (siehe Moduldoc-Herleitung).
for (let i = 0; i < n; i++) {
const j = (i + 1) % n;
const bi = base + i;
const bj = base + j;
const ti = base + n + i;
const tj = base + n + j;
group.indices.push(bi, ti, tj);
group.indices.push(bi, tj, bj);
}
}
/** Baut alle Mesh-Gruppen (Wände, Decken, Extrusionen) des Projekts. */
function buildGroups(project: Project): MeshGroup[] {
const groups: MeshGroup[] = [];
const { walls, slabs } = pickGeometry(project);
// Wände: alle Schicht-Bänder DERSELBEN Wand-Id in EINE Gruppe (mehrere
// Materiallagen ergeben mehrere Boxen im selben Bauteil-Vorkommen).
const wallGroups = new Map<string, MeshGroup>();
for (const w of walls) {
let g = wallGroups.get(w.wallId);
if (!g) {
g = newGroup(`Wand_${sanitizeName(w.wallId)}`);
wallGroups.set(w.wallId, g);
groups.push(g);
}
pushWall(g, w);
}
// Decken: ein Prisma je Decke.
for (const s of slabs) {
const g = newGroup(`Decke_${sanitizeName(s.ceilingId)}`);
const outline: Vec2[] = s.outline.map(([x, y]) => ({ x, y }));
pushPrism(g, outline, s.zBottom, s.zTop);
groups.push(g);
}
// Extrudierte Profile (truck-Integration): eigenes Prisma je Körper (siehe
// Moduldoc — bewusst NICHT die truck-WASM-Dreiecke, aus Sync-/Pure-Gründen).
for (const solid of project.extrudedSolids ?? []) {
if (solid.points.length < 3 || solid.height <= 0) continue;
let baseZ = 0;
try {
baseZ = getFloor(project, solid.levelId).baseElevation ?? 0;
} catch {
// Geschoss nicht (mehr) auflösbar: bei UK 0 extrudieren (Fallback, wie toWalls3d).
}
const g = newGroup(`Extrusion_${sanitizeName(solid.id)}`);
pushPrism(g, solid.points, baseZ, baseZ + solid.height, solid.taper ?? 0);
groups.push(g);
}
return groups.filter((g) => g.indices.length > 0);
}
/** 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);
return s.replace(/(\.\d*?)0+$/, "$1").replace(/\.$/, ".0");
}
/**
* Baut einen vollständigen Wavefront-OBJ-String des 3D-Gebäudemodells (Meter,
* rechtshändig, Y-up — siehe Moduldoc). Ein `o`-Objekt je Bauteil-Vorkommen
* (Wand/Decke/Extrusion). Leeres Projekt ⇒ gültige Datei nur mit Kopf-
* Kommentaren, kein `v`/`f` (kein Crash).
*/
export function exportObj(project: Project): string {
const groups = buildGroups(project);
const lines: string[] = [
"# DOSSIER OBJ-Export - Dreiecks-Mesh des 3D-Gebaeudemodells",
"# Achsen: rechtshaendig, Y-up (Y = Hoehe ueber OKFF), wie der 3D-Viewer.",
"# Waende: Schicht-Baender mit oeffnungsgenau ausgeschnittenen Loechern (inkl. Laibungen).",
"# Decken/Extrusionen: Umriss-Prisma (Ear-Clipping-Triangulierung).",
];
let offset = 0;
for (const g of groups) {
const vertCount = g.positions.length / 3;
if (vertCount === 0) continue;
lines.push(`o ${g.name}`);
for (let i = 0; i < g.positions.length; i += 3) {
lines.push(`v ${fmt(g.positions[i])} ${fmt(g.positions[i + 1])} ${fmt(g.positions[i + 2])}`);
}
for (let i = 0; i < g.indices.length; i += 3) {
const a = g.indices[i] + 1 + offset;
const b = g.indices[i + 1] + 1 + offset;
const c = g.indices[i + 2] + 1 + offset;
lines.push(`f ${a} ${b} ${c}`);
}
offset += vertCount;
}
return lines.join("\n") + "\n";
}
/**
* Baut einen vollständigen ASCII-STL-String des 3D-Gebäudemodells (Meter,
* dieselbe Achsen-Konvention wie {@link exportObj}). Jedes Dreieck trägt eine
* aus dem Kreuzprodukt berechnete Facettennormale (konsistente Winding, siehe
* `pushWallBox`/`pushPrism`). Leeres Projekt ⇒ gültiges leeres `solid`-Gerüst.
*/
export function exportStl(project: Project): string {
const groups = buildGroups(project);
const lines: string[] = ["solid dossier"];
for (const g of groups) {
for (let i = 0; i < g.indices.length; i += 3) {
const ia = g.indices[i] * 3;
const ib = g.indices[i + 1] * 3;
const ic = g.indices[i + 2] * 3;
const ax = g.positions[ia];
const ay = g.positions[ia + 1];
const az = g.positions[ia + 2];
const bx = g.positions[ib];
const by = g.positions[ib + 1];
const bz = g.positions[ib + 2];
const cx = g.positions[ic];
const cy = g.positions[ic + 1];
const cz = g.positions[ic + 2];
const ux = bx - ax;
const uy = by - ay;
const uz = bz - az;
const vx = cx - ax;
const vy = cy - ay;
const vz = cz - az;
let nx = uy * vz - uz * vy;
let ny = uz * vx - ux * vz;
let nz = ux * vy - uy * vx;
const len = Math.hypot(nx, ny, nz);
if (len > 1e-12) {
nx /= len;
ny /= len;
nz /= len;
} else {
nx = 0;
ny = 0;
nz = 0;
}
lines.push(`facet normal ${fmt(nx)} ${fmt(ny)} ${fmt(nz)}`);
lines.push("outer loop");
lines.push(`vertex ${fmt(ax)} ${fmt(ay)} ${fmt(az)}`);
lines.push(`vertex ${fmt(bx)} ${fmt(by)} ${fmt(bz)}`);
lines.push(`vertex ${fmt(cx)} ${fmt(cy)} ${fmt(cz)}`);
lines.push("endloop");
lines.push("endfacet");
}
}
lines.push("endsolid dossier");
return lines.join("\n") + "\n";
}
+95 -15
View File
@@ -1,14 +1,18 @@
// Unit-Tests für die Bauteilliste (Bauteil-Schedule CSV, AUDIT A6).
// • Kopfzeile + eine Zeile je Bauteil-Vorkommen (2 Wände + 1 Decke).
// Unit-Tests für die Bauteilliste (Bauteil-Schedule CSV, AUDIT A6 + D2).
// • Kopfzeile + eine Zeile je Bauteil-Vorkommen (2 Wände, 1 Decke, 1 Tür,
// 1 Fenster, 1 Treppe, 1 Extrusion).
// • korrekte Kennwerte (Länge/Höhe/Dicke/Fläche) aus dem Modell abgeleitet.
// • CSV-Escaping bei einem Typnamen mit Semikolon/Anführungszeichen.
// • leeres Projekt ⇒ nur Kopfzeile (kein Crash).
import { describe, it, expect } from "vitest";
import { exportScheduleCsv, scheduleRows } from "./exportSchedule";
import type { Project, Wall, Ceiling } from "../model/types";
import type { Project, Wall, Ceiling, Door, Opening, Stair, ExtrudedSolid } from "../model/types";
/** Minimalprojekt: 2 Wände (1 Wandtyp T=0.4) + 1 Decke (Deckentyp T=0.2). */
/**
* Minimalprojekt: 2 Wände (1 Wandtyp T=0.4) + 1 Decke (Deckentyp T=0.2) +
* 1 Tür + 1 Fenster (je an W1 gehostet) + 1 Treppe + 1 Extrusion.
*/
function fixtureProject(): Project {
const walls: Wall[] = [
{
@@ -48,6 +52,61 @@ function fixtureProject(): Project {
ceilingTypeId: "dt",
},
];
const doors: Door[] = [
{
id: "T1",
type: "door",
hostWallId: "W1",
categoryCode: "21",
position: 1,
width: 0.9,
height: 2.1, // Fläche 1.89
swing: "left",
hinge: "start",
},
];
const openings: Opening[] = [
{
id: "F1",
type: "opening",
hostWallId: "W2",
categoryCode: "21",
kind: "window",
position: 1,
width: 1.2,
height: 1.5, // Fläche 1.80
sillHeight: 0.9,
},
];
const stairs: Stair[] = [
{
id: "S1",
type: "stair",
floorId: "eg",
categoryCode: "40",
shape: "straight",
start: { x: 0, y: 0 },
dir: { x: 1, y: 0 },
runLength: 3, // Länge 3 (kein 2. Lauf)
width: 1.2,
totalRise: 2.6,
stepCount: 16,
},
];
const extrudedSolids: ExtrudedSolid[] = [
{
id: "E1",
type: "extrudedSolid",
levelId: "eg",
points: [
{ x: 0, y: 0 },
{ x: 2, y: 0 },
{ x: 2, y: 3 },
{ x: 0, y: 3 },
], // 2×3 = 6 m²
height: 2.5,
},
];
return {
id: "t",
name: "T",
@@ -61,10 +120,11 @@ function fixtureProject(): Project {
],
layers: [{ code: "20", name: "Wände", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }],
walls,
doors: [],
openings: [],
doors,
openings,
ceilings,
stairs: [],
stairs,
extrudedSolids,
rooms: [],
drawings2d: [],
context: [],
@@ -76,8 +136,8 @@ describe("exportScheduleCsv — Bauteilliste", () => {
const csv = exportScheduleCsv(fixtureProject(), { includeSummary: false });
const lines = csv.split("\r\n");
// Kopfzeile + 3 Bauteil-Zeilen (2 Wände, 1 Decke).
expect(lines).toHaveLength(4);
// Kopfzeile + 7 Bauteil-Zeilen (2 Wände, 1 Decke, 1 Tür, 1 Fenster, 1 Treppe, 1 Extrusion).
expect(lines).toHaveLength(8);
expect(lines[0]).toBe("Typ;ID;Bauteil;Geschoss;Länge [m];Höhe [m];Dicke [m];Fläche [m²]");
// Wand W1: Länge 5, Höhe 2.6, Dicke 0.4, Ansichtsfläche 13.
@@ -86,19 +146,35 @@ describe("exportScheduleCsv — Bauteilliste", () => {
expect(lines[2]).toBe("Wand;W2;Aussenwand;EG;4.00;2.60;0.40;10.40");
// Decke D1: keine Länge/Höhe, Dicke 0.2, Grundrissfläche 20.
expect(lines[3]).toBe("Decke;D1;Betondecke;EG;;;0.20;20.00");
// Tür T1: Breite 0.9, Höhe 2.1, keine Dicke, Fläche 1.89.
expect(lines[4]).toBe("Tür;T1;Tür;EG;0.90;2.10;;1.89");
// Fenster F1: Breite 1.2, Höhe 1.5, keine Dicke, Fläche 1.80.
expect(lines[5]).toBe("Fenster;F1;Fenster;EG;1.20;1.50;;1.80");
// Treppe S1: Lauflänge 3 (kein 2. Lauf), Steighöhe 2.6, keine Dicke/Fläche.
expect(lines[6]).toBe("Treppe;S1;Treppe;EG;3.00;2.60;;");
// Extrusion E1: keine Länge, Höhe 2.5, keine Dicke, Grundrissfläche 6.
expect(lines[7]).toBe("Extrusion;E1;Extrusion;EG;;2.50;;6.00");
});
it("hängt bei includeSummary einen Aggregat-Block je Bauteil-Typ an", () => {
const csv = exportScheduleCsv(fixtureProject());
const lines = csv.split("\r\n");
// Kopf(1) + 3 Zeilen + Leerzeile + Titel + 2 Aggregate (Aussenwand, Betondecke) = 8.
expect(lines).toHaveLength(8);
expect(lines[4]).toBe("");
expect(lines[5]).toContain("Zusammenfassung");
// Kopf(1) + 7 Zeilen + Leerzeile + Titel + 6 Aggregate = 16.
expect(lines).toHaveLength(16);
expect(lines[8]).toBe("");
expect(lines[9]).toContain("Zusammenfassung");
// Wand-Aggregat: 2 Stk, Gesamtlänge 9, Gesamtfläche 23.40.
expect(lines[6]).toBe("Wand;;Aussenwand;2 Stk;9.00;;;23.40");
expect(lines[10]).toBe("Wand;;Aussenwand;2 Stk;9.00;;;23.40");
// Decken-Aggregat: 1 Stk, keine Länge, Fläche 20.
expect(lines[7]).toBe("Decke;;Betondecke;1 Stk;;;;20.00");
expect(lines[11]).toBe("Decke;;Betondecke;1 Stk;;;;20.00");
// Tür-Aggregat: 1 Stk, Gesamtbreite 0.9, Fläche 1.89.
expect(lines[12]).toBe("Tür;;Tür;1 Stk;0.90;;;1.89");
// Fenster-Aggregat: 1 Stk, Gesamtbreite 1.2, Fläche 1.80.
expect(lines[13]).toBe("Fenster;;Fenster;1 Stk;1.20;;;1.80");
// Treppen-Aggregat: 1 Stk, Gesamtlänge 3, keine bekannte Fläche (leer statt 0.00).
expect(lines[14]).toBe("Treppe;;Treppe;1 Stk;3.00;;;");
// Extrusions-Aggregat: 1 Stk, keine Länge, Fläche 6.
expect(lines[15]).toBe("Extrusion;;Extrusion;1 Stk;;;;6.00");
});
it("quotet Felder mit Sonderzeichen (Semikolon/Anführungszeichen) RFC-4180-konform", () => {
@@ -114,6 +190,10 @@ describe("exportScheduleCsv — Bauteilliste", () => {
const proj = fixtureProject();
proj.walls = [];
proj.ceilings = [];
proj.doors = [];
proj.openings = [];
proj.stairs = [];
proj.extrudedSolids = [];
const csv = exportScheduleCsv(proj);
expect(csv.split("\r\n")).toHaveLength(1);
expect(scheduleRows(proj)).toHaveLength(0);
Binary file not shown.
+181
View File
@@ -0,0 +1,181 @@
// Ordner → Mehrseiten-PDF (DOSSIER A3): exportiert alle Layouts eines Ordners
// als EIN gemeinsames PDF, jedes Layout = eine Seite. Das Rendering je Seite
// spiegelt exakt `ui/LayoutSheet.tsx` (Master-Rahmen + Titelblock + echte
// Viewport-Pläne via `generatePlan`→`planToPrintSvg`) und komponiert die Seiten
// per jsPDF (`addPage` je Layout) analog `export/exportPdf.ts` (buildPlanPdf).
//
// Trennung: die Seiten-Sammlung/-Reihenfolge + effektive Blattgrösse ist reine,
// testbare Logik (`folderPdfPages`, layoutModel); das SVG/jsPDF-Rendering
// (`buildFolderPdf`) braucht ein DOM und läuft nur im Browser/Tauri.
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import { jsPDF } from "jspdf";
import "svg2pdf.js";
import type { MasterLayout, Project } from "../model/types";
import { generatePlan } from "../plan/generatePlan";
import { planToPrintSvg } from "./planToPrintSvg";
import { saveBinaryFile } from "../io/saveFile";
import {
findMaster,
findSnapshot,
folderPdfPages,
resolveTitleBlock,
resolveViewportScale,
visibleCodesFromSnapshot,
type FolderPdfPage,
type ResolvedTitleBlock,
} from "../panels/layoutModel";
export { folderPdfPages, type FolderPdfPage };
/** Titelblock (mm-Vektor) auf die aktuelle jsPDF-Seite — Anmutung wie LayoutSheet. */
function drawTitleBlock(
doc: jsPDF,
sheetW: number,
sheetH: number,
tb: ResolvedTitleBlock,
paper: string,
orientation: string,
): void {
const boxW = 70;
const boxH = 30;
const pad = 6;
const x = sheetW - boxW - pad;
const y = sheetH - boxH - pad;
const fmt = `${paper.toUpperCase()} ${orientation === "landscape" ? "quer" : "hoch"}`;
doc.setFillColor(255, 255, 255);
doc.setDrawColor(17, 17, 17);
doc.setLineWidth(0.35);
doc.rect(x, y, boxW, boxH, "FD");
doc.setLineWidth(0.2);
doc.line(x, y + 8, x + boxW, y + 8);
doc.line(x, y + boxH - 7, x + boxW, y + boxH - 7);
doc.setTextColor(17, 17, 17);
doc.setFont("helvetica", "bold");
doc.setFontSize(9);
doc.text(clip(tb.projectName, 34), x + 3, y + 5.6);
doc.setFont("helvetica", "normal");
doc.setFontSize(8);
doc.text(clip(tb.sheetName, 36), x + 3, y + 13);
if (tb.author) {
doc.setFontSize(7);
doc.setTextColor(68, 68, 68);
doc.text(clip(tb.author, 40), x + 3, y + 18);
doc.setTextColor(17, 17, 17);
}
doc.setFontSize(8);
doc.text(tb.scale, x + 3, y + boxH - 2.4);
doc.setFontSize(7);
doc.text(`${tb.date} · ${fmt}`, x + boxW - 3, y + boxH - 2.4, { align: "right" });
}
function clip(s: string, max: number): string {
return s.length > max ? s.slice(0, max - 1) + "…" : s;
}
/** Zeichnet die Viewports + Master-Elemente EINER Seite in das jsPDF-Dokument. */
async function renderPage(
doc: jsPDF,
project: Project,
page: FolderPdfPage,
master: MasterLayout | undefined,
): Promise<void> {
const { layout, widthMm, heightMm } = page;
// Echte Viewport-Pläne (wie ViewportPlan in LayoutSheet.tsx).
for (const vp of layout.viewports) {
const snap = findSnapshot(project, vp.snapshotId);
if (!snap) continue;
try {
const codes = visibleCodesFromSnapshot(snap);
const plan = generatePlan(
project,
snap.activeLevelId,
codes,
undefined,
snap.detail,
false,
false,
);
const scaleN = resolveViewportScale(vp, snap);
const print = planToPrintSvg(plan, {
scaleDenominator: scaleN,
pageWidthMm: vp.widthMm,
pageHeightMm: vp.heightMm,
});
await doc.svg(print.svg, {
x: vp.xMm,
y: vp.yMm,
width: vp.widthMm,
height: vp.heightMm,
});
} catch {
// Einzelner Viewport, der nicht rendert, darf die Seite nicht killen.
}
}
// Master: Rahmen + Titelblock (read-only, wie im In-Viewport-Editor).
if (master) {
if (master.border ?? true) {
doc.setDrawColor(17, 17, 17);
doc.setLineWidth(0.35);
doc.rect(5, 5, widthMm - 10, heightMm - 10);
}
const tb = resolveTitleBlock(project, layout, master);
drawTitleBlock(doc, widthMm, heightMm, tb, layout.paper, layout.orientation);
}
}
/**
* Baut das Mehrseiten-PDF eines Ordners und liefert das jsPDF-Dokument. Jede
* Seite = ein Layout in Baum-Reihenfolge. Gibt `null`, wenn der Ordner keine
* Layouts enthält (der Aufrufer meldet das dann in der UI).
*/
export async function buildFolderPdf(
project: Project,
folderId: string,
): Promise<jsPDF | null> {
const pages = folderPdfPages(project, folderId);
if (pages.length === 0) return null;
const orientOf = (p: FolderPdfPage) =>
p.widthMm > p.heightMm ? "landscape" : "portrait";
const first = pages[0];
const doc = new jsPDF({
unit: "mm",
format: [first.widthMm, first.heightMm],
orientation: orientOf(first),
compress: true,
});
for (let i = 0; i < pages.length; i++) {
const page = pages[i];
if (i > 0) doc.addPage([page.widthMm, page.heightMm], orientOf(page));
const master = findMaster(project, page.layout);
await renderPage(doc, project, page, master);
}
return doc;
}
/**
* Baut das Ordner-PDF und speichert es (nativer Tauri-Dialog bzw.
* Browser-Download). Liefert `false`, wenn nichts zu exportieren war oder der
* Speichern-Dialog abgebrochen wurde.
*/
export async function saveFolderPdf(
project: Project,
folderId: string,
fileName: string,
): Promise<boolean> {
const doc = await buildFolderPdf(project, folderId);
if (!doc) return false;
const bytes = doc.output("arraybuffer");
const name = fileName.replace(/\.pdf$/i, "");
return saveBinaryFile(new Uint8Array(bytes), `${name}.pdf`, "application/pdf");
}
+46
View File
@@ -0,0 +1,46 @@
// Stützen-Geometrie: leitet aus einer Stütze (Column) ihren Grundriss-Querschnitt
// (Poché-/Footprint-Polygon) ab. Reine Funktion, keine Modell-Mutation — sowohl
// der 2D-Grundriss (generatePlan) als auch die 3D-Extrusion (toWalls3d) und die
// Auswahl-/Transform-Vorschau (transform.ts) nutzen dieselbe Kontur.
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import type { Column, Vec2 } from "../model/types";
/** Tessellierungs-Segmentzahl eines runden Stützenprofils (Grundriss + 3D-Prisma). */
export const COLUMN_ROUND_SEGMENTS = 32;
/**
* Grundriss-Querschnitt einer Stütze als geschlossenes Polygon (Modell-Meter,
* CCW), um `position` platziert und (bei Rechteck) um `rotation` gedreht. Ein
* rundes Profil wird als regelmäßiges {@link COLUMN_ROUND_SEGMENTS}-Eck
* tesselliert; der Schlusspunkt wird NICHT dupliziert.
*/
export function columnFootprint(col: Column): Vec2[] {
const { x: cx, y: cy } = col.position;
if (col.profile.kind === "round") {
const r = col.profile.radius;
const pts: Vec2[] = [];
for (let i = 0; i < COLUMN_ROUND_SEGMENTS; i++) {
const t = (i / COLUMN_ROUND_SEGMENTS) * Math.PI * 2;
pts.push({ x: cx + r * Math.cos(t), y: cy + r * Math.sin(t) });
}
return pts;
}
// Rechteck: lokale Ecken ±w/2 (X) × ±d/2 (Y), um `rotation` gedreht.
const hw = col.profile.width / 2;
const hd = col.profile.depth / 2;
const rot = col.rotation ?? 0;
const cos = Math.cos(rot);
const sin = Math.sin(rot);
const local: Vec2[] = [
{ x: -hw, y: -hd },
{ x: +hw, y: -hd },
{ x: +hw, y: +hd },
{ x: -hw, y: +hd },
];
return local.map((p) => ({
x: cx + p.x * cos - p.y * sin,
y: cy + p.x * sin + p.y * cos,
}));
}
+325 -1
View File
@@ -60,7 +60,7 @@ import {
stairGeometry,
type StairGeometry as TSStairGeometry,
} from "./stair";
import type { Stair } from "../model/types";
import { wallTypeThickness, type Opening, type Project, type Stair, type Wall, type WallType } from "../model/types";
import {
detectRooms,
pointInPolygon as rbPointInPolygon,
@@ -69,6 +69,19 @@ import {
type WallSegment,
type WallFace,
} from "./roomBoundary";
import { wallReferenceOffset } from "../model/wall";
import {
doorSymbol,
openingCenter,
openingGapQuad,
openingInterval,
openingJambs,
wallAxisFrame,
wallAxisLength,
windowSymbol,
type DoorSymbol,
type WindowSymbol,
} from "./opening";
type Poly = { pts: Vec2[]; closed: boolean };
@@ -912,6 +925,317 @@ describe.skipIf(!built)("kernel2d Rust-WASM ⇄ TS Paritaet — Slice 3: roomBou
});
});
// ── Slice 4: opening ──────────────────────────────────────────────────────────
// `opening.ts` ist model-gekoppelt (Wall/Opening/Project) — die geometrisch
// reinen Kerne wurden mit abgeflachter Signatur (Vec2/number) portiert
// (PORT_PLAN §1). `thickness`/`refOffset` werden hier — wie beim Rust-Port —
// bereits aufgeloest uebergeben (`wallTypeThickness`/`wallReferenceOffset`,
// nicht ueber `getWallType`/ein volles `Project` im WASM-Aufruf).
/** Zufaellige, NICHT entartete Wand (Laenge 0.5..15.5 m). */
function genWall(rng: () => number): Wall {
const start = v(rng);
const angle = rng() * Math.PI * 2;
const length = 0.5 + rng() * 15;
const end = { x: start.x + Math.cos(angle) * length, y: start.y + Math.sin(angle) * length };
const refs = ["left", "center", "right"] as const;
return {
id: "w1",
type: "wall",
floorId: "f1",
categoryCode: "20",
start,
end,
wallTypeId: "wt1",
height: 2.4,
referenceLine: refs[Math.floor(rng() * 3)],
};
}
/** Einschichtiger WallType mit zufaelliger Dicke (0.15..0.5 m). */
function genWallType(rng: () => number): WallType {
return { id: "wt1", name: "wt", layers: [{ componentId: "c1", thickness: 0.15 + rng() * 0.35 }] };
}
/** Minimales Project — `getWallType` liest nur `wallTypes`. */
function makeProject(wallType: WallType): Project {
return { wallTypes: [wallType] } as unknown as Project;
}
/** Zufaellige Oeffnung auf `wall`: position/width teils ausserhalb der Achse
* oder negativ (Strukturabdeckung fuer den null-Zweig von `openingInterval`). */
function genOpening(rng: () => number, wall: Wall, kind: "window" | "door"): Opening {
const axis = wallAxisLength(wall);
const position = (rng() * 1.6 - 0.3) * axis;
const width = rng() < 0.1 ? -0.5 + rng() * 0.5 : 0.3 + rng() * Math.max(0.3, axis);
const swings = ["left", "right"] as const;
const hinges = ["start", "end"] as const;
const dirs = ["in", "out"] as const;
return {
id: "o1",
type: "opening",
hostWallId: wall.id,
categoryCode: "21",
kind,
position,
width,
height: 1.2,
sillHeight: kind === "door" ? 0 : 0.8,
wingCount: rng() < 0.5 ? undefined : 1 + Math.floor(rng() * 4),
hinge: rng() < 0.3 ? undefined : hinges[Math.floor(rng() * 2)],
swing: rng() < 0.3 ? undefined : swings[Math.floor(rng() * 2)],
swingAngle: rng() < 0.3 ? undefined : 30 + rng() * 120,
openingDir: rng() < 0.3 ? undefined : dirs[Math.floor(rng() * 2)],
};
}
describe.skipIf(!built)("kernel2d Rust-WASM ⇄ TS Paritaet — Slice 4: opening", () => {
it("wallAxisLength (Zufallswaende, rel 1e-9)", () => {
const rng = mulberry32(50);
const walls = Array.from({ length: N }, () => genWall(rng));
const qs = walls.map((wl) => ({ start: wl.start, end: wl.end }));
const w = JSON.parse(K.wall_axis_length_batch_json(JSON.stringify(qs))) as number[];
walls.forEach((wl, i) => {
expect(closeNum(w[i], wallAxisLength(wl)), `axisLen#${i}`).toBe(true);
});
});
it("openingInterval: Struktur exakt (null-Zweig inklusive) + Werte", () => {
const rng = mulberry32(51);
const cases = Array.from({ length: N }, () => {
const wall = genWall(rng);
return { wall, o: genOpening(rng, wall, "window") };
});
const qs = cases.map(({ wall, o }) => ({
start: wall.start,
end: wall.end,
position: o.position,
width: o.width,
}));
const w = JSON.parse(K.opening_interval_batch_json(JSON.stringify(qs))) as (
| { from: number; to: number }
| null
)[];
cases.forEach(({ wall, o }, i) => {
const t = openingInterval(wall, o);
expect(w[i] === null, `iv-null#${i}`).toBe(t === null);
if (t && w[i]) {
expect(closeNum(w[i]!.from, t.from), `iv-from#${i}`).toBe(true);
expect(closeNum(w[i]!.to, t.to), `iv-to#${i}`).toBe(true);
}
});
});
it("wallAxisFrame (u/n, rel 1e-9)", () => {
const rng = mulberry32(52);
const walls = Array.from({ length: N }, () => genWall(rng));
const qs = walls.map((wl) => ({ start: wl.start, end: wl.end }));
const w = JSON.parse(K.wall_axis_frame_batch_json(JSON.stringify(qs))) as { u: Vec2; n: Vec2 }[];
walls.forEach((wl, i) => {
const t = wallAxisFrame(wl);
expect(closeVec(w[i].u, t.u), `frame-u#${i}`).toBe(true);
expect(closeVec(w[i].n, t.n), `frame-n#${i}`).toBe(true);
});
});
it("openingJambs / openingCenter: Struktur exakt (null-Zweig) + Werte", () => {
const rng = mulberry32(53);
const cases = Array.from({ length: N }, () => {
const wall = genWall(rng);
return { wall, o: genOpening(rng, wall, "window") };
});
const qs = cases.map(({ wall, o }) => ({
start: wall.start,
end: wall.end,
position: o.position,
width: o.width,
}));
const wJ = JSON.parse(K.opening_jambs_batch_json(JSON.stringify(qs))) as (
| { jambStart: Vec2; jambEnd: Vec2 }
| null
)[];
const wC = JSON.parse(K.opening_center_batch_json(JSON.stringify(qs))) as (Vec2 | null)[];
cases.forEach(({ wall, o }, i) => {
const tJ = openingJambs(wall, o);
expect(wJ[i] === null, `jambs-null#${i}`).toBe(tJ === null);
if (tJ && wJ[i]) {
expect(closeVec(wJ[i]!.jambStart, tJ.jambStart), `jambs-start#${i}`).toBe(true);
expect(closeVec(wJ[i]!.jambEnd, tJ.jambEnd), `jambs-end#${i}`).toBe(true);
}
const tC = openingCenter(wall, o);
expect(wC[i] === null, `center-null#${i}`).toBe(tC === null);
if (tC && wC[i]) expect(closeVec(wC[i]!, tC), `center#${i}`).toBe(true);
});
});
it("openingGapQuad / windowSymbol: Struktur exakt + Werte (rel 1e-9)", () => {
const rng = mulberry32(54);
const cases = Array.from({ length: 150 }, () => {
const wall = genWall(rng);
const wallType = genWallType(rng);
wall.wallTypeId = wallType.id;
const project = makeProject(wallType);
const o = genOpening(rng, wall, "window");
const total = wallTypeThickness(wallType);
const refOffset = wallReferenceOffset(wall, total);
const glassCount = 1 + Math.floor(rng() * 3);
return { wall, o, project, total, refOffset, glassCount };
});
const qsGap = cases.map((c) => ({
start: c.wall.start,
end: c.wall.end,
position: c.o.position,
width: c.o.width,
thickness: c.total,
refOffset: c.refOffset,
}));
const wGap = JSON.parse(K.opening_gap_quad_batch_json(JSON.stringify(qsGap))) as (Vec2[] | null)[];
cases.forEach((c, i) => {
const t = openingGapQuad(c.project, c.wall, c.o);
expect(wGap[i] === null, `gap-null#${i}`).toBe(t === null);
if (t && wGap[i]) expect(eqPts(wGap[i]!, t), `gap-pts#${i}`).toBe(true);
});
const qsWin = cases.map((c) => ({
start: c.wall.start,
end: c.wall.end,
position: c.o.position,
width: c.o.width,
thickness: c.total,
refOffset: c.refOffset,
glassCount: c.glassCount,
wingCount: c.o.wingCount,
}));
const wWin = JSON.parse(K.window_symbol_batch_json(JSON.stringify(qsWin))) as (WindowSymbol | null)[];
cases.forEach((c, i) => {
const t = windowSymbol(c.project, c.wall, c.o, c.glassCount);
expect(wWin[i] === null, `win-null#${i}`).toBe(t === null);
if (t && wWin[i]) {
expect(eqPts(wWin[i]!.frame, t.frame), `win-frame#${i}`).toBe(true);
expect(eqPairs(wWin[i]!.glassLines, t.glassLines), `win-glass#${i}`).toBe(true);
expect(eqPairs(wWin[i]!.mullionLines, t.mullionLines), `win-mullion#${i}`).toBe(true);
}
});
});
it("doorSymbol: Struktur exakt + Werte (Default-Parameter inklusive)", () => {
const rng = mulberry32(55);
const cases = Array.from({ length: 150 }, () => {
const wall = genWall(rng);
return { wall, o: genOpening(rng, wall, "door") };
});
const qs = cases.map(({ wall, o }) => ({
start: wall.start,
end: wall.end,
position: o.position,
width: o.width,
swing: o.swing,
openingDir: o.openingDir,
hinge: o.hinge,
swingAngle: o.swingAngle,
}));
const w = JSON.parse(K.door_symbol_batch_json(JSON.stringify(qs))) as (DoorSymbol | null)[];
cases.forEach(({ wall, o }, i) => {
const t = doorSymbol(wall, o);
expect(w[i] === null, `door-null#${i}`).toBe(t === null);
if (t && w[i]) {
expect(closeVec(w[i]!.hinge, t.hinge), `door-hinge#${i}`).toBe(true);
expect(closeVec(w[i]!.openEnd, t.openEnd), `door-open#${i}`).toBe(true);
expect(closeVec(w[i]!.closedEnd, t.closedEnd), `door-closed#${i}`).toBe(true);
expect(closeNum(w[i]!.radius, t.radius), `door-radius#${i}`).toBe(true);
expect(closeVec(w[i]!.jambStart, t.jambStart), `door-jambStart#${i}`).toBe(true);
expect(closeVec(w[i]!.jambEnd, t.jambEnd), `door-jambEnd#${i}`).toBe(true);
expect(closeVec(w[i]!.normal, t.normal), `door-normal#${i}`).toBe(true);
}
});
});
it("Golden: entartetes Intervall (Breite 0 / ausserhalb der Achse) → null; wingCount-Clamp >4 → 3 Pfosten", () => {
const wall: Wall = {
id: "w",
type: "wall",
floorId: "f",
categoryCode: "20",
start: { x: 0, y: 0 },
end: { x: 5, y: 0 },
wallTypeId: "wt",
height: 2.4,
referenceLine: "center",
};
const wallType: WallType = { id: "wt", name: "wt", layers: [{ componentId: "c", thickness: 0.24 }] };
const project = makeProject(wallType);
// Breite 0 → entartet.
const oZero: Opening = {
id: "o",
type: "opening",
hostWallId: "w",
categoryCode: "21",
kind: "window",
position: 2,
width: 0,
height: 1.2,
sillHeight: 0.8,
};
const ivZero = JSON.parse(
K.opening_interval_batch_json(
JSON.stringify([{ start: wall.start, end: wall.end, position: oZero.position, width: oZero.width }]),
),
) as ({ from: number; to: number } | null)[];
expect(ivZero[0] === null).toBe(true);
expect(ivZero[0] === null).toBe(openingInterval(wall, oZero) === null);
// Vollstaendig ausserhalb der Achse.
const oOut: Opening = { ...oZero, position: 10, width: 1 };
const ivOut = JSON.parse(
K.opening_interval_batch_json(
JSON.stringify([{ start: wall.start, end: wall.end, position: oOut.position, width: oOut.width }]),
),
) as ({ from: number; to: number } | null)[];
expect(ivOut[0] === null).toBe(true);
expect(ivOut[0] === null).toBe(openingInterval(wall, oOut) === null);
// wingCount > 4 wird auf 4 geklemmt → 3 Mittelpfosten.
const total = wallTypeThickness(wallType);
const refOffset = wallReferenceOffset(wall, total);
const oWings: Opening = {
id: "o",
type: "opening",
hostWallId: "w",
categoryCode: "21",
kind: "window",
position: 1,
width: 2,
height: 1.2,
sillHeight: 0.8,
wingCount: 9,
};
const wWin = JSON.parse(
K.window_symbol_batch_json(
JSON.stringify([
{
start: wall.start,
end: wall.end,
position: oWings.position,
width: oWings.width,
thickness: total,
refOffset,
glassCount: 1,
wingCount: oWings.wingCount,
},
]),
),
) as (WindowSymbol | null)[];
const tWin = windowSymbol(project, wall, oWings, 1);
expect(tWin).not.toBeNull();
expect(wWin[0]).not.toBeNull();
expect(wWin[0]!.mullionLines.length).toBe(tWin!.mullionLines.length);
expect(wWin[0]!.mullionLines.length).toBe(3);
expect(eqPairs(wWin[0]!.mullionLines, tWin!.mullionLines)).toBe(true);
});
});
describe.skipIf(!built)("kernel2d Rust-WASM ⇄ TS Paritaet — Golden (Grenzfaelle)", () => {
it("parallele/kollineare Strecken → null (beide)", () => {
const qs = [
+102
View File
@@ -0,0 +1,102 @@
// Tests für die SIA-416-Flächenbilanz (roomArea.ts): Roll-up-Hierarchie,
// insbesondere die Einhängung der Kategorie AGF (Aussengeschossfläche).
import { describe, expect, it } from "vitest";
import { balance, roomsToCsv, SIA_CATEGORIES, siaLabel } from "./roomArea";
describe("SIA_CATEGORIES", () => {
it("enthält AGF als Blatt-Kategorie", () => {
expect(SIA_CATEGORIES).toContain("AGF");
});
it("liefert das deutsche Label für AGF", () => {
expect(siaLabel("AGF")).toBe("Aussengeschossfläche");
});
});
describe("balance() — Roll-up-Hierarchie", () => {
it("rollt HNF/NNF/VF/FF/KGF wie bisher auf (Referenzfall ohne AGF)", () => {
const bal = balance([
{ category: "HNF", area: 20 },
{ category: "NNF", area: 5 },
{ category: "VF", area: 8 },
{ category: "FF", area: 2 },
{ category: "KGF", area: 10 },
]);
expect(bal.byKey.NF).toBeCloseTo(25);
expect(bal.byKey.NGF).toBeCloseTo(35);
expect(bal.byKey.GF).toBeCloseTo(45);
expect(bal.byKey.AGF).toBe(0);
});
it("zählt AGF zur GF, aber NICHT zu NF/NGF", () => {
const bal = balance([
{ category: "HNF", area: 20 },
{ category: "AGF", area: 12 },
]);
// NGF bleibt unverändert (nur HNF trägt bei) — AGF fliesst hier nicht ein.
expect(bal.byKey.NF).toBeCloseTo(20);
expect(bal.byKey.NGF).toBeCloseTo(20);
// GF = NGF + KGF + AGF = 20 + 0 + 12
expect(bal.byKey.AGF).toBeCloseTo(12);
expect(bal.byKey.GF).toBeCloseTo(32);
expect(bal.total).toBeCloseTo(32);
});
it("GF = NGF + KGF + AGF bei gemischter Belegung", () => {
const bal = balance([
{ category: "HNF", area: 40 },
{ category: "NNF", area: 6 },
{ category: "VF", area: 9 },
{ category: "FF", area: 3 },
{ category: "KGF", area: 15 },
{ category: "AGF", area: 7.5 },
]);
expect(bal.byKey.NF).toBeCloseTo(46);
expect(bal.byKey.NGF).toBeCloseTo(58);
expect(bal.byKey.KGF).toBeCloseTo(15);
expect(bal.byKey.AGF).toBeCloseTo(7.5);
expect(bal.byKey.GF).toBeCloseTo(bal.byKey.NGF + bal.byKey.KGF + bal.byKey.AGF);
expect(bal.byKey.GF).toBeCloseTo(80.5);
expect(bal.total).toBeCloseTo(80.5);
});
it("führt eine eigene Bilanzzeile für AGF, positioniert nach KGF und vor GF", () => {
const bal = balance([{ category: "AGF", area: 4 }]);
const keys = bal.rows.map((r) => r.key);
expect(keys).toEqual(["HNF", "NNF", "NF", "VF", "FF", "NGF", "KGF", "AGF", "GF"]);
const agfRow = bal.rows.find((r) => r.key === "AGF");
expect(agfRow).toBeDefined();
expect(agfRow?.aggregate).toBe(false);
expect(agfRow?.area).toBeCloseTo(4);
expect(agfRow?.count).toBe(1);
});
});
describe("roomsToCsv() — Bilanzblock mit AGF", () => {
it("weist einen Raum mit Kategorie AGF in Raumliste und Bilanz korrekt aus", () => {
const csv = roomsToCsv([
{ number: "R.01", name: "Wohnen", category: "HNF", area: 20 },
{ number: "R.02", name: "Balkon", category: "AGF", area: 6 },
]);
const lines = csv.split("\r\n");
// Raumliste enthält den AGF-Raum mit Kategorie + Label.
const roomLine = lines.find((l) => l.startsWith("R.02"));
expect(roomLine).toBeDefined();
expect(roomLine).toContain("AGF");
expect(roomLine).toContain("Aussengeschossfläche");
expect(roomLine).toContain("6.00");
// Bilanzblock enthält eine AGF-Zeile mit der Fläche des Raums.
const balanceAgfLine = lines.find((l) => l.includes(";AGF;"));
expect(balanceAgfLine).toBeDefined();
expect(balanceAgfLine).toContain("Aussengeschossfläche");
expect(balanceAgfLine).toContain("6.00");
// GF-Summe berücksichtigt AGF (20 HNF + 6 AGF = 26).
const gfLine = lines.find((l) => l.includes(";GF;"));
expect(gfLine).toBeDefined();
expect(gfLine).toContain("26.00");
});
});
+15 -6
View File
@@ -92,6 +92,7 @@ export function centroid(pts: Vec2[]): Vec2 {
//
// GF Geschossfläche
// ├── KGF Konstruktionsfläche (Wände, Stützen, Schächte-Wandungen …)
// ├── AGF Aussengeschossfläche (Balkone, Terrassen, Laubengänge …)
// └── NGF Nettogeschossfläche
// ├── NF Nutzfläche
// │ ├── HNF Hauptnutzfläche (der eigentliche Nutzungszweck)
@@ -100,10 +101,12 @@ export function centroid(pts: Vec2[]): Vec2 {
// └── 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.
// HNF, NNF, VF, FF, KGF und AGF. HNF+NNF = NF; NF+VF+FF = NGF; NGF+KGF+AGF = GF.
// AGF zählt zur Geschossfläche, ist aber ausserhalb der Gebäudehülle (Aussen-
// bereich) und fliesst daher NICHT in NGF (bzw. NF) ein.
/** Blatt-Kategorien nach SIA 416, die einem Raum direkt zugewiesen werden. */
export type SiaCategory = "HNF" | "NNF" | "VF" | "FF" | "KGF";
export type SiaCategory = "HNF" | "NNF" | "VF" | "FF" | "KGF" | "AGF";
/** Aggregat-(Zwischensummen-)Ebenen der SIA-416-Hierarchie. */
export type SiaAggregate = "NF" | "NGF" | "GF";
@@ -118,6 +121,7 @@ export const SIA_CATEGORIES: readonly SiaCategory[] = [
"VF",
"FF",
"KGF",
"AGF",
] as const;
/** Deutsche Bezeichnungen (Kurz + Lang) je SIA-416-Schlüssel. */
@@ -134,6 +138,7 @@ const SIA_LABELS: Record<SiaKey, string> = {
VF: "Verkehrsfläche",
FF: "Funktionsfläche",
KGF: "Konstruktionsfläche",
AGF: "Aussengeschossfläche",
NF: "Nutzfläche",
NGF: "Nettogeschossfläche",
GF: "Geschossfläche",
@@ -152,7 +157,8 @@ export function siaLabelFull(key: SiaKey): string {
/**
* 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.
* AGF (Aussengeschossfläche) trägt nur zu GF bei — sie liegt ausserhalb der
* Gebäudehülle und zählt NICHT zu NF/NGF. Wird für den Bilanz-Roll-up genutzt.
*/
const CATEGORY_ROLLUP: Record<SiaCategory, SiaAggregate[]> = {
HNF: ["NF", "NGF", "GF"],
@@ -160,6 +166,7 @@ const CATEGORY_ROLLUP: Record<SiaCategory, SiaAggregate[]> = {
VF: ["NGF", "GF"],
FF: ["NGF", "GF"],
KGF: ["GF"],
AGF: ["GF"],
};
// ── Raum-Flächenergebnis ─────────────────────────────────────────────────────
@@ -242,14 +249,14 @@ export interface Balance {
* 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(=Σ)
* HNF, NNF, NF(=Σ), VF, FF, NGF(=Σ), KGF, AGF, 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 leaf: Record<SiaCategory, number> = { HNF: 0, NNF: 0, VF: 0, FF: 0, KGF: 0, AGF: 0 };
const leafCount: Record<SiaCategory, number> = { HNF: 0, NNF: 0, VF: 0, FF: 0, KGF: 0, AGF: 0 };
const agg: Record<SiaAggregate, number> = { NF: 0, NGF: 0, GF: 0 };
const aggCount: Record<SiaAggregate, number> = { NF: 0, NGF: 0, GF: 0 };
@@ -269,6 +276,7 @@ export function balance(
VF: leaf.VF,
FF: leaf.FF,
KGF: leaf.KGF,
AGF: leaf.AGF,
NF: agg.NF,
NGF: agg.NGF,
GF: agg.GF,
@@ -297,6 +305,7 @@ export function balance(
mkLeaf("FF"),
mkAgg("NGF"),
mkLeaf("KGF"),
mkLeaf("AGF"),
mkAgg("GF"),
];
+365 -7
View File
@@ -15,16 +15,28 @@ export const de = {
"topbar.perspektive": "Perspektive",
"view3d.group": "Ansicht 3D",
"view3d.front": "Front",
"view3d.front": "Vorne",
"view3d.front.hint": "Frontalansicht von vorne",
"view3d.back": "Hinten",
"view3d.back.hint": "Rückansicht von hinten",
"view3d.top": "Oben",
"view3d.top.hint": "Draufsicht (senkrecht von oben)",
"view3d.side": "Seite",
"view3d.side.hint": "Seitenansicht (90° zur Front)",
"view3d.side": "Rechts",
"view3d.side.hint": "Rechte Seitenansicht (90° zur Front)",
"view3d.left": "Links",
"view3d.left.hint": "Linke Seitenansicht (90° zur Front, gegenüber Rechts)",
"view3d.perspective": "Perspektive",
"view3d.perspective.hint": "Freie Perspektive (3/4-Blick)",
"view3d.iso": "Isometrie",
"view3d.iso.hint": "Isometrische Ansicht (gleichwinklig von vorne-oben-rechts)",
"view3d.isoFrontLeft": "Vorne-Links",
"view3d.isoFrontLeft.hint": "Isometrische Ansicht (gleichwinklig von vorne-oben-links)",
"view3d.isoBackRight": "Hinten-Rechts",
"view3d.isoBackRight.hint": "Isometrische Ansicht (gleichwinklig von hinten-oben-rechts)",
"view3d.isoBackLeft": "Hinten-Links",
"view3d.isoBackLeft.hint": "Isometrische Ansicht (gleichwinklig von hinten-oben-links)",
"view3d.iso.menu": "Weitere Iso-Ansichten",
"view3d.iso.menu.hint": "Einen der vier oberen Iso-Oktanten wählen",
"view3d.camera": "Kamera",
"view3d.camera.hint": "Kamera-Einstellungen (Sichtwinkel)",
"view3d.fov": "Sichtwinkel",
@@ -86,12 +98,12 @@ export const de = {
"topbar.resources.hint": "Ressourcen-Fenster ein-/ausblenden",
// ── Layout-Menü (Topbar) ─────────────────────────────────────────────────
"layout.title": "Layout",
"layout.placeholder": "Layout …",
"layout.saveCurrent": " Aktuelles speichern …",
"layout.title": "Arbeitsumgebung",
"layout.placeholder": "Arbeitsumgebung …",
"layout.saveCurrent": " Aktuelle speichern …",
"layout.loadGroup": "Laden",
"layout.deleteGroup": "Löschen",
"layout.savePrompt": "Layout speichern als:",
"layout.savePrompt": "Arbeitsumgebung speichern als:",
// ── Sichtbarkeits-Kombinationen (Topbar) ─────────────────────────────────
"combo.layers": "Ebenen-Komb.",
@@ -120,6 +132,10 @@ export const de = {
"tool.circle.hint": "Kreis: Mittelpunkt setzen, dann Radius",
"tool.arc": "Bogen",
"tool.arc.hint": "Bogen: Mittelpunkt, Startpunkt, dann Endpunkt (CCW)",
"tool.text": "Text",
"tool.text.hint": "Text: Ankerpunkt setzen, dann Text eintippen",
"tool.textbox": "Textspalte",
"tool.textbox.hint": "Textspalte: Anker, dann Spaltenbreite ziehen, dann Text eintippen",
// Ribbon-Oberleiste (Tabs + Gruppen)
"ribbon.tab.2d": "2D",
"ribbon.tab.3d": "3D",
@@ -234,6 +250,7 @@ export const de = {
"attr.fill": "Füllung",
"attr.fillColor": "Füllfarbe",
"attr.clearFill": "Füllfarbe entfernen",
"attr.pickColor": "Farbe wählen",
"attr.foreground": "Vordergrund",
"attr.background": "Hintergrund",
"attr.bySystem": "Nach System",
@@ -266,12 +283,29 @@ export const de = {
"objinfo.kind.opening": "Öffnung",
"objinfo.kind.stair": "Treppe",
"objinfo.kind.room": "Raum",
"objinfo.kind.extrudedSolid": "Extrusion",
"objinfo.kind.column": "Stütze",
// ── Stützen-Attribute (Object-Info-Panel) ───────────────────────────────
"objinfo.column.section": "Stütze",
"objinfo.column.profile": "Profil",
"objinfo.column.profile.rect": "Rechteck",
"objinfo.column.profile.round": "Rund",
"objinfo.column.width": "Breite",
"objinfo.column.depth": "Tiefe",
"objinfo.column.radius": "Radius",
"objinfo.column.height": "Höhe",
"objinfo.column.rotation": "Drehung (°)",
"objinfo.column.floor": "Geschoss",
// ── Wand-Attribute (Object-Info-Panel) ──────────────────────────────────
"objinfo.wall.section": "Wand",
"objinfo.wall.refLine": "Referenzlinie",
"objinfo.wall.refLine.left": "Außen (links)",
"objinfo.wall.refLine.center": "Mitte",
"objinfo.wall.refLine.right": "Innen (rechts)",
"objinfo.wall.sliceTermination": "An Decke enden",
"objinfo.wall.sliceTermination.both": "Aus",
"objinfo.wall.sliceTermination.below": "Unten",
"objinfo.wall.sliceTermination.above": "Oben",
"objinfo.wall.buildup": "Aufbau",
"objinfo.wall.single": "Solid",
"objinfo.wall.multi": "Mehrschichtig",
@@ -284,6 +318,11 @@ export const de = {
"objinfo.wall.top": "Oberkante (OK)",
"objinfo.wall.anchorFloor": "Gebunden",
"objinfo.wall.anchorCustom": "Eigene",
"objinfo.anchor.refFloor": "Referenzgeschoss",
"objinfo.anchor.customHeight": "Eigene Höhe",
"objinfo.wall.length": "Länge",
"objinfo.wall.volumeGross": "Brutto",
"objinfo.volume": "Volumen",
"objinfo.wall.height": "Höhe",
// ── Decken-Attribute (Object-Info-Panel) ────────────────────────────────
"objinfo.ceiling.section": "Decke",
@@ -299,6 +338,9 @@ export const de = {
"objinfo.ceiling.anchorCustom": "Eigene",
// ── Öffnungs-Attribute (Object-Info-Panel) ──────────────────────────────
"objinfo.opening.section": "Öffnung",
"objinfo.opening.type": "Typ",
"objinfo.stair.type": "Typ",
"objinfo.type.none": "(kein Typ)",
"objinfo.opening.kind": "Art",
"objinfo.opening.kind.window": "Fenster",
"objinfo.opening.kind.door": "Tür",
@@ -359,6 +401,15 @@ export const de = {
"objinfo.room.perimeter": "Umfang",
"objinfo.room.refFloor": "Referenzgeschoss",
"objinfo.room.editStamp": "Stempel bearbeiten …",
// ── Extrusions-Attribute (truck-Integration, Object-Info-Panel) ─────────
"objinfo.extrudedSolid.section": "Extrusion",
"objinfo.extrudedSolid.height": "Höhe",
"objinfo.extrudedSolid.taper": "Verjüngung",
"objinfo.extrudedSolid.area": "Grundfläche",
"objinfo.extrudedSolid.floor": "Geschoss",
"objinfo.rotate": "Drehen",
"objinfo.drawing.length": "Länge",
"objinfo.drawing.radius": "Radius",
"objinfo.wall.floorAboveNone": "Kein oberes Geschoss",
"levels.addFloor": "+ Geschoss",
"levels.addSection": "+ Schnitt",
@@ -489,6 +540,9 @@ export const de = {
"default.ceilingTypeSingle": "Decke {thickness} m",
"default.wallTypeName": "Neuer Wandtyp",
"default.ceilingTypeName": "Neuer Deckentyp",
"default.doorTypeName": "Neuer Türtyp",
"default.windowTypeName": "Neuer Fenstertyp",
"default.stairTypeName": "Neuer Treppentyp",
// ── Warnungen (Lösch-Schutz) ─────────────────────────────────────────────
"alert.layerInUse": "Ebene „{code} {name}\" wird von Bauteilen verwendet und kann nicht gelöscht werden.",
@@ -497,6 +551,9 @@ export const de = {
"alert.lineStyleInUse": "Linienstil „{name}\" wird von einer Schraffur verwendet und kann nicht gelöscht werden.",
"alert.wallTypeInUse": "Wandtyp „{name}\" wird von einer Wand verwendet und kann nicht gelöscht werden.",
"alert.ceilingTypeInUse": "Deckentyp „{name}\" wird von einer Decke verwendet und kann nicht gelöscht werden.",
"alert.doorTypeInUse": "Türtyp „{name}\" wird von einer Tür verwendet und kann nicht gelöscht werden.",
"alert.windowTypeInUse": "Fenstertyp „{name}\" wird von einem Fenster verwendet und kann nicht gelöscht werden.",
"alert.stairTypeInUse": "Treppentyp „{name}\" wird von einer Treppe verwendet und kann nicht gelöscht werden.",
// ── Ressourcen-Manager ───────────────────────────────────────────────────
"resources.title": "Ressourcen",
@@ -507,7 +564,38 @@ export const de = {
"resources.tab.lines": "Linien",
"resources.tab.wallStyles": "Wandstile",
"resources.tab.ceilingStyles": "Deckenstile",
"resources.tab.doorStyles": "Türtypen",
"resources.tab.windowStyles": "Fenstertypen",
"resources.tab.stairStyles": "Treppentypen",
"resources.tab.materials": "Materialien",
"resources.tab.overrides": "Overrides",
// Grafische Overrides (Regel-Engine).
"resources.overrides.hint":
"Regeln übersteuern Farbe/Strichstärke/Linienstil beim Rendern (Elementdaten bleiben unverändert). Reihenfolge = Priorität: pro Feld gewinnt die oberste aktive Regel.",
"resources.overrides.empty": "Noch keine Override-Regeln.",
"resources.overrides.placeholder": "Regel",
"resources.overrides.defaultName": "Neue Regel",
"resources.overrides.delete": "Regel löschen",
"resources.overrides.moveUp": "Regel nach oben (höhere Priorität)",
"resources.overrides.moveDown": "Regel nach unten (tiefere Priorität)",
"resources.overrides.enabled": "Regel aktiv",
"resources.overrides.condition": "Bedingung",
"resources.overrides.condType": "Kriterium",
"resources.overrides.condType.layer": "Ebene (Name/Code)",
"resources.overrides.condType.object": "Objektname",
"resources.overrides.operator": "Operator",
"resources.overrides.op.equals": "ist gleich",
"resources.overrides.op.contains": "enthält",
"resources.overrides.op.startsWith": "beginnt mit",
"resources.overrides.op.notEquals": "ist ungleich",
"resources.overrides.value": "Wert",
"resources.overrides.value.placeholder": "z. B. Wände oder 20",
"resources.overrides.actions": "Aktionen",
"resources.overrides.action.color": "Farbe",
"resources.overrides.action.lineweight": "Strichstärke (mm)",
"resources.overrides.action.linetype": "Linienstil",
"resources.overrides.linetype.none": "— (unverändert)",
"resources.wallStyles.hint":
"Je Wandtyp die Schichtfugen (zwischen benachbarten Schichten) als wählbaren Linienstil. Ohne Zuweisung: Haarlinie 0.02 mm.",
@@ -517,6 +605,75 @@ export const de = {
"resources.ceilingStyles.empty": "Noch keine Deckentypen.",
"resources.wallStyles.placeholder": "Wandtyp",
"resources.ceilingStyles.placeholder": "Deckentyp",
// Bauteil-Typen (Tür/Fenster/Treppe) — Formular-Bibliotheken.
"resources.doorTypes.hint":
"Wiederverwendbare Türtypen mit Bauart, Blattausführung und Standardmaßen für neu platzierte Türen.",
"resources.doorTypes.empty": "Noch keine Türtypen.",
"resources.doorTypes.placeholder": "Türtyp",
"resources.windowTypes.hint":
"Wiederverwendbare Fenstertypen mit Öffnungsart, Verglasung und Standardmaßen für neu platzierte Fenster.",
"resources.windowTypes.empty": "Noch keine Fenstertypen.",
"resources.windowTypes.placeholder": "Fenstertyp",
"resources.stairTypes.hint":
"Wiederverwendbare Treppentypen mit Tragart, Stufenausbildung und Geländer für neu platzierte Treppen.",
"resources.stairTypes.empty": "Noch keine Treppentypen.",
"resources.stairTypes.placeholder": "Treppentyp",
"resources.delete.doorType": "Türtyp „{name}\" löschen",
"resources.delete.windowType": "Fenstertyp „{name}\" löschen",
"resources.delete.stairType": "Treppentyp „{name}\" löschen",
"resources.field.doorKind": "Bauart",
"resources.field.leafCount": "Flügelzahl",
"resources.field.leafStyle": "Blattausführung",
"resources.field.glazingRatio": "Glasanteil",
"resources.field.frameThickness": "Zargenstärke (m)",
"resources.field.frameDepth": "Zargentiefe (m)",
"resources.field.defaultWidth": "Lichtbreite (m)",
"resources.field.defaultHeight": "Lichthöhe (m)",
"resources.field.threshold": "Bodenschwelle",
"resources.field.windowKind": "Öffnungsart",
"resources.field.wingCount": "Flügelzahl",
"resources.field.glazing": "Verglasung",
"resources.field.defaultSillHeight": "Brüstungshöhe (m)",
"resources.field.sillBoard": "Fensterbank",
"resources.field.structure": "Tragart",
"resources.field.closedRisers": "Setzstufen geschlossen",
"resources.field.treadThickness": "Trittstufendicke (m)",
"resources.field.nosing": "Trittkanten-Überstand (m)",
"resources.field.railing": "Geländer",
"resources.field.stairWidth": "Laufbreite (m)",
"resources.doorKind.dreh": "Drehflügel",
"resources.doorKind.schiebe": "Schiebe",
"resources.doorKind.wandoeffnung": "Wandöffnung",
"resources.leafCount.1": "1-flügelig",
"resources.leafCount.2": "2-flügelig",
"resources.leafStyle.glatt": "Glatt",
"resources.leafStyle.kassette": "Kassette",
"resources.leafStyle.glas": "Glas",
"resources.windowKind.dreh": "Dreh",
"resources.windowKind.kipp": "Kipp",
"resources.windowKind.drehkipp": "Dreh-Kipp",
"resources.windowKind.fest": "Fest",
"resources.windowKind.schiebe": "Schiebe",
"resources.glazing.einfach": "Einfach",
"resources.glazing.zweifach": "Zweifach",
"resources.glazing.dreifach": "Dreifach",
"resources.sillBoard.keine": "Keine",
"resources.sillBoard.innen": "Innen",
"resources.sillBoard.aussen": "Außen",
"resources.sillBoard.beide": "Beide",
"resources.structure.massiv": "Massiv",
"resources.structure.beton": "Betontreppe (Laufplatte)",
"resources.structure.wange": "Wange",
"resources.structure.aufgesattelt": "Aufgesattelt",
"resources.structure.spindel": "Spindel",
"resources.railing.keine": "Keine",
"resources.railing.links": "Links",
"resources.railing.rechts": "Rechts",
"resources.railing.beide": "Beide",
"resources.col.layer": "Schicht",
"resources.col.component": "Bauteil",
"resources.col.thickness": "Dicke (mm)",
@@ -698,6 +855,9 @@ export const de = {
"cmd.opening.dir": "Richtung",
"cmd.opening.sillField": "Brüstung",
"cmd.opening.swingField": "Winkel",
"cmd.column.label": "Stütze",
"cmd.column.place": "Stütze setzen:",
"cmd.column.profile": "Profil",
"cmd.stair.label": "Treppe",
"cmd.stair.start": "Startpunkt der Treppe:",
"cmd.stair.run": "Laufrichtung / Ende ( Grundform · Auf/Ab ):",
@@ -732,11 +892,23 @@ export const de = {
"cmd.arc.center": "Mittelpunkt:",
"cmd.arc.start": "Startpunkt (Radius + Startwinkel):",
"cmd.arc.end": "Endpunkt (Endwinkel):",
"cmd.text.label": "Text",
"cmd.text.point": "Ankerpunkt:",
"cmd.text.enter": "Text eingeben:",
"cmd.textbox.label": "Textspalte",
"cmd.textbox.point": "Ankerpunkt:",
"cmd.textbox.width": "Spaltenbreite (zweiter Punkt):",
"cmd.textbox.enter": "Text eingeben:",
// Editierbefehle (Tier 1): Move/Copy/Offset operieren auf der Auswahl.
"cmd.move.label": "Verschieben",
"cmd.move.base": "Basispunkt:",
"cmd.move.target": "Zielpunkt:",
"cmd.move.empty": "Nichts gewählt (erst Objekte wählen).",
"cmd.rotate.label": "Drehen",
"cmd.measure.label": "Messen",
"cmd.measure.start": "Messen von:",
"cmd.measure.to": "bis (Distanz · Winkel):",
"transform.needSelection": "Erst Objekte wählen, dann drehen.",
"cmd.mirror.label": "Spiegeln",
"cmd.mirror.first": "Erster Punkt der Spiegelachse:",
"cmd.mirror.second": "Zweiter Punkt der Spiegelachse:",
@@ -749,6 +921,10 @@ export const de = {
"cmd.offset.label": "Versatz",
"cmd.offset.pick": "Kurve wählen:",
"cmd.offset.side": "Seite oder Distanz:",
"cmd.extrude.label": "Extrudieren",
"cmd.extrude.pick": "Geschlossenes Profil wählen:",
"cmd.extrude.height": "Höhe:",
"cmd.extrude.taper": "Verjüngung (01, 1=Spitze):",
// Trim (Quick-Trim): auf den wegzuschneidenden Abschnitt klicken; wiederholend.
"cmd.trim.label": "Stutzen",
"cmd.trim.pick": "Abschnitt zum Wegschneiden anklicken (Esc beendet):",
@@ -863,11 +1039,43 @@ export const de = {
"file.exportPdf": "Als PDF exportieren…",
"file.exportDxf": "Als DXF exportieren…",
"file.exportSchedule": "Bauteilliste (CSV)…",
"file.exportIfc": "IFC exportieren",
"file.exportObj": "OBJ exportieren (3D-Mesh)",
"file.exportStl": "STL exportieren (3D-Mesh)",
"file.export": "Exportieren",
"file.openFailed": "Projekt-Datei konnte nicht gelesen werden.",
// ── Lock-Konflikt (Projektdatei bereits in anderer Instanz geöffnet) ────
"lock.title": "Projekt bereits geöffnet",
"lock.message":
"Diese Projektdatei ist bereits in einer anderen Instanz geöffnet.",
"lock.messageInfo": "Geöffnet auf {host} (Prozess {pid}).",
"lock.openReadOnly": "Schreibgeschützt öffnen",
"lock.forceOpen": "Trotzdem erzwingen",
"lock.forceOpenWarning":
"Achtung: Öffnet zum Schreiben, obwohl eine andere Instanz die Datei hält — Änderungen können sich gegenseitig überschreiben.",
"exportSave.title": "Exportieren",
"exportSave.format": "Format",
"exportSave.filename": "Dateiname",
"exportSave.locationNote": "Wird in den Standard-Download-Ordner gespeichert.",
"exportSave.fmt.csv": "Bauteilliste (CSV)",
"exportSave.fmt.ifc": "IFC (BIM)",
"exportSave.fmt.obj": "OBJ (3D-Mesh)",
"exportSave.fmt.stl": "STL (3D-Mesh)",
"about.title": "Über dossier",
"about.version": "Version",
"about.desc": "Open-Source-CAAD (Computer Aided Architecture Design) — schöne, normgerechte Pläne aus einem semantischen Gebäudemodell. Als Desktop-App (vollständige Fassung) und im Browser zugänglich.",
"about.copyright": "© 2026 Karim Gabriele Varano",
"about.license": "Lizenz: AGPL-3.0-or-later",
"about.suite": "Teil der openbureau-Suite",
"about.licenses": "Open-Source-Lizenzen (Drittkomponenten)",
"about.close": "Schließen",
// ── Einstellungs-Fenster ──────────────────────────────────────────────────
"topbar.settings": "Einstellungen",
"topbar.settings.hint": "Einstellungen öffnen (Darstellungsfarben, Projekt)",
"settings.title": "Einstellungen",
"settings.section.workspace": "Arbeitsumgebung",
"settings.section.display": "Darstellung",
"settings.marqueeColor": "Auswahlrahmen-Farbe",
"settings.marqueeColor.hint":
@@ -907,6 +1115,9 @@ export const de = {
"ctxImport.src.osmRoads": "OSM-Strassen",
"ctxImport.src.osmWater": "OSM-Wasser",
"ctxImport.src.osmGreen": "OSM-Grün",
"ctxImport.src.osmParking": "OSM-Parkplätze",
"ctxImport.src.osmRailway": "OSM-Bahn",
"ctxImport.src.osmForest": "OSM-Wald",
"ctxImport.cancel": "Abbrechen",
"ctxImport.confirm": "Importieren",
"ctxImport.importing": "Importiere…",
@@ -950,6 +1161,9 @@ export const de = {
"roomStamp.showFloorArea": "Bodenfläche anzeigen",
"roomStamp.floorAreaPrefix": "Präfix Bodenfläche",
"roomStamp.showUsage": "Nutzung anzeigen",
"roomStamp.roundingStep": "Flächen-Rundung",
"roomStamp.roundingStepDefault": "Standard (2 Nachkommastellen)",
"roomStamp.occupancy": "Personenzahl",
"text.selectedHint": "Formatierung wirkt auf den ausgewählten Text",
// ── Rich-Text-Editor ────────────────────────────────────────────────────────
@@ -958,6 +1172,8 @@ export const de = {
"rt.underline": "Unterstrichen",
"rt.strike": "Durchgestrichen",
"rt.strikethrough": "Durchgestrichen",
"rt.super": "Hochgestellt",
"rt.sub": "Tiefgestellt",
"rt.fontSize": "Schriftgrösse",
"rt.color": "Farbe",
"rt.preset": "Vorlage",
@@ -969,6 +1185,148 @@ export const de = {
"WebGPU konnte nicht initialisiert werden. In der Statusleiste auf WebGL2 umschalten, um die 3D-Ansicht weiter zu nutzen.",
"viewport3d.grid.show": "Bodenraster einblenden",
"viewport3d.grid.hide": "Bodenraster ausblenden",
"viewport3d.section.label": "Schnittebene",
"viewport3d.section.show": "Schnittebene ein",
"viewport3d.section.hide": "Schnittebene aus",
"viewport3d.grip.vertex": "Wandende ziehen",
"viewport3d.grip.height": "Höhe ziehen",
"viewport3d.grip.move": "Verschieben",
// ── Element-Baum (Elemente-Panel, ROADMAP §11) ──────────────────────────────
"elements.title": "Elemente",
"elements.search": "Elemente suchen…",
"elements.empty": "Keine Bauteile im Projekt.",
"elements.noMatch": "Keine Treffer.",
"elements.kind.wall": "Wand",
"elements.kind.ceiling": "Decke",
"elements.kind.window": "Fenster",
"elements.kind.opening": "Öffnung",
"elements.kind.stair": "Treppe",
"elements.kind.extrusion": "Extrusion",
// Ausschnitte / View-Snapshots (DOSSIER A2).
"viewsnap.title": "Ausschnitte",
"viewsnap.saveCurrent": " Aktuellen Ausschnitt speichern …",
"viewsnap.savePrompt": "Ausschnitt speichern als:",
"viewsnap.renamePrompt": "Ausschnitt umbenennen:",
"viewsnap.rename": "Umbenennen",
"viewsnap.delete": "Löschen",
"viewsnap.deleteConfirm": "Ausschnitt „{name}\" löschen?",
"viewsnap.empty": "Noch keine Ausschnitte. Ansicht einrichten und speichern.",
"viewsnap.applyHint": "Ausschnitt „{name}\" wiederherstellen",
"viewsnap.add.folder": "Ordner erstellen",
"viewsnap.add.snapshot": "Ausschnitt speichern",
"viewsnap.folder.defaultName": "Neuer Ordner",
"viewsnap.folder.rename": "Ordner umbenennen",
"viewsnap.folder.delete": "Ordner löschen",
"viewsnap.folder.empty": "Leerer Ordner",
"viewsnap.tree.empty": "Noch keine Ordner oder Ausschnitte. Über „+\" anlegen.",
"viewsnap.footer.scale": "Massstab",
"viewsnap.footer.layerCombo": "Ebenenkombi",
"viewsnap.footer.drawingCombo": "Zeichnungskombi",
"viewsnap.footer.overrides": "Overrides",
"viewsnap.footer.none": "—",
// Layout-Blätter mit Masterlayout (DOSSIER A3).
"layouts.panelTitle": "Mappe",
"layouts.title": "Layouts",
"layouts.new": "Anlegen",
"layouts.namePlaceholder": "Blattname …",
"layouts.defaultName": "Neues Layout",
"layouts.rename": "Umbenennen",
"layouts.delete": "Löschen",
"layouts.renamePrompt": "Umbenennen:",
"layouts.deleteConfirm": "„{name}\" löschen?",
"layouts.openHint": "Layout „{name}\" öffnen",
"layouts.empty": "Noch keine Layouts. Blatt anlegen und öffnen.",
"layouts.paper": "Papierformat",
"layouts.orientation": "Ausrichtung",
"layouts.portrait": "Hoch",
"layouts.landscape": "Quer",
"layouts.border": "Rahmen",
"layouts.masters": "Masterlayouts",
"layouts.newMaster": " Master",
"layouts.newMasterPrompt": "Neues Masterlayout:",
"layouts.defaultMasterName": "Master",
"layouts.noMasters": "Noch keine Masterlayouts.",
"layouts.master": "Master",
"layouts.noMaster": "Kein Master",
"layouts.tb.projectName": "Projekt",
"layouts.tb.sheetName": "Blattname",
"layouts.tb.scale": "Massstab",
"layouts.tb.date": "Datum",
"layouts.tb.author": "Bearbeiter",
"layouts.tb.autoPlaceholder": "(automatisch)",
// Editor
"layouts.editorTitle": "Layout-Editor",
"layouts.fit": "Einpassen",
"layouts.addViewport": "Viewport hinzufügen",
"layouts.pickSnapshot": "Ausschnitt wählen …",
"layouts.noSnapshots": "Keine Ausschnitte vorhanden. Zuerst einen Ausschnitt speichern.",
"layouts.viewportProps": "Viewport-Eigenschaften",
"layouts.noViewports": "Noch keine Viewports. Ausschnitt wählen und hinzufügen.",
"layouts.selectViewport": "Viewport auf dem Blatt anklicken.",
"layouts.missingSnapshot": "Ausschnitt fehlt",
"layouts.xMm": "X (mm)",
"layouts.yMm": "Y (mm)",
"layouts.widthMm": "Breite (mm)",
"layouts.heightMm": "Höhe (mm)",
"layouts.scale": "Massstab",
"layouts.scaleReset": "Massstab vom Ausschnitt",
"layouts.removeViewport": "Viewport entfernen",
"layouts.backToModel": "Zurück zum Modell",
"layouts.placeViewport": "Viewport aufziehen",
"layouts.placeHint": "Rechteck auf dem Blatt aufziehen, dann Ausschnitt binden.",
"layouts.bindSnapshot": "Ausschnitt binden",
"layouts.emptyViewports":
"Noch keine Viewports. „Viewport aufziehen\" wählen und ein Rechteck auf dem Blatt ziehen.",
// Annotationen (Linie/Rechteck/Text) direkt auf dem Blatt
"layouts.annotLine": "Linie zeichnen",
"layouts.annotRect": "Rechteck zeichnen",
"layouts.annotText": "Text setzen",
"layouts.annotTextTitle": "Text",
"layouts.annotTextLabel": "Textinhalt:",
"layouts.annotEditText": "Text ändern",
"layouts.annotColor": "Farbe",
"layouts.annotWeight": "Strichstärke (mm)",
"layouts.annotRemove": "Annotation entfernen",
"layouts.annot.line": "Linie",
"layouts.annot.rect": "Rechteck",
"layouts.annot.text": "Text",
// Baum / „+"-Menü / Ordner
"layouts.add": "Neu …",
"layouts.addFolder": "Ordner erstellen",
"layouts.addLayout": "Layout erstellen",
"layouts.addMaster": "Masterlayout erstellen",
"layouts.add.folder": "Ordner erstellen",
"layouts.add.layout": "Layout erstellen",
"layouts.add.master": "Masterlayout erstellen",
"layouts.folder.defaultName": "Neuer Ordner",
"layouts.folder.rename": "Ordner umbenennen",
"layouts.folder.delete": "Ordner löschen",
"layouts.folder.export": "Ordner als PDF exportieren",
"layouts.folder.empty": "Leerer Ordner",
"layouts.folder.exportEmpty": "Ordner enthält keine Layouts.",
"layouts.tree.empty": "Noch keine Ordner oder Layouts. Über „+\" anlegen.",
// Dialog „Neues Layout"
"layouts.newLayout.title": "Neues Layout",
"layouts.newLayout.source": "Vorlage",
"layouts.newLayout.fromMaster": "Masterlayout als Vorlage",
"layouts.newLayout.freeSize": "Freie Grösse",
"layouts.newLayout.pickMaster": "Masterlayout wählen …",
"layouts.newLayout.noMasters": "Keine Masterlayouts vorhanden.",
// Dialog „Neues Masterlayout"
"layouts.newMaster.title": "Neues Masterlayout",
// Gemeinsame Grössen-Auswahl (beide Dialoge)
"layouts.size.format": "Format",
"layouts.size.custom": "Freie Grösse",
"layouts.size.a4": "A4",
"layouts.size.a3": "A3",
"layouts.paperGroup.a": "A-Reihe",
"layouts.paperGroup.b": "B-Reihe",
"layouts.size.widthMm": "Breite (mm)",
"layouts.size.heightMm": "Höhe (mm)",
"layouts.size.create": "Erstellen",
} as const;
export type TranslationKey = keyof typeof de;
+362 -5
View File
@@ -16,14 +16,26 @@ export const en: Record<TranslationKey, string> = {
"view3d.group": "3D view",
"view3d.front": "Front",
"view3d.front.hint": "Front view (frontal)",
"view3d.back": "Back",
"view3d.back.hint": "Back view (rear)",
"view3d.top": "Top",
"view3d.top.hint": "Top view (straight down)",
"view3d.side": "Side",
"view3d.side.hint": "Side view (90° to front)",
"view3d.side": "Right",
"view3d.side.hint": "Right side view (90° to the front)",
"view3d.left": "Left",
"view3d.left.hint": "Left side view (90° to the front, opposite Right)",
"view3d.perspective": "Perspective",
"view3d.perspective.hint": "Free perspective (3/4 view)",
"view3d.iso": "Isometric",
"view3d.iso.hint": "Isometric view (equiangular from front-top-right)",
"view3d.isoFrontLeft": "Front Left",
"view3d.isoFrontLeft.hint": "Isometric view (equiangular from front-top-left)",
"view3d.isoBackRight": "Back Right",
"view3d.isoBackRight.hint": "Isometric view (equiangular from back-top-right)",
"view3d.isoBackLeft": "Back Left",
"view3d.isoBackLeft.hint": "Isometric view (equiangular from back-top-left)",
"view3d.iso.menu": "More isometric views",
"view3d.iso.menu.hint": "Pick one of the four upper isometric octants",
"view3d.camera": "Camera",
"view3d.camera.hint": "Camera settings (field of view)",
"view3d.fov": "Field of view",
@@ -85,12 +97,12 @@ export const en: Record<TranslationKey, string> = {
"topbar.resources.hint": "Show/hide the resources window",
// Layout menu.
"layout.title": "Layout",
"layout.placeholder": "Layout …",
"layout.title": "Workspace",
"layout.placeholder": "Workspace …",
"layout.saveCurrent": " Save current …",
"layout.loadGroup": "Load",
"layout.deleteGroup": "Delete",
"layout.savePrompt": "Save layout as:",
"layout.savePrompt": "Save workspace as:",
// Visibility combinations (topbar).
"combo.layers": "Layer comb.",
@@ -119,6 +131,10 @@ export const en: Record<TranslationKey, string> = {
"tool.circle.hint": "Circle: set center, then radius",
"tool.arc": "Arc",
"tool.arc.hint": "Arc: center, start point, then end point (CCW)",
"tool.text": "Text",
"tool.text.hint": "Text: set anchor point, then type the text",
"tool.textbox": "Text column",
"tool.textbox.hint": "Text column: anchor, drag column width, then type the text",
// Ribbon top bar (tabs + groups)
"ribbon.tab.2d": "2D",
"ribbon.tab.3d": "3D",
@@ -233,6 +249,7 @@ export const en: Record<TranslationKey, string> = {
"attr.fill": "Fill",
"attr.fillColor": "Fill color",
"attr.clearFill": "Remove fill color",
"attr.pickColor": "Choose color",
"attr.foreground": "Foreground",
"attr.background": "Background",
"attr.bySystem": "By system",
@@ -265,12 +282,29 @@ export const en: Record<TranslationKey, string> = {
"objinfo.kind.opening": "Opening",
"objinfo.kind.stair": "Stair",
"objinfo.kind.room": "Room",
"objinfo.kind.extrudedSolid": "Extrusion",
"objinfo.kind.column": "Column",
// ── Column attributes (object info panel) ────────────────────────────────
"objinfo.column.section": "Column",
"objinfo.column.profile": "Profile",
"objinfo.column.profile.rect": "Rectangle",
"objinfo.column.profile.round": "Round",
"objinfo.column.width": "Width",
"objinfo.column.depth": "Depth",
"objinfo.column.radius": "Radius",
"objinfo.column.height": "Height",
"objinfo.column.rotation": "Rotation (°)",
"objinfo.column.floor": "Floor",
// ── Wall attributes (object info panel) ──────────────────────────────────
"objinfo.wall.section": "Wall",
"objinfo.wall.refLine": "Reference line",
"objinfo.wall.refLine.left": "Exterior (left)",
"objinfo.wall.refLine.center": "Center",
"objinfo.wall.refLine.right": "Interior (right)",
"objinfo.wall.sliceTermination": "End at ceiling",
"objinfo.wall.sliceTermination.both": "Off",
"objinfo.wall.sliceTermination.below": "Below",
"objinfo.wall.sliceTermination.above": "Above",
"objinfo.wall.buildup": "Build-up",
"objinfo.wall.single": "Solid",
"objinfo.wall.multi": "Multi layer",
@@ -283,6 +317,11 @@ export const en: Record<TranslationKey, string> = {
"objinfo.wall.top": "Top (OK)",
"objinfo.wall.anchorFloor": "Bound",
"objinfo.wall.anchorCustom": "Custom",
"objinfo.anchor.refFloor": "Reference storey",
"objinfo.anchor.customHeight": "Custom height",
"objinfo.wall.length": "Length",
"objinfo.wall.volumeGross": "Gross",
"objinfo.volume": "Volume",
"objinfo.wall.height": "Height",
"objinfo.wall.floorAboveNone": "No floor above",
// ── Ceiling attributes (object info panel) ───────────────────────────────
@@ -299,6 +338,9 @@ export const en: Record<TranslationKey, string> = {
"objinfo.ceiling.anchorCustom": "Custom",
// ── Opening attributes (object info panel) ──────────────────────────────
"objinfo.opening.section": "Opening",
"objinfo.opening.type": "Type",
"objinfo.stair.type": "Type",
"objinfo.type.none": "(no type)",
"objinfo.opening.kind": "Type",
"objinfo.opening.kind.window": "Window",
"objinfo.opening.kind.door": "Door",
@@ -356,6 +398,15 @@ export const en: Record<TranslationKey, string> = {
"objinfo.room.perimeter": "Perimeter",
"objinfo.room.refFloor": "Reference floor",
"objinfo.room.editStamp": "Edit stamp …",
// ── Extruded solid attributes (truck integration, object info panel) ────
"objinfo.extrudedSolid.section": "Extrusion",
"objinfo.extrudedSolid.height": "Height",
"objinfo.extrudedSolid.taper": "Taper",
"objinfo.extrudedSolid.area": "Footprint area",
"objinfo.extrudedSolid.floor": "Floor",
"objinfo.rotate": "Rotate",
"objinfo.drawing.length": "Length",
"objinfo.drawing.radius": "Radius",
"levels.addFloor": "+ Floor",
"levels.addSection": "+ Section",
"levels.addElevation": "+ Elevation",
@@ -484,6 +535,9 @@ export const en: Record<TranslationKey, string> = {
"default.ceilingTypeSingle": "Ceiling {thickness} m",
"default.wallTypeName": "New wall type",
"default.ceilingTypeName": "New ceiling type",
"default.doorTypeName": "New door type",
"default.windowTypeName": "New window type",
"default.stairTypeName": "New stair type",
// Alerts.
"alert.layerInUse": "Layer “{code} {name}” is used by components and cannot be deleted.",
@@ -492,6 +546,9 @@ export const en: Record<TranslationKey, string> = {
"alert.lineStyleInUse": "Line style “{name}” is used by a hatch and cannot be deleted.",
"alert.wallTypeInUse": "Wall type “{name}” is used by a wall and cannot be deleted.",
"alert.ceilingTypeInUse": "Ceiling type “{name}” is used by a ceiling and cannot be deleted.",
"alert.doorTypeInUse": "Door type “{name}” is used by a door and cannot be deleted.",
"alert.windowTypeInUse": "Window type “{name}” is used by a window and cannot be deleted.",
"alert.stairTypeInUse": "Stair type “{name}” is used by a stair and cannot be deleted.",
// Resource manager.
"resources.title": "Resources",
@@ -502,7 +559,38 @@ export const en: Record<TranslationKey, string> = {
"resources.tab.lines": "Lines",
"resources.tab.wallStyles": "Wall styles",
"resources.tab.ceilingStyles": "Ceiling styles",
"resources.tab.doorStyles": "Door types",
"resources.tab.windowStyles": "Window types",
"resources.tab.stairStyles": "Stair types",
"resources.tab.materials": "Materials",
"resources.tab.overrides": "Overrides",
// Graphic overrides (rule engine).
"resources.overrides.hint":
"Rules override color/lineweight/line style at render time (element data stays untouched). Order = priority: per field, the topmost active rule wins.",
"resources.overrides.empty": "No override rules yet.",
"resources.overrides.placeholder": "Rule",
"resources.overrides.defaultName": "New rule",
"resources.overrides.delete": "Delete rule",
"resources.overrides.moveUp": "Move rule up (higher priority)",
"resources.overrides.moveDown": "Move rule down (lower priority)",
"resources.overrides.enabled": "Rule active",
"resources.overrides.condition": "Condition",
"resources.overrides.condType": "Criterion",
"resources.overrides.condType.layer": "Layer (name/code)",
"resources.overrides.condType.object": "Object name",
"resources.overrides.operator": "Operator",
"resources.overrides.op.equals": "equals",
"resources.overrides.op.contains": "contains",
"resources.overrides.op.startsWith": "starts with",
"resources.overrides.op.notEquals": "not equal to",
"resources.overrides.value": "Value",
"resources.overrides.value.placeholder": "e.g. Walls or 20",
"resources.overrides.actions": "Actions",
"resources.overrides.action.color": "Color",
"resources.overrides.action.lineweight": "Lineweight (mm)",
"resources.overrides.action.linetype": "Line style",
"resources.overrides.linetype.none": "— (unchanged)",
"resources.wallStyles.hint":
"Per wall type, the layer joints (between adjacent layers) as a selectable line style. Unassigned: 0.02 mm hairline.",
@@ -512,6 +600,75 @@ export const en: Record<TranslationKey, string> = {
"resources.ceilingStyles.empty": "No ceiling types yet.",
"resources.wallStyles.placeholder": "Wall type",
"resources.ceilingStyles.placeholder": "Ceiling type",
// Component types (door/window/stair) — form-based libraries.
"resources.doorTypes.hint":
"Reusable door types with construction, leaf style and default dimensions for newly placed doors.",
"resources.doorTypes.empty": "No door types yet.",
"resources.doorTypes.placeholder": "Door type",
"resources.windowTypes.hint":
"Reusable window types with opening style, glazing and default dimensions for newly placed windows.",
"resources.windowTypes.empty": "No window types yet.",
"resources.windowTypes.placeholder": "Window type",
"resources.stairTypes.hint":
"Reusable stair types with structure, tread style and railing for newly placed stairs.",
"resources.stairTypes.empty": "No stair types yet.",
"resources.stairTypes.placeholder": "Stair type",
"resources.delete.doorType": "Delete door type “{name}”",
"resources.delete.windowType": "Delete window type “{name}”",
"resources.delete.stairType": "Delete stair type “{name}”",
"resources.field.doorKind": "Construction",
"resources.field.leafCount": "Leaf count",
"resources.field.leafStyle": "Leaf style",
"resources.field.glazingRatio": "Glazing ratio",
"resources.field.frameThickness": "Frame thickness (m)",
"resources.field.frameDepth": "Frame depth (m)",
"resources.field.defaultWidth": "Clear width (m)",
"resources.field.defaultHeight": "Clear height (m)",
"resources.field.threshold": "Threshold",
"resources.field.windowKind": "Opening style",
"resources.field.wingCount": "Wing count",
"resources.field.glazing": "Glazing",
"resources.field.defaultSillHeight": "Sill height (m)",
"resources.field.sillBoard": "Sill board",
"resources.field.structure": "Structure",
"resources.field.closedRisers": "Closed risers",
"resources.field.treadThickness": "Tread thickness (m)",
"resources.field.nosing": "Nosing (m)",
"resources.field.railing": "Railing",
"resources.field.stairWidth": "Run width (m)",
"resources.doorKind.dreh": "Hinged",
"resources.doorKind.schiebe": "Sliding",
"resources.doorKind.wandoeffnung": "Wall opening",
"resources.leafCount.1": "Single-leaf",
"resources.leafCount.2": "Double-leaf",
"resources.leafStyle.glatt": "Flush",
"resources.leafStyle.kassette": "Panelled",
"resources.leafStyle.glas": "Glazed",
"resources.windowKind.dreh": "Turn",
"resources.windowKind.kipp": "Tilt",
"resources.windowKind.drehkipp": "Turn-tilt",
"resources.windowKind.fest": "Fixed",
"resources.windowKind.schiebe": "Sliding",
"resources.glazing.einfach": "Single",
"resources.glazing.zweifach": "Double",
"resources.glazing.dreifach": "Triple",
"resources.sillBoard.keine": "None",
"resources.sillBoard.innen": "Inside",
"resources.sillBoard.aussen": "Outside",
"resources.sillBoard.beide": "Both",
"resources.structure.massiv": "Solid",
"resources.structure.beton": "Concrete (waist slab)",
"resources.structure.wange": "String",
"resources.structure.aufgesattelt": "Saddled",
"resources.structure.spindel": "Spiral",
"resources.railing.keine": "None",
"resources.railing.links": "Left",
"resources.railing.rechts": "Right",
"resources.railing.beide": "Both",
"resources.col.layer": "Layer",
"resources.col.component": "Component",
"resources.col.thickness": "Thickness (mm)",
@@ -688,6 +845,9 @@ export const en: Record<TranslationKey, string> = {
"cmd.opening.dir": "Direction",
"cmd.opening.sillField": "Sill",
"cmd.opening.swingField": "Angle",
"cmd.column.label": "Column",
"cmd.column.place": "Place column:",
"cmd.column.profile": "Profile",
"cmd.stair.label": "Stair",
"cmd.stair.start": "Start point of the stair:",
"cmd.stair.run": "Run direction / end ( Shape · Up/Down ):",
@@ -722,10 +882,22 @@ export const en: Record<TranslationKey, string> = {
"cmd.arc.center": "Center:",
"cmd.arc.start": "Start point (radius + start angle):",
"cmd.arc.end": "End point (end angle):",
"cmd.text.label": "Text",
"cmd.text.point": "Anchor point:",
"cmd.text.enter": "Enter text:",
"cmd.textbox.label": "Text column",
"cmd.textbox.point": "Anchor point:",
"cmd.textbox.width": "Column width (second point):",
"cmd.textbox.enter": "Enter text:",
"cmd.move.label": "Move",
"cmd.move.base": "Base point:",
"cmd.move.target": "Target point:",
"cmd.move.empty": "Nothing selected (select objects first).",
"cmd.rotate.label": "Rotate",
"cmd.measure.label": "Measure",
"cmd.measure.start": "Measure from:",
"cmd.measure.to": "to (distance · angle):",
"transform.needSelection": "Select objects first, then rotate.",
"cmd.mirror.label": "Mirror",
"cmd.mirror.first": "First point of mirror axis:",
"cmd.mirror.second": "Second point of mirror axis:",
@@ -738,6 +910,10 @@ export const en: Record<TranslationKey, string> = {
"cmd.offset.label": "Offset",
"cmd.offset.pick": "Select curve:",
"cmd.offset.side": "Side or distance:",
"cmd.extrude.label": "Extrude",
"cmd.extrude.pick": "Select closed profile:",
"cmd.extrude.height": "Height:",
"cmd.extrude.taper": "Taper (01, 1=point):",
// 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):",
@@ -852,11 +1028,42 @@ export const en: Record<TranslationKey, string> = {
"file.exportPdf": "Export as PDF…",
"file.exportDxf": "Export as DXF…",
"file.exportSchedule": "Component schedule (CSV)…",
"file.exportIfc": "Export IFC",
"file.exportObj": "Export OBJ (3D mesh)",
"file.exportStl": "Export STL (3D mesh)",
"file.export": "Export",
"file.openFailed": "Could not read the project file.",
// Lock conflict (project file already open in another instance).
"lock.title": "Project already open",
"lock.message": "This project file is already open in another instance.",
"lock.messageInfo": "Opened on {host} (process {pid}).",
"lock.openReadOnly": "Open read-only",
"lock.forceOpen": "Force open anyway",
"lock.forceOpenWarning":
"Warning: opens for writing even though another instance holds the file — changes may overwrite each other.",
"exportSave.title": "Export",
"exportSave.format": "Format",
"exportSave.filename": "File name",
"exportSave.locationNote": "Saved to the default downloads folder.",
"exportSave.fmt.csv": "Component schedule (CSV)",
"exportSave.fmt.ifc": "IFC (BIM)",
"exportSave.fmt.obj": "OBJ (3D mesh)",
"exportSave.fmt.stl": "STL (3D mesh)",
"about.title": "About dossier",
"about.version": "Version",
"about.desc": "Open-source CAAD (Computer Aided Architecture Design) — beautiful, standard-compliant drawings from a semantic building model. Available as a desktop app (full version) and in the browser.",
"about.copyright": "© 2026 Karim Gabriele Varano",
"about.license": "License: AGPL-3.0-or-later",
"about.suite": "Part of the openbureau suite",
"about.licenses": "Open-source licenses (third-party)",
"about.close": "Close",
// Settings dialog.
"topbar.settings": "Settings",
"topbar.settings.hint": "Open settings (display colors, project)",
"settings.title": "Settings",
"settings.section.workspace": "Workspace",
"settings.section.display": "Display",
"settings.marqueeColor": "Selection box color",
"settings.marqueeColor.hint":
@@ -896,6 +1103,9 @@ export const en: Record<TranslationKey, string> = {
"ctxImport.src.osmRoads": "OSM roads",
"ctxImport.src.osmWater": "OSM water",
"ctxImport.src.osmGreen": "OSM greenery",
"ctxImport.src.osmParking": "OSM parking",
"ctxImport.src.osmRailway": "OSM railway",
"ctxImport.src.osmForest": "OSM forest",
"ctxImport.cancel": "Cancel",
"ctxImport.confirm": "Import",
"ctxImport.importing": "Importing…",
@@ -940,6 +1150,9 @@ export const en: Record<TranslationKey, string> = {
"roomStamp.showFloorArea": "Show floor area",
"roomStamp.floorAreaPrefix": "Floor area prefix",
"roomStamp.showUsage": "Show usage",
"roomStamp.roundingStep": "Area rounding",
"roomStamp.roundingStepDefault": "Default (2 decimal places)",
"roomStamp.occupancy": "Occupancy",
"text.selectedHint": "Formatting applies to the selected text",
"rt.bold": "Bold",
@@ -947,6 +1160,8 @@ export const en: Record<TranslationKey, string> = {
"rt.underline": "Underline",
"rt.strike": "Strikethrough",
"rt.strikethrough": "Strikethrough",
"rt.super": "Superscript",
"rt.sub": "Subscript",
"rt.fontSize": "Font size",
"rt.color": "Color",
"rt.preset": "Preset",
@@ -958,4 +1173,146 @@ export const en: Record<TranslationKey, string> = {
"WebGPU could not be initialized. Switch to WebGL2 in the status bar to keep using the 3D view.",
"viewport3d.grid.show": "Show ground grid",
"viewport3d.grid.hide": "Hide ground grid",
"viewport3d.section.label": "Section plane",
"viewport3d.section.show": "Section plane on",
"viewport3d.section.hide": "Section plane off",
"viewport3d.grip.vertex": "Drag wall endpoint",
"viewport3d.grip.height": "Drag height",
"viewport3d.grip.move": "Move",
// ── Element tree (Elements panel, ROADMAP §11) ──────────────────────────────
"elements.title": "Elements",
"elements.search": "Search elements…",
"elements.empty": "No components in the project.",
"elements.noMatch": "No matches.",
"elements.kind.wall": "Wall",
"elements.kind.ceiling": "Ceiling",
"elements.kind.window": "Window",
"elements.kind.opening": "Opening",
"elements.kind.stair": "Stair",
"elements.kind.extrusion": "Extrusion",
// Views / view snapshots (DOSSIER A2).
"viewsnap.title": "Views",
"viewsnap.saveCurrent": " Save current view …",
"viewsnap.savePrompt": "Save view as:",
"viewsnap.renamePrompt": "Rename view:",
"viewsnap.rename": "Rename",
"viewsnap.delete": "Delete",
"viewsnap.deleteConfirm": "Delete view “{name}”?",
"viewsnap.empty": "No views yet. Set up a view and save it.",
"viewsnap.applyHint": "Restore view “{name}”",
"viewsnap.add.folder": "Create folder",
"viewsnap.add.snapshot": "Save view",
"viewsnap.folder.defaultName": "New folder",
"viewsnap.folder.rename": "Rename folder",
"viewsnap.folder.delete": "Delete folder",
"viewsnap.folder.empty": "Empty folder",
"viewsnap.tree.empty": "No folders or views yet. Add via “+”.",
"viewsnap.footer.scale": "Scale",
"viewsnap.footer.layerCombo": "Layer combo",
"viewsnap.footer.drawingCombo": "Drawing combo",
"viewsnap.footer.overrides": "Overrides",
"viewsnap.footer.none": "—",
// Layout sheets with master layout (DOSSIER A3).
"layouts.panelTitle": "Portfolio",
"layouts.title": "Layouts",
"layouts.new": "Create",
"layouts.namePlaceholder": "Sheet name …",
"layouts.defaultName": "New layout",
"layouts.rename": "Rename",
"layouts.delete": "Delete",
"layouts.renamePrompt": "Rename:",
"layouts.deleteConfirm": "Delete “{name}”?",
"layouts.openHint": "Open layout “{name}”",
"layouts.empty": "No layouts yet. Create a sheet and open it.",
"layouts.paper": "Paper size",
"layouts.orientation": "Orientation",
"layouts.portrait": "Portrait",
"layouts.landscape": "Landscape",
"layouts.border": "Border",
"layouts.masters": "Master layouts",
"layouts.newMaster": " Master",
"layouts.newMasterPrompt": "New master layout:",
"layouts.defaultMasterName": "Master",
"layouts.noMasters": "No master layouts yet.",
"layouts.master": "Master",
"layouts.noMaster": "No master",
"layouts.tb.projectName": "Project",
"layouts.tb.sheetName": "Sheet name",
"layouts.tb.scale": "Scale",
"layouts.tb.date": "Date",
"layouts.tb.author": "Author",
"layouts.tb.autoPlaceholder": "(automatic)",
// Editor
"layouts.editorTitle": "Layout editor",
"layouts.fit": "Fit",
"layouts.addViewport": "Add viewport",
"layouts.pickSnapshot": "Pick a view …",
"layouts.noSnapshots": "No views available. Save a view first.",
"layouts.viewportProps": "Viewport properties",
"layouts.noViewports": "No viewports yet. Pick a view and add it.",
"layouts.selectViewport": "Click a viewport on the sheet.",
"layouts.missingSnapshot": "View missing",
"layouts.xMm": "X (mm)",
"layouts.yMm": "Y (mm)",
"layouts.widthMm": "Width (mm)",
"layouts.heightMm": "Height (mm)",
"layouts.scale": "Scale",
"layouts.scaleReset": "Scale from view",
"layouts.removeViewport": "Remove viewport",
"layouts.backToModel": "Back to model",
"layouts.placeViewport": "Draw viewport",
"layouts.placeHint": "Drag a rectangle on the sheet, then bind a snapshot.",
"layouts.bindSnapshot": "Bind snapshot",
"layouts.emptyViewports":
"No viewports yet. Choose “Draw viewport” and drag a rectangle on the sheet.",
// Annotations (line/rect/text) drawn directly on the sheet
"layouts.annotLine": "Draw line",
"layouts.annotRect": "Draw rectangle",
"layouts.annotText": "Place text",
"layouts.annotTextTitle": "Text",
"layouts.annotTextLabel": "Text content:",
"layouts.annotEditText": "Edit text",
"layouts.annotColor": "Color",
"layouts.annotWeight": "Line weight (mm)",
"layouts.annotRemove": "Remove annotation",
"layouts.annot.line": "Line",
"layouts.annot.rect": "Rectangle",
"layouts.annot.text": "Text",
// Tree / “+” menu / folders
"layouts.add": "New …",
"layouts.addFolder": "Create folder",
"layouts.addLayout": "Create layout",
"layouts.addMaster": "Create master layout",
"layouts.add.folder": "Create folder",
"layouts.add.layout": "Create layout",
"layouts.add.master": "Create master layout",
"layouts.folder.defaultName": "New folder",
"layouts.folder.rename": "Rename folder",
"layouts.folder.delete": "Delete folder",
"layouts.folder.export": "Export folder as PDF",
"layouts.folder.empty": "Empty folder",
"layouts.folder.exportEmpty": "Folder contains no layouts.",
"layouts.tree.empty": "No folders or layouts yet. Add one via “+”.",
// “New layout” dialog
"layouts.newLayout.title": "New layout",
"layouts.newLayout.source": "Template",
"layouts.newLayout.fromMaster": "Master layout as template",
"layouts.newLayout.freeSize": "Free size",
"layouts.newLayout.pickMaster": "Pick a master layout …",
"layouts.newLayout.noMasters": "No master layouts available.",
// “New master layout” dialog
"layouts.newMaster.title": "New master layout",
// Shared size picker (both dialogs)
"layouts.size.format": "Format",
"layouts.size.custom": "Free size",
"layouts.size.a4": "A4",
"layouts.size.a3": "A3",
"layouts.paperGroup.a": "A series",
"layouts.paperGroup.b": "B series",
"layouts.size.widthMm": "Width (mm)",
"layouts.size.heightMm": "Height (mm)",
"layouts.size.create": "Create",
};
+15 -2
View File
@@ -2,7 +2,7 @@
//
// 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
// kategorisiert nach Art (Gebäude/Strasse/Wasser/Grün/Parkplatz/Bahn/Wald). Die UI wandelt diese in
// `ContourSet`-Kontext-Objekte pro Kategorie um und legt sie in `project.context`.
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
@@ -12,7 +12,14 @@ import type { GeoOrigin } from "./lv95";
import type { ContextObject, Contour, ImportedMesh } from "../model/types";
/** Fachliche Kategorie einer importierten Kontext-Linie. */
export type GeoCategory = "building" | "road" | "water" | "green";
export type GeoCategory =
| "building"
| "road"
| "water"
| "green"
| "parking"
| "railway"
| "forest";
/** Ein importiertes Polygon/Polylinie in lokalen Modell-Metern. */
export interface GeoFeature {
@@ -44,6 +51,9 @@ const CATEGORY_LABEL: Record<GeoCategory, string> = {
road: "Strassen",
water: "Gewässer",
green: "Grünflächen",
parking: "Parkplätze",
railway: "Bahn",
forest: "Wald",
};
/** Layer-Kennung (englischer Identifier) je Kategorie — für spätere Zuordnung. */
@@ -52,6 +62,9 @@ const CATEGORY_LAYER: Record<GeoCategory, string> = {
road: "context-roads",
water: "context-water",
green: "context-green",
parking: "context-parking",
railway: "context-railway",
forest: "context-forest",
};
/** Erzeugt eine (kollisionsarme) ID für ein neues Kontext-Objekt. */
+28 -8
View File
@@ -1,7 +1,8 @@
// 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
// Holt für eine LV95-Box Gebäude / Strassen / Wasser / Grünflächen / Parkplätze /
// Bahnlinien / Wald 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
@@ -24,6 +25,9 @@ export interface OsmSelection {
roads: boolean;
water: boolean;
green: boolean;
parking: boolean;
railway: boolean;
forest: boolean;
}
/** Ein Overpass-Element mit inline-Geometrie (`out geom`). */
@@ -55,7 +59,12 @@ function buildQuery(
}
if (sel.green) {
parts.push(`way["leisure"="park"](${b});`);
parts.push(`way["landuse"~"grass|forest|meadow|recreation_ground"](${b});`);
parts.push(`way["landuse"~"grass|meadow|recreation_ground"](${b});`);
}
if (sel.parking) parts.push(`way["amenity"="parking"](${b});`);
if (sel.railway) parts.push(`way["railway"~"rail|tram"](${b});`);
if (sel.forest) {
parts.push(`way["landuse"="forest"](${b});`);
parts.push(`way["natural"="wood"](${b});`);
}
return `[out:json][timeout:40];(${parts.join("")});out geom;`;
@@ -69,20 +78,23 @@ function categorize(
if (!tags) return null;
if (sel.buildings && tags.building) return "building";
if (sel.water && (tags.natural === "water" || tags.waterway)) return "water";
if (sel.forest && (tags.natural === "wood" || tags.landuse === "forest"))
return "forest";
if (
sel.green &&
(tags.leisure === "park" ||
tags.natural === "wood" ||
/grass|forest|meadow|recreation_ground/.test(tags.landuse ?? ""))
/grass|meadow|recreation_ground/.test(tags.landuse ?? ""))
)
return "green";
if (sel.parking && tags.amenity === "parking") return "parking";
if (sel.railway && /rail|tram/.test(tags.railway ?? "")) return "railway";
if (sel.roads && tags.highway) return "road";
return null;
}
/** Geschlossene Kategorien (Flächen) vs. offene (Strassen). */
/** Geschlossene Kategorien (Flächen) vs. offene (Strassen/Bahn). */
function isClosedCategory(cat: GeoCategory): boolean {
return cat !== "road";
return cat !== "road" && cat !== "railway";
}
/**
@@ -118,7 +130,15 @@ export async function fetchOsm(
originIn?: GeoOrigin,
): Promise<{ origin: GeoOrigin; features: GeoFeature[] }> {
const origin = originIn ?? makeOrigin(center);
if (!sel.buildings && !sel.roads && !sel.water && !sel.green) {
if (
!sel.buildings &&
!sel.roads &&
!sel.water &&
!sel.green &&
!sel.parking &&
!sel.railway &&
!sel.forest
) {
return { origin, features: [] };
}
+161
View File
@@ -0,0 +1,161 @@
// Eigenes Projektdatei-Format (Endung `.obp`, "openbureau project") + Lock-Datei
// gegen gleichzeitiges Öffnen derselben Projektdatei aus zwei Instanzen.
//
// Der Dateiinhalt ist unverändert das bisherige Projekt-JSON — nur Endung/
// Dialog-Filter sind neu. Alte `.json`-Projekte bleiben ladbar (Filter erlaubt
// beide, s. PROJECT_DIALOG_FILTERS).
//
// Der Lock ist ein OS-Advisory-Lock im Rust-Kern (src-tauri/src/lock.rs, Crate
// `fs4`): eine Sidecar-Datei `foo.obp.lock` wird exklusiv gelockt, solange die
// Instanz die Datei offen hat. Der OS-Lock fällt beim Prozessende (auch bei
// Absturz) automatisch weg — hier nur "best effort" für sauberes Timing beim
// normalen Schliessen. Nur unter Tauri relevant: im reinen Browser gibt es
// keinen echten Dateipfad (Blob-Download), daher entfällt der Lock dort.
import { saveTextFile } from "./saveFile";
/** Datei-Endung für Projektdateien (ohne Punkt). Einzige Änderungsstelle. */
export const PROJECT_EXT = "obp";
/** Dialog-Filter für den nativen Speichern-/Öffnen-Dialog: neue Endung + Alt-JSON. */
export const PROJECT_DIALOG_FILTERS = [
{ name: "openbureau-Projekt", extensions: [PROJECT_EXT] },
{ name: "openbureau-Projekt (alt, JSON)", extensions: ["json"] },
];
/** Erkennt Tauri wie an anderen Stellen (saveFile.ts, nativeSync.ts). */
export function isTauriRuntime(): boolean {
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
}
/** Ergebnis eines erfolgreichen nativen Öffnen-Vorgangs. */
export interface OpenedProjectFile {
path: string;
content: string;
}
/**
* Öffnet eine Projektdatei über den NATIVEN Öffnen-Dialog (Filter: `.obp` +
* `.json`). Nur unter Tauri sinnvoll — liefert `null` im Browser, dort greift
* weiterhin der bestehende `<input type=file>`-Fluss (FileReader).
*
* @returns `null` bei Abbruch/ohne Tauri, sonst Pfad + Textinhalt.
*/
export async function openProjectFileNative(): Promise<OpenedProjectFile | null> {
if (!isTauriRuntime()) return null;
const { open } = await import("@tauri-apps/plugin-dialog");
const selected = await open({
multiple: false,
directory: false,
filters: PROJECT_DIALOG_FILTERS,
});
if (!selected || Array.isArray(selected)) return null; // Abbruch (oder Mehrfachauswahl, sollte hier nicht vorkommen)
const { readTextFile } = await import("@tauri-apps/plugin-fs");
const content = await readTextFile(selected);
return { path: selected, content };
}
/**
* Speichert das Projekt-JSON in eine `.obp`-Datei. Unter Tauri nativer
* Speichern-Dialog mit Projekt-Filter, im Browser Blob-Download — beides via
* {@link saveTextFile} (dort steckt die WKWebView-Härtung).
*
* @param json Fertig serialisiertes Projekt (JSON.stringify des ProjectSlice).
* @param projectName `project.name` für den Standard-Dateinamen (Fallback "projekt").
*/
export async function saveProjectFile(json: string, projectName: string): Promise<boolean> {
const filename = `${projectName || "projekt"}.${PROJECT_EXT}`;
return saveTextFile(json, filename, "application/json", PROJECT_DIALOG_FILTERS);
}
// ── Lock gegen Doppelöffnung (nur Tauri) ────────────────────────────────────
/** Info-Block aus der Lock-Sidecar-Datei — für die Konflikt-Meldung an den Nutzer. */
export interface LockInfo {
pid: number;
hostname: string;
timestamp: string;
}
export type LockResult = { ok: true } | { ok: false; info: LockInfo | null };
/**
* Holt (ungeguardet) die Tauri-`invoke`-Funktion, oder `null` ohne Tauri-Core.
* Anders als das Muster in compute/index.ts bzw. plan/nativeSync.ts schluckt
* dieser Helfer KEINE Fehler aus dem eigentlichen Aufruf — ein `Err(...)` vom
* Rust-Command (hier: „schon gelockt", inkl. {@link LockInfo}-Payload) muss bis
* zum Aufrufer durchgereicht werden.
*/
async function tauriInvokeFn(): Promise<
((cmd: string, args?: Record<string, unknown>) => Promise<unknown>) | 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 (mod as any).invoke;
} catch {
return null;
}
}
/**
* Fordert den exklusiven Lock für `path` an (Rust-Command `acquire_project_lock`,
* legt `path + ".lock"` an und hält einen fs4-OS-Lock darauf). Im Browser (kein
* Tauri) sofort `{ok:true}` — dort gibt es keinen echten Pfad, also keinen Lock.
*/
export async function acquireProjectLock(path: string): Promise<LockResult> {
if (!isTauriRuntime()) return { ok: true };
const invoke = await tauriInvokeFn();
if (!invoke) return { ok: true }; // Tauri-Core nicht verfügbar -> nicht blockieren
try {
await invoke("acquire_project_lock", { path });
return { ok: true };
} catch (err) {
return { ok: false, info: isLockInfo(err) ? err : null };
}
}
/** Gibt den Lock für `path` frei (Rust-Command `release_project_lock`). No-op ohne Tauri. */
export async function releaseProjectLock(path: string): Promise<void> {
if (!isTauriRuntime()) return;
const invoke = await tauriInvokeFn();
if (!invoke) return;
try {
await invoke("release_project_lock", { path });
} catch {
// Freigabe ist best effort — der OS-Lock fällt beim Prozessende ohnehin weg.
}
}
function isLockInfo(v: unknown): v is LockInfo {
return (
typeof v === "object" &&
v !== null &&
typeof (v as LockInfo).pid === "number" &&
typeof (v as LockInfo).hostname === "string"
);
}
/**
* Best-effort Freigabe beim Schliessen des Fensters (sauberes Timing): der
* OS-Lock fällt beim Prozessende/Absturz ohnehin automatisch weg (fs4-Handle
* schliesst mit dem Prozess) — dieser Hook sorgt nur dafür, dass ein normales
* Schliessen die Lock-Datei nicht unnötig bis zum nächsten GC/Poll blockiert.
* No-op ohne Tauri.
*/
export function registerLockAutoRelease(getPath: () => string | null): void {
if (!isTauriRuntime()) return;
void (async () => {
try {
const { getCurrentWindow } = await import("@tauri-apps/api/window");
const win = getCurrentWindow();
await win.onCloseRequested(() => {
const path = getPath();
if (path) void releaseProjectLock(path);
});
} catch {
// Tauri-window-API nicht verfügbar -> ignorieren, OS-Lock greift ohnehin.
}
})();
}
+51
View File
@@ -0,0 +1,51 @@
// Tests fuer saveTextFile. Vitest laeuft hier im "node"-Env (kein jsdom), daher
// stubben wir eine minimale DOM-/URL-Umgebung, um den Browser-Fallback-Pfad zu
// pruefen — ohne echten Tauri (kein `__TAURI_INTERNALS__`).
import { afterEach, describe, expect, it, vi } from "vitest";
import { saveTextFile } from "./saveFile";
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
/** Stubt Blob, URL und ein rudimentaeres document fuer den Anchor-Download. */
function stubBrowserEnv() {
const clicked: string[] = [];
const anchor: Record<string, unknown> = {
click: () => clicked.push("click"),
};
vi.stubGlobal("URL", {
createObjectURL: () => "blob:stub",
revokeObjectURL: () => {},
});
vi.stubGlobal("document", {
createElement: () => anchor,
body: { appendChild: () => {}, removeChild: () => {} },
});
return { clicked, anchor };
}
describe("saveTextFile", () => {
it("nimmt im Nicht-Tauri-Umfeld den Browser-Download und liefert true", async () => {
// Kein __TAURI_INTERNALS__ → Browser-Pfad.
const hadWindow = typeof globalThis.window !== "undefined";
if (!hadWindow) vi.stubGlobal("window", {});
const { clicked, anchor } = stubBrowserEnv();
const ok = await saveTextFile("hallo", "modell.txt", "text/plain");
expect(ok).toBe(true);
expect(clicked).toContain("click");
expect(anchor.download).toBe("modell.txt");
});
it("nutzt den Standard-MIME-Typ ohne Angabe, ohne zu crashen", async () => {
const hadWindow = typeof globalThis.window !== "undefined";
if (!hadWindow) vi.stubGlobal("window", {});
stubBrowserEnv();
await expect(saveTextFile("x", "a.bin")).resolves.toBe(true);
});
});
+116
View File
@@ -0,0 +1,116 @@
// Datei-Speicherung mit nativem „Speichern unter"-Dialog im Tauri-Fenster,
// im reinen Browser Fallback auf den klassischen Blob-/Anchor-Download.
//
// Wichtig: Die Tauri-Plugins werden NUR dynamisch importiert. So scheitert der
// reine Browser-Build/vitest nicht an fehlenden Tauri-Modulen und der Code laeuft
// auch ohne Tauri-Laufzeit.
/** Erkennt, ob wir im Tauri-Webview laufen (dann steht `__TAURI_INTERNALS__`). */
function isTauri(): boolean {
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
}
let warnedFallback = false;
/** Datei-Endungsfilter fuer den nativen Speichern-/Oeffnen-Dialog (Tauri). */
export interface FileDialogFilter {
name: string;
extensions: string[];
}
/**
* Speichert Text-Inhalt in eine Datei.
* - Unter Tauri: nativer Save-Dialog (Ordner + Name) `writeTextFile`.
* - Im Browser: Blob + Anchor-Download in den Download-Ordner.
*
* @param filters Optionale Datei-Endungsfilter fuer den nativen Dialog (z. B.
* Projekt-Endung). Additiv/optional, damit bestehende Aufrufer ohne
* Filter unveraendert funktionieren (Default-Verhalten: irgendeine Endung).
* @returns `true` wenn geschrieben/geladen wurde, `false` bei Abbruch (Dialog
* ohne Pfad). Bei einem Tauri-Fehler wird auf den Browser-Fallback
* zurueckgefallen.
*/
export async function saveTextFile(
content: string,
defaultName: string,
mime = "application/octet-stream",
filters?: FileDialogFilter[],
): Promise<boolean> {
if (isTauri()) {
try {
const { save } = await import("@tauri-apps/plugin-dialog");
const path = await save({ defaultPath: defaultName, filters });
if (!path) return false; // Nutzer hat abgebrochen
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
await writeTextFile(path, content);
return true;
} catch (err) {
if (!warnedFallback) {
warnedFallback = true;
console.warn(
"Nativer Speichern-Dialog fehlgeschlagen, nutze Browser-Download.",
err,
);
}
// Weiter zum Browser-Fallback unten.
}
}
return browserDownload(content, defaultName, mime);
}
/**
* Speichert Binaer-Inhalt (z. B. ein PDF) in eine Datei gleiches Tauri-/
* Browser-Muster wie {@link saveTextFile}, nur mit `writeFile` (Bytes) statt
* `writeTextFile`.
* - Unter Tauri: nativer Save-Dialog `writeFile(path, bytes)`.
* - Im Browser: Blob + Anchor-Download.
*
* @returns `true` wenn geschrieben/geladen wurde, `false` bei Abbruch.
*/
export async function saveBinaryFile(
bytes: Uint8Array,
defaultName: string,
mime = "application/octet-stream",
): Promise<boolean> {
if (isTauri()) {
try {
const { save } = await import("@tauri-apps/plugin-dialog");
const path = await save({ defaultPath: defaultName });
if (!path) return false; // Nutzer hat abgebrochen
const { writeFile } = await import("@tauri-apps/plugin-fs");
await writeFile(path, bytes);
return true;
} catch (err) {
if (!warnedFallback) {
warnedFallback = true;
console.warn(
"Nativer Speichern-Dialog fehlgeschlagen, nutze Browser-Download.",
err,
);
}
// Weiter zum Browser-Fallback unten.
}
}
return browserDownloadBlob(new Blob([bytes as BlobPart], { type: mime }), defaultName);
}
/** Klassischer Blob-/Anchor-Download (gehaertet wie der bisherige Helfer). */
function browserDownload(content: string, defaultName: string, mime: string): boolean {
return browserDownloadBlob(new Blob([content], { type: mime }), defaultName);
}
/** Anchor-Download eines fertigen Blobs (Text wie Binaer). */
function browserDownloadBlob(blob: Blob, defaultName: string): boolean {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = defaultName;
a.rel = "noopener";
document.body.appendChild(a);
a.click();
setTimeout(() => {
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, 4000);
return true;
}
+49
View File
@@ -4,6 +4,7 @@ import {
roomStampToDoc,
roomStampExtraLines,
roomDisplayName,
formatStampArea,
} from "./roomStamp";
import type { Room } from "./types";
@@ -82,6 +83,54 @@ describe("roomStampExtraLines", () => {
);
expect(lines).toHaveLength(0);
});
it("rundet die Fläche auf roundingStep, falls gesetzt", () => {
const lines = roomStampExtraLines(
{ name: "Bad", showFloorArea: true, showUsage: false, roundingStep: 0.5 },
{ netArea: 12.34, siaCategory: "HNF", siaLabel: "Hauptnutzfläche" },
);
expect(lines[0].text).toBe("12.5 m²");
});
it("nutzt 2 Nachkommastellen ohne roundingStep (Alt-Verhalten)", () => {
const lines = roomStampExtraLines(
{ name: "Bad", showFloorArea: true, showUsage: false },
{ netArea: 12.345, siaCategory: "HNF", siaLabel: "Hauptnutzfläche" },
);
expect(lines[0].text).toBe("12.35 m²");
});
it("zeigt die Personenzahl als eigene Zeile, wenn gesetzt", () => {
const lines = roomStampExtraLines(
{ name: "Bad", showFloorArea: false, showUsage: false, occupancy: 4 },
{ netArea: 12.345, siaCategory: "HNF", siaLabel: "Hauptnutzfläche" },
);
expect(lines).toHaveLength(1);
expect(lines[0].text).toBe("4 Pers.");
expect(lines[0].align).toBe("center");
});
it("blendet die Personenzahl-Zeile aus, wenn occupancy fehlt", () => {
const lines = roomStampExtraLines(
{ name: "Bad", showFloorArea: false, showUsage: false },
{ netArea: 12.345, siaCategory: "HNF", siaLabel: "Hauptnutzfläche" },
);
expect(lines).toHaveLength(0);
});
});
describe("formatStampArea", () => {
it("2 Nachkommastellen ohne roundingStep", () => {
expect(formatStampArea(12.345)).toBe("12.35");
expect(formatStampArea(12)).toBe("12.00");
});
it("rundet auf das nächste Vielfache von roundingStep", () => {
expect(formatStampArea(12.24, 0.5)).toBe("12");
expect(formatStampArea(12.26, 0.5)).toBe("12.5");
expect(formatStampArea(12.3, 0.1)).toBe("12.3");
expect(formatStampArea(12.34, 1)).toBe("12");
});
});
describe("roomDisplayName", () => {
+24 -3
View File
@@ -67,17 +67,32 @@ export interface RoomStampLine {
align: Align;
}
/**
* Formatiert die Bodenfläche für die Stempel-Anzeige: ohne `roundingStep` wie
* bisher auf 2 Nachkommastellen; mit `roundingStep` auf das nächste Vielfache
* davon gerundet (z. B. 0.5 halbe m²), ohne Nachkommastellen-Rauschen.
*/
export function formatStampArea(area: number, roundingStep?: number): string {
if (roundingStep && roundingStep > 0) {
const rounded = Math.round(area / roundingStep) * roundingStep;
// Rundungsfehler aus der Fliesskomma-Division kappen (max. 2 Nachkommastellen).
return `${Math.round(rounded * 100) / 100}`;
}
return area.toFixed(2);
}
/**
* Die Live-Zeilen unter dem Stempel: Bodenfläche (mit Präfix) wenn showFloorArea,
* Nutzung (SIA-Kürzel · Bezeichnung) wenn showUsage. Die Ausrichtung folgt den
* Stempel-Feldern; fehlt sie, gilt zentriert" (Alt-Verhalten).
* Nutzung (SIA-Kürzel · Bezeichnung) wenn showUsage, Personenzahl wenn gesetzt.
* Die Ausrichtung folgt den Stempel-Feldern; fehlt sie, gilt zentriert"
* (Alt-Verhalten).
*/
export function roomStampExtraLines(stamp: RoomStamp, ctx: RoomStampContext): RoomStampLine[] {
const lines: RoomStampLine[] = [];
if (stamp.showFloorArea) {
const prefix = stamp.floorAreaPrefix ?? "";
lines.push({
text: `${prefix}${ctx.netArea.toFixed(2)}`,
text: `${prefix}${formatStampArea(ctx.netArea, stamp.roundingStep)}`,
align: stamp.floorAreaAlign ?? "center",
});
}
@@ -87,6 +102,12 @@ export function roomStampExtraLines(stamp: RoomStamp, ctx: RoomStampContext): Ro
align: stamp.usageAlign ?? "center",
});
}
if (stamp.occupancy != null) {
lines.push({
text: `${stamp.occupancy} Pers.`,
align: "center",
});
}
return lines;
}
+121 -4
View File
@@ -1,5 +1,15 @@
import type { Project } from "./types";
import { recomputeFloorElevations } from "./types";
import { MATERIAL_LIBRARY } from "../materials/library";
import { materialFromAsset } from "../materials/runtime";
// Bibliotheks-Materialien für die Basis-Components ab Werk (PBR-Texturen,
// s. `src/materials/library.ts`). Zuordnung nur, wo ein Bibliotheks-Asset
// sinnvoll passt — Wärmedämmung und Zementestrich bleiben bewusst ohne
// Zuweisung (kein passendes Asset in der Bibliothek), statt zu raten.
const PLASTER_ASSET = MATERIAL_LIBRARY.find((a) => a.id === "Plaster001")!;
const BRICK_ASSET = MATERIAL_LIBRARY.find((a) => a.id === "Bricks104")!;
const CONCRETE_ASSET = MATERIAL_LIBRARY.find((a) => a.id === "Concrete048")!;
// Demo-Haus mit zwei Geschossen:
// EG — rechteckiger Raum (5 × 4 m) mit einer Tür in der Südwand.
@@ -111,14 +121,44 @@ export const sampleProject: Project = {
// Bauteil-Materialien: color = Poché-Füllung im Grundriss UND 3D-Diffusfarbe;
// joinPriority höher = läuft am Stoß durch (für spätere T-Stöße).
components: [
{ id: "render-ext", name: "Aussenputz", color: "#d8d2c7", hatchId: "none", joinPriority: 10 },
{
id: "render-ext",
name: "Aussenputz",
color: "#d8d2c7",
hatchId: "none",
joinPriority: 10,
material: materialFromAsset(PLASTER_ASSET),
},
// Weißer Grund unter den durchgezogenen Dämmungs-Haarlinien (quer zur Wand).
// Kein passendes Bibliotheks-Asset (Dämmung) → bewusst ohne `material`.
{ id: "insulation", name: "Wärmedämmung", color: "#ffffff", hatchId: "sia-insulation", joinPriority: 20 },
{ id: "brick", name: "Backstein", color: "#8c5544", hatchId: "sia-brick", joinPriority: 50 },
{ id: "render-int", name: "Innenputz", color: "#efece6", hatchId: "none", joinPriority: 10 },
{
id: "brick",
name: "Backstein",
color: "#8c5544",
hatchId: "sia-brick",
joinPriority: 50,
material: materialFromAsset(BRICK_ASSET),
},
{
id: "render-int",
name: "Innenputz",
color: "#efece6",
hatchId: "none",
joinPriority: 10,
material: materialFromAsset(PLASTER_ASSET),
},
// Für spätere T-Stöße: Beton als durchlaufender Backbone (höchste Priorität).
{ id: "concrete", name: "Beton", color: "#9aa0a6", hatchId: "sia-concrete", joinPriority: 100 },
{
id: "concrete",
name: "Beton",
color: "#9aa0a6",
hatchId: "sia-concrete",
joinPriority: 100,
material: materialFromAsset(CONCRETE_ASSET),
},
// Zementestrich — oberste Schicht des mehrschichtigen Deckenaufbaus (Decken-Stile).
// Kein passendes Bibliotheks-Asset (Estrich) → bewusst ohne `material`.
{ id: "screed", name: "Zementestrich", color: "#c9c2b3", hatchId: "diagonal", joinPriority: 30 },
],
wallTypes: [
@@ -162,6 +202,80 @@ export const sampleProject: Project = {
],
},
],
// Bauteil-Typen (Bibliothek) — Tür/Fenster/Treppe, analog Wand-/Deckenstile.
// Elemente referenzieren sie per `typeId`; ohne Referenz gilt Inline-Verhalten.
doorTypes: [
{
id: "door-standard",
name: "Zimmertür 0.9 m",
kind: "dreh",
leafCount: 1,
leafStyle: "glatt",
frameThickness: 0.05,
defaultWidth: 0.9,
defaultHeight: 2.0,
},
],
windowTypes: [
{
id: "window-standard",
name: "Fenster 1.2 × 1.4 m",
kind: "drehkipp",
wingCount: 1,
glazing: "zweifach",
frameThickness: 0.06,
defaultSillHeight: 0.9,
defaultWidth: 1.2,
defaultHeight: 1.4,
},
],
stairTypes: [
{
// Standard: normale Ortbeton-Laufplatte (schräge, offene Untersicht).
id: "stair-standard",
name: "Betontreppe 1.0 m",
structure: "beton",
closedRisers: true,
treadThickness: 0.05,
nosing: 0.03,
railing: "keine",
defaultWidth: 1.0,
},
{
id: "stair-massiv",
name: "Massivtreppe (auf Sockel)",
structure: "massiv",
closedRisers: true,
treadThickness: 0.04,
nosing: 0.03,
railing: "keine",
defaultWidth: 1.0,
},
{
// Offene Wangentreppe: schwebende Tritte (unten offen), zum direkten
// Vergleich mit der Massivtreppe — im ObjectInfoPanel der Treppe zuweisen.
id: "stair-wange-holz",
name: "Wangentreppe Holz (offen)",
structure: "wange",
closedRisers: false,
treadThickness: 0.05,
nosing: 0.03,
railing: "keine",
defaultWidth: 1.0,
},
{
// Aufgesattelte Treppe mit geschlossenen Setzstufen (Tritt + Setzstufe,
// aber unten offen — der Raum darunter bleibt frei).
id: "stair-aufgesattelt",
name: "Aufgesattelt (Setzstufen)",
structure: "aufgesattelt",
closedRisers: true,
treadThickness: 0.05,
nosing: 0.025,
railing: "keine",
defaultWidth: 1.0,
},
],
// Oberste Schnitte: Geschosse + Schnitt/Ansicht + freie Zeichnung.
// baseElevation wird über recomputeFloorElevations gestapelt: EG=0, OG=2.6.
drawingLevels: recomputeFloorElevations([
@@ -257,6 +371,7 @@ export const sampleProject: Project = {
hostWallId: "W1",
categoryCode: "21",
kind: "window",
typeId: "window-standard",
position: 3.0, // Startkante 3.0 m → Fenster x 3.04.0, rechts von W9 mit Massiv-Abstand
width: 1.0,
height: 1.2,
@@ -268,6 +383,7 @@ export const sampleProject: Project = {
hostWallId: "W3",
categoryCode: "21",
kind: "window",
typeId: "window-standard",
// Startkante 0.6 m ab W3-Start (x=5) → Fenster x 3.04.4, rechts von W9 (x=2.4)
// mit sichtbarer Massiv-Wand dazwischen (die Querwand stösst auf Mauerwerk).
position: 0.6,
@@ -306,6 +422,7 @@ export const sampleProject: Project = {
floorId: "eg",
categoryCode: "40",
shape: "straight",
typeId: "stair-standard",
start: { x: 3.7, y: 0.4 },
dir: { x: 0, y: 1 },
runLength: 3.2,
+693 -2
View File
@@ -18,6 +18,10 @@ export type { SiaCategory } from "../geometry/roomArea";
// Rich-Text-Dokument für den Raum-Stempel (frei editierbarer Teil).
import type { RichTextDoc, Align } from "../text/richText";
export type { RichTextDoc } from "../text/richText";
// Ansichts-Enums für Ausschnitte/View-Snapshots (Ansichtstyp, Kamera-Preset,
// Detailgrad). Rein type-only importiert — zur Laufzeit erased, kein Zyklus
// (TopBar importiert seinerseits nur `Project` als Typ). Wie SiaCategory oben.
import type { DetailLevel, View3d, ViewType } from "../ui/TopBar";
// ── Ressourcen-Bibliotheken (Vectorworks-/DOSSIER-Stil) ────────────────────
// Verwaltete, per id verwiesene Stil-Ressourcen. Verweis-Kette:
@@ -272,6 +276,166 @@ export interface CeilingType {
layers: Layer[];
}
// ── Bauteil-Typen: Tür / Fenster / Treppe ──────────────────────────────────
// Wiederverwendbare Stile (Presets) für Türen, Fenster und Treppen — analog
// `WallType`/`CeilingType` (Bibliothek im Projekt, referenziert per `typeId`).
// Getrennte Typen (kein gemeinsames „OpeningType"), damit türspezifische und
// fensterspezifische Parameter sauber je Gattung wohnen. Ein Element ohne
// `typeId` behält sein heutiges Inline-Verhalten (rückwärtskompatibel).
// Detailstufe der Bauteil-Darstellung (Grundriss + 3D) nutzt das bestehende
// {@link DetailLevel}-Vokabular ("grob" | "mittel" | "fein", aus ui/TopBar) —
// bewusst KEIN zweites Detail-Enum, damit Ansicht und Bauteil dieselbe Skala
// teilen. Ausgewertet an der Symbol-/Mesh-Erzeugung (Phase 2).
/**
* Ein Türtyp (Türstil) wiederverwendbares Preset für {@link Opening} mit
* `kind: "door"`. Bündelt Bauart, Blattausführung und Standardmaße. Werte am
* einzelnen Element (Breite/Höhe/Anschlag) übersteuern die Typ-Defaults.
*/
export interface DoorType {
id: string;
name: string;
/**
* Bauart: normaler Drehflügel, reine Wandöffnung (kein Blatt/Schwenk),
* Schiebetür. Ersetzt/ergänzt das Inline-Feld `Opening.doorType`.
*/
kind: "dreh" | "schiebe" | "wandoeffnung";
/** Anzahl Türblätter (1 = einflügelig, 2 = zweiflügelig). */
leafCount: 1 | 2;
/** Blatt-Ausführung (3D/Detail): glatt, Kassette, Glasfüllung. */
leafStyle: "glatt" | "kassette" | "glas";
/** Glasanteil des Blatts (0..1); nur bei `leafStyle: "glas"` relevant. */
glazingRatio?: number;
/** Zargen-/Rahmenstärke quer zur Wand (Meter). */
frameThickness: number;
/** Rahmen-/Zargen-Tiefe in Wandrichtung (Meter); fehlt ⇒ aus Wanddicke. */
frameDepth?: number;
/** Default-Lichtbreite (Meter) für neu platzierte Türen dieses Typs. */
defaultWidth: number;
/** Default-Lichthöhe (Meter). */
defaultHeight: number;
/** Bodenschwelle/Anschlag zeichnen (3D/Detail). */
threshold?: boolean;
}
/**
* Ein Fenstertyp (Fensterstil) wiederverwendbares Preset für {@link Opening}
* mit `kind: "window"`. Bündelt Öffnungsart, Flügelzahl, Verglasung und
* Standardmaße inkl. Brüstungshöhe.
*/
export interface WindowType {
id: string;
name: string;
/** Öffnungsart: Dreh, Kipp, Dreh-Kipp, fest verglast, Schiebe. */
kind: "dreh" | "kipp" | "drehkipp" | "fest" | "schiebe";
/** Anzahl Flügel (14) → Mittelpfosten = wingCount 1 (vgl. `Opening.wingCount`). */
wingCount: number;
/** Verglasung: Einfach/Zweifach/Dreifach (3D-Scheibenzahl). */
glazing: "einfach" | "zweifach" | "dreifach";
/** Rahmenstärke quer zur Wand (Meter). */
frameThickness: number;
/** Rahmen-Tiefe in Wandrichtung (Meter); fehlt ⇒ aus Wanddicke. */
frameDepth?: number;
/** Default-Brüstungshöhe über Wand-UK (Meter). */
defaultSillHeight: number;
/** Default-Lichtbreite (Meter). */
defaultWidth: number;
/** Default-Lichthöhe (Meter). */
defaultHeight: number;
/** Fensterbank zeichnen (Detail): keine / innen / aussen / beide. */
sillBoard?: "keine" | "innen" | "aussen" | "beide";
}
/**
* Ein Treppentyp (Treppenstil) wiederverwendbares Preset für {@link Stair}.
* Bündelt Tragart, Stufenausbildung und Geländer. Geometrische Grundform
* (gerade/L/Wendel) + Lauflänge/Stufenzahl bleiben am einzelnen Element.
*/
export interface StairType {
id: string;
name: string;
/**
* Tragart:
* "massiv" Vollblock bis zur Treppen-UK (Treppe auf Erdreich/Sockel).
* "beton" Ortbeton-Laufplatte mit SCHRÄGER, offener Untersicht
* (die klassische normale Betontreppe" zwischen zwei Geschossen).
* "wange" Wangentreppe (schwebende Tritte, offen).
* "aufgesattelt" aufgesattelte Tritte (mit Setzstufen, offen).
* "spindel" Spindel-/Wendeltreppe (offen).
*/
structure: "massiv" | "beton" | "wange" | "aufgesattelt" | "spindel";
/** Setzstufen geschlossen (true) oder offen (false, durchsichtige Tritte). */
closedRisers: boolean;
/** Trittstufendicke (Meter). */
treadThickness: number;
/** Trittkanten-Überstand / Nase (Meter); fehlt ⇒ 0. */
nosing?: number;
/** Handlauf/Geländer: keine / links / rechts / beide (in Laufrichtung). */
railing?: "keine" | "links" | "rechts" | "beide";
/** Default-Laufbreite (Meter) für neu platzierte Treppen dieses Typs. */
defaultWidth: number;
}
// ── Grafische Overrides (Regel-Engine) ─────────────────────────────────────
// ArchiCAD-/Vectorworks-Stil: benannte Regeln `condition → actions`, die beim
// RENDERN als oberste Schicht über die By-Layer/By-Object-Attribut-Auflösung
// gelegt werden (reines Rendering-Overlay — die Elementdaten bleiben
// unverändert, jederzeit reversibel). Regeln werden additiv angewendet: pro
// Aktions-Feld gewinnt die OBERSTE (erste) aktive Regel, die es setzt.
// Auswertung: `src/overrides/engine.ts`; Einhängepunkt: `plan/generatePlan.ts`.
/**
* Bedingungs-Typ einer Override-Regel wogegen der Wert verglichen wird:
* "layer_name" Name ODER Code der Ebene (LayerCategory) des Elements.
* "object_name" Element-/Typ-Name (z. B. Wandtyp-Name, Raum-Name,
* Öffnungs-Label, Drawing2D-Formname).
* Ein `user_string`-Tag (DOSSIER-Rhino) existiert am Element noch nicht und
* ist deshalb bewusst NICHT enthalten (kein Schein-Feature).
*/
export type OverrideConditionType = "layer_name" | "object_name";
/** Vergleichs-Operator einer Override-Bedingung (case-insensitiv). */
export type OverrideOperator =
| "equals"
| "contains"
| "starts_with"
| "not_equals";
/** Bedingung einer Override-Regel. */
export interface OverrideCondition {
type: OverrideConditionType;
operator: OverrideOperator;
/** Vergleichswert (Freitext, case-insensitiv verglichen). */
value: string;
}
/**
* Aktionen einer Override-Regel jede optional (Teilaktionen sind erlaubt;
* fehlende Felder lassen die reguläre Auflösung unberührt).
*/
export interface OverrideActions {
/** Strich-/Umrandungsfarbe (hex). */
color?: string;
/** Strichstärke in mm Papier. */
lineweight?: number;
/** Linienstil (Line Manager) — wirkt, wo Elemente einen LineStyle tragen (Drawing2D). */
linetypeId?: string;
}
/**
* Eine grafische Override-Regel. Lebt in `Project.overrideRules` (Reihenfolge
* = Priorität, oben gewinnt) und ist einzeln aktivier-/deaktivierbar.
*/
export interface OverrideRule {
id: string;
name: string;
/** Deaktivierte Regeln werden bei der Auswertung übersprungen. */
enabled: boolean;
condition: OverrideCondition;
actions: OverrideActions;
}
// ── Parametrische Wände (Parametric Walls) ────────────────────────────────
// Parametrische Wand-Regeln generieren automatisch Wall[]-Arrays — analog zu
// FreeCAD BIM. Sie leben in Project.resources.parametricWalls[] und werden
@@ -619,8 +783,26 @@ export interface Wall {
* Z-Wert; floor" bindet die OK an ein (z. B. nächsthöheres) Geschoss.
*/
top?: VerticalAnchor;
/**
* TERMINIERUNGS-Regel am horizontalen Deckenanschluss (Zuschnitt, NICHT
* Priorität bewusst getrennt von `Component.joinPriority`/der Schnitt-
* Boolean-Dominanz gehalten). Steuert, ob ein Wandband, das eine dominante
* Deckenschicht durchstösst, oberhalb der Decke wieder auftaucht":
* `undefined`/"both" heutiges Verhalten: die dominante Deckenschicht
* stanzt nur ihr z-Band aus, das Wandband bleibt oben UND unten erhalten.
* "below" die Wand ENDET an der Decke: nur der Teil UNTER dem höchst-
* gelegenen dominanten Cut bleibt (Regelfall, Wand steigt von unten in die
* Decke). Der oberhalb der Decke stehende Rest wird verworfen.
* "above" spiegelbildlich: nur der Teil ÜBER dem tiefsten dominanten Cut
* bleibt (Brüstung/Attika, die von oben an die Decke stösst).
* Additiv; Alt-Projekte laden unverändert (Default = "both").
*/
sliceTermination?: SliceTermination;
}
/** Terminierungs-Regel einer Wand am Deckenanschluss (siehe {@link Wall.sliceTermination}). */
export type SliceTermination = "both" | "below" | "above";
/**
* Eine Decke (Slab) ein geschossgebundenes, mehrschichtiges Flächenbauteil,
* definiert über einen GESCHLOSSENEN Umriss (Polygon) im Grundriss und eine
@@ -631,7 +813,9 @@ export interface Wall {
* Vertikale Lage: die OBERKANTE (OK) der Decke. Fehlt `top`, liegt die OK an der
* Oberkante des Geschosses (baseElevation + floorHeight also bündig mit dem
* Wandkopf); die Decke wächst um `thickness` nach UNTEN. `top: custom` setzt eine
* absolute Z-Höhe, `top: floor` bindet die OK an ein Geschoss.
* absolute Z-Höhe, `top: floor` bindet die OK an ein Geschoss. Optional übersteuert
* `bottom` die UNTERKANTE (UK) direkt (analog zur Wand); fehlt `bottom`, bleibt es
* beim heutigen Verhalten UK = OK `thickness`.
*/
export interface Ceiling {
id: string;
@@ -699,6 +883,12 @@ export interface Ceiling {
* Oberkante des Geschosses (baseElevation + floorHeight).
*/
top?: VerticalAnchor;
/**
* Vertikale Bindung der UNTERKANTE (UK). Fehlt sie, ergibt sich die UK aus
* OK `thickness` (= heutiges Verhalten). custom" setzt einen absoluten
* Z-Wert; floor" bindet die UK an ein Geschoss.
*/
bottom?: VerticalAnchor;
}
/** Schwenkrichtung der Tür relativ zur Wandachse. */
@@ -772,6 +962,18 @@ export interface Opening {
* "beide" beide Linien (Default wenn nicht gesetzt)
*/
lintelLines?: "keine" | "innen" | "aussen" | "beide";
/**
* Referenz auf einen Bauteil-Typ (Bibliothek): {@link DoorType} bei
* `kind: "door"`, {@link WindowType} bei `kind: "window"`. Fehlt sie, gilt
* das heutige Inline-Verhalten (rückwärtskompatibel). Typ-Defaults liefern
* Bauart/Rahmen/Verglasung; Element-Felder (Breite/Höhe/) übersteuern.
*/
typeId?: string;
/**
* Optionale per-Element-Übersteuerung der Detailstufe (grob/normal/
* detailliert); fehlt sie, gilt die Ansichts-/Projekt-Detailstufe.
*/
detailLevel?: DetailLevel;
/**
* Optionale Übersteuerung der Strich-/Symbolfarbe; sonst gilt die
* Kategorie-Farbe.
@@ -862,6 +1064,17 @@ export interface Stair {
* `dir` (Default). false kehrt Auf-/Abpfeil um (Treppe steigt zum Start hin).
*/
up?: boolean;
/**
* Referenz auf einen {@link StairType} (Bibliothek). Fehlt sie, gilt das
* heutige Inline-Verhalten (rückwärtskompatibel). Typ-Defaults liefern
* Tragart/Stufenausbildung/Geländer; Element-Felder übersteuern.
*/
typeId?: string;
/**
* Optionale per-Element-Übersteuerung der Detailstufe (grob/normal/
* detailliert); fehlt sie, gilt die Ansichts-/Projekt-Detailstufe.
*/
detailLevel?: DetailLevel;
/**
* Optionale Übersteuerung der Strich-/Umrandungsfarbe; sonst gilt die
* Kategorie-Farbe.
@@ -948,6 +1161,17 @@ export interface RoomStamp {
floorAreaAlign?: Align;
/** Ausrichtung der Nutzungs-Zeile. Fehlt sie, gilt „zentriert" (Alt-Verhalten). */
usageAlign?: Align;
/**
* Personenzahl (Live-Zeile), z. B. für Nutzungsauflagen. Fehlt/undefined:
* keine Personenzahl-Zeile (Alt-Verhalten).
*/
occupancy?: number;
/**
* Rundungsschritt der angezeigten Bodenfläche in m² (z. B. 0.5 auf halbe
* m² gerundet). Fehlt er, gilt das Alt-Verhalten: 2 Nachkommastellen ohne
* Schrittrundung.
*/
roundingStep?: number;
}
/** Eine Tür, gehostet in einer Wand. Ihr Geschoss ergibt sich aus der Wand. */
@@ -980,7 +1204,19 @@ export type Drawing2DGeom =
| { shape: "rect"; min: Vec2; max: Vec2 }
| { shape: "circle"; center: Vec2; r: number }
| { shape: "arc"; center: Vec2; r: number; a0: number; a1: number }
| { shape: "text"; at: Vec2; text: string; height: number; angle: number };
| {
shape: "text";
at: Vec2;
text: string;
height: number;
angle: number;
/**
* Optionale Spaltenbreite in Metern (Textspalte/Absatztext). Ist sie gesetzt,
* wird der Text beim Rendern wortweise auf diese Breite umgebrochen; fehlt
* sie, bleibt es einzeiliger Text (heutiges Verhalten).
*/
width?: number;
};
/** Ein freies 2D-Zeichenelement auf einer Zeichnungsebene. */
export interface Drawing2D {
@@ -1067,6 +1303,96 @@ export interface EdgeGrip {
export type Element = Wall | Ceiling | Opening | Door | Stair | Room | Drawing2D;
// ── Extrudierter Körper (truck-Integration, docs/design/truck-plan.md) ─────
// Ein per `extrude`-Befehl aus einem geschlossenen 2D-Profil (Polylinie-Ring/
// Rechteck) erzeugter 3D-Körper. Die eigentliche B-Rep-Extrusion (truck-WASM,
// `engine/truckSolid.ts`) läuft NICHT hier, sondern asynchron in
// `toWalls3d.ts` (emitExtrudedSolids) — dieser Typ hält nur die Rohdaten.
/** Ein extrudierter Körper: geschlossenes Profil (Modell-Meter) + Höhe. */
export interface ExtrudedSolid {
id: string;
type: "extrudedSolid";
/** Geschoss, dessen `baseElevation` die UK des Körpers bestimmt. */
levelId: string;
/**
* Geschlossenes Profil in der XY-Ebene (Grundriss), 3 Punkte. Bei einem
* Kreis-Profil (`circle` gesetzt) eine Tessellierung des Kreises (48-Eck)
* für 2D-Footprint/Auswahl/bbox/Verschieben; die 3D-Extrusion nutzt in dem
* Fall stattdessen `circle` (echte runde truck-Extrusion, `extrudeCircle`).
*/
points: Vec2[];
/** Extrusionshöhe in Metern (> 0), nach +Z ab der Geschoss-UK. */
height: number;
/**
* Gesetzt, wenn das Profil ein Kreis ist (aus einem `Drawing2D` mit
* `shape:"circle"`) `points` bleibt die Tessellierung, `toWalls3d.ts`
* (emitExtrudedSolids) nutzt `circle` für die runde 3D-Extrusion.
*/
circle?: { center: Vec2; r: number };
/**
* Verjüngung 0 (Prisma, Default/fehlend) 1 (Spitze Kegel bei Kreis-,
* Pyramide bei Polygon-Profil). Linear zum Profil-Schwerpunkt skaliert.
*/
taper?: number;
}
// ── Stütze (Column, Tragwerk) ──────────────────────────────────────────────
// Eine Stütze ist im Kern eine PLATZIERTE PROFIL-EXTRUSION: ein an `position`
// gesetztes 2D-Profil (Rechteck oder Kreis), um `rotation` gedreht, über `height`
// nach oben extrudiert. Anders als der generische `ExtrudedSolid` trägt sie
// vollständige BIM-Attribute (Geschoss, Kategorie, optionales Bauteil, UK/OK-
// Anker) — sie lebt daher in `Project.columns` mit eigenem Selektionskanal.
/**
* Querschnitt einer Stütze im Grundriss (Modell-Meter), lokal um `position`.
* "rect" Rechteck `width` (X, quer) × `depth` (Y, längs), vor Rotation.
* "round" Kreis mit `radius`.
*/
export type ColumnProfile =
| { kind: "rect"; width: number; depth: number }
| { kind: "round"; radius: number };
/**
* Eine Stütze (Column) ein geschossgebundenes Tragwerk-Bauteil (Kategorie
* 50"), definiert über einen Einfügepunkt `position`, ein Profil, eine Drehung
* und eine Höhe. Vertikale Lage analog Wand (UK/OK-Anker, sonst Geschoss-UK +
* `height`).
*/
export interface Column {
id: string;
type: "column";
/** Zugehörige Zeichnungsebene (Geschoss). */
floorId: string;
/** Grafik-Kategorie (Ebene), z. B. "50" für Tragwerk. */
categoryCode: string;
/** Einfügepunkt (Profil-Mitte) im Grundriss (Meter). */
position: Vec2;
/** Querschnitt (Rechteck oder Kreis). */
profile: ColumnProfile;
/** Drehung des Profils um `position` (Radiant, CCW; Default 0). */
rotation: number;
/** Höhe in Metern (> 0), nach +Z ab der UK. */
height: number;
/** Optionales tragendes Bauteil (Component) für Poché/3D-Farbe/Schraffur. */
componentId?: string;
/**
* Vertikale Bindung der Unterkante (UK). Fehlt sie, sitzt die UK auf der
* baseElevation des zugehörigen Geschosses (= Wand-Default).
*/
bottom?: VerticalAnchor;
/**
* Vertikale Bindung der Oberkante (OK). Fehlt sie, ergibt sich die OK aus
* UK + `height`.
*/
top?: VerticalAnchor;
/**
* Optionale Übersteuerung der Strich-/Umrandungsfarbe; sonst gilt die
* Kategorie-Farbe.
*/
color?: string;
}
// ── Kontext-Geometrie (importiert / abgeleitet, NICHT semantisch) ───────────
// Importierte Geometrie ist „dumme" KONTEXT-Geometrie (Anzeige + späteres
// Snap-Ziel), KEIN Teil des semantischen BIM-Modells. Sie lebt in einer eigenen
@@ -1160,6 +1486,278 @@ export interface TerrainMesh {
export type ContextObject = ImportedMesh | ContourSet | TerrainMesh;
/** Das gesamte Projekt. */
/**
* Ein Ausschnitt (View-Snapshot) eine benannte, gespeicherte Ansicht, die den
* kompletten Darstellungszustand einfängt und per Klick wiederherstellt
* (DOSSIER A2, ROADMAP §2c/§11). KEIN neuer Zustand, sondern ein Container, der
* bereits vorhandene Bausteine KOMPONIERT + benennt: Ansicht (Typ/Kamera/
* Massstab/Detail), Sichtbarkeit (Ebenen-/Zeichnungskombination, gleiches
* Payload-Format wie {@link https LayerCombo}/`DrawingCombo`), aktive Overrides
* und das aktive Geschoss. Lebt im DOKUMENT (`Project.viewSnapshots`), nicht im
* localStorage Ausschnitte gehören zum Projekt (Export/Teilen).
*/
export interface ViewSnapshot {
id: string;
name: string;
/**
* LEGACY: name-basierte Flach-Gruppierung (Phase 1). Wird von der neuen
* Ordner-/Baum-UI nicht mehr genutzt, bleibt aber für Alt-Projekte gültig.
* Neue Ausschnitte tragen stattdessen `folderId` (echte Ordner-Referenz).
*/
folder?: string;
/**
* Ordner (Baumstruktur) des Ausschnitte-Panels, in dem der Ausschnitt liegt
* ({@link ViewSnapshotFolder}). Fehlt er (oder verweist er ins Leere), liegt
* der Ausschnitt auf der Wurzelebene. Additiv Alt-Projekte laden unverändert.
*/
folderId?: string;
// ── Ansicht ──────────────────────────────────────────────────────────────
/** Ansichtstyp (Grundriss / Perspektive). */
viewType: ViewType;
/** Kanonischer 3D-Blickwinkel (Front/Top/Iso/… bzw. freie Perspektive). */
view3d: View3d;
/** Sichtwinkel (FOV) der 3D-Perspektivkamera in Grad. Optional (nur 3D relevant). */
fov?: number;
/** Papier-Massstab „1:N" (der Nenner). */
scaleDenominator: number;
/** Detailgrad der Darstellung (grob/mittel/fein). */
detail: DetailLevel;
// ── Zustand ──────────────────────────────────────────────────────────────
/** Aktives Geschoss/Zeichnungsebene beim Erfassen. */
activeLevelId: string;
/** Ebenen-Sichtbarkeit: Kategorie-`code` → sichtbar (wie `LayerCombo.codes`). */
layerVisibility: Record<string, boolean>;
/** Zeichnungsebenen-Sichtbarkeit: DrawingLevel-`id` → sichtbar (wie `DrawingCombo.ids`). */
drawingVisibility: Record<string, boolean>;
/**
* Ids der beim Erfassen AKTIVEN (`enabled`) Override-Regeln. Beim
* Wiederherstellen wird jede Regel des Projekts auf `enabled = ids.includes(id)`
* gesetzt. Optional, damit hand-/altangelegte Snapshots ohne Feld gültig sind.
*/
enabledOverrideRuleIds?: string[];
}
/**
* Ein Ordner im Ausschnitte-Baum (DOSSIER A2). Ausschnitte verweisen per
* `folderId` auf ihren Ordner; `parentId` erlaubt Verschachtelung (Ordner in
* Ordner). Fehlt `parentId`, liegt der Ordner auf der Wurzelebene. Additiv
* Alt-Projekte laden unverändert (spiegelbildlich zu {@link LayoutFolder}).
*/
export interface ViewSnapshotFolder {
id: string;
name: string;
/** Übergeordneter Ordner; ohne → Wurzelebene. */
parentId?: string;
}
// ── Layout-Blätter mit Masterlayout (DOSSIER A3, ROADMAP §11) ───────────────
//
// Ein „Layout" ist ein Druck-/Plan-Blatt (A4/A3), auf dem mehrere Viewports
// platziert werden; jeder Viewport ist an einen Ausschnitt ({@link ViewSnapshot})
// gebunden und rendert dessen Plan im gewählten Massstab. Ein Masterlayout
// liefert die gemeinsamen Blatt-Elemente (Titelblock/Rahmen), die die Layouts
// erben. Alles lebt im DOKUMENT (`Project.layouts`/`Project.masterLayouts`),
// nicht im localStorage — Layouts gehören zum Projekt. Additiv: bestehende
// Projekte ohne diese Felder bleiben unverändert gültig.
/**
* Papierformat eines Layout-Blatts (Blattmasse in mm identisch zum PDF-Export).
* ISO-216-Reihen A0A6 und B0B6; Alt-Projekte (nur A4/A3) laden unverändert.
*/
export type LayoutPaperFormat =
| "a0"
| "a1"
| "a2"
| "a3"
| "a4"
| "a5"
| "a6"
| "b0"
| "b1"
| "b2"
| "b3"
| "b4"
| "b5"
| "b6";
/** Ausrichtung eines Layout-Blatts. */
export type LayoutOrientation = "portrait" | "landscape";
/**
* Einfache Textfelder des Titelblocks (Master). Alle optional leere Felder
* werden beim Auflösen aus dem Projekt/Layout mit sinnvollen Defaults gefüllt
* (Projektname `Project.name`, Blattname `Layout.name`, Datum heute).
*/
export interface LayoutTitleBlock {
projectName?: string;
sheetName?: string;
scale?: string;
date?: string;
author?: string;
}
/**
* Masterlayout gemeinsame Blatt-Elemente (Titelblock + optionaler Rahmen), die
* einzelne {@link Layout}s per `masterId` erben. Eine Änderung am Master schlägt
* auf alle Layouts durch, die ihn referenzieren (InDesign-/ArchiCAD-Muster).
*/
export interface MasterLayout {
id: string;
name: string;
paper: LayoutPaperFormat;
orientation: LayoutOrientation;
/** Titelblock-Textfelder (Vorlage; leere Felder werden aufgelöst/aufgefüllt). */
titleBlock: LayoutTitleBlock;
/**
* Ordner (Master-Baum), in dem das Masterlayout liegt ({@link LayoutFolder} mit
* `kind:"master"`). Fehlt er (oder verweist auf einen gelöschten/Layout-Ordner),
* liegt der Master auf der Wurzelebene. Additiv Alt-Projekte laden unverändert.
*/
folderId?: string;
/** Blattrahmen zeichnen (Randlinie). Optional, Default `true`. */
border?: boolean;
/**
* Freie Blattbreite in mm. Ist sie (zusammen mit `customHeightMm`) gesetzt,
* überschreibt sie `paper`/A4·A3 (die effektive Blattgrösse ist dann
* `customWidthMm`×`customHeightMm`, `orientation` wird ignoriert). Fehlt sie,
* gilt `paper`+`orientation` wie bisher. Additiv Alt-Projekte laden unverändert.
*/
customWidthMm?: number;
/** Freie Blatthöhe in mm (siehe {@link MasterLayout.customWidthMm}). */
customHeightMm?: number;
}
/**
* Ein platzierter Viewport auf einem Layout-Blatt Position/Grösse in Papier-mm,
* gebunden an einen Ausschnitt (`snapshotId`). `scaleDenominator` übersteuert
* optional den Massstab des Ausschnitts (Massstab pro Viewport).
*/
export interface LayoutViewport {
id: string;
/** Id des gebundenen Ausschnitts ({@link ViewSnapshot}). */
snapshotId: string;
/** Linke Kante auf dem Blatt (mm, von links). */
xMm: number;
/** Obere Kante auf dem Blatt (mm, von oben). */
yMm: number;
/** Breite des Viewport-Rahmens auf dem Blatt (mm). */
widthMm: number;
/** Höhe des Viewport-Rahmens auf dem Blatt (mm). */
heightMm: number;
/** Optionaler Massstab-Nenner (1:N); übersteuert `snapshot.scaleDenominator`. */
scaleDenominator?: number;
}
/**
* Eine 2D-Annotation (Linie/Rechteck/Text), direkt auf dem Layout-Blatt in
* Papier-mm-Koordinaten gezeichnet eine vom Viewport-Baum unabhängige
* Zusatzschicht (Markup). `color`/`weightMm` sind optional (Editor-Defaults:
* `#111111` / 0.25 mm); Text trägt statt `weightMm` seine Zeilenhöhe.
*/
export type LayoutAnnotation =
| {
id: string;
kind: "line";
x1Mm: number;
y1Mm: number;
x2Mm: number;
y2Mm: number;
color?: string;
weightMm?: number;
}
| {
id: string;
kind: "rect";
xMm: number;
yMm: number;
widthMm: number;
heightMm: number;
color?: string;
weightMm?: number;
}
| {
id: string;
kind: "text";
xMm: number;
yMm: number;
text: string;
heightMm: number;
color?: string;
};
/**
* Flaches Patch-Objekt für {@link LayoutAnnotation} alle Felder ALLER Arten
* optional (statt eines pro Art unterscheidenden Unions), damit CRUD/Handler
* unabhängig von der konkreten `kind` bleiben (der Aufrufer setzt ohnehin nur
* die zur gewählten Annotation passenden Felder).
*/
export type LayoutAnnotationPatch = Partial<{
x1Mm: number;
y1Mm: number;
x2Mm: number;
y2Mm: number;
xMm: number;
yMm: number;
widthMm: number;
heightMm: number;
text: string;
color: string;
weightMm: number;
}>;
/**
* Ein Layout-Blatt Papierformat/Ausrichtung, optionaler Master (`masterId`)
* und die platzierten Viewports.
*/
export interface Layout {
id: string;
name: string;
paper: LayoutPaperFormat;
orientation: LayoutOrientation;
/** Referenz auf ein {@link MasterLayout}; ohne → kein Master (blanko Blatt). */
masterId?: string;
/**
* Ordner (Baumstruktur), in dem das Layout liegt ({@link LayoutFolder}). Fehlt
* er (oder verweist auf einen gelöschten Ordner), liegt das Layout auf der
* Wurzelebene. Additiv Alt-Projekte laden unverändert.
*/
folderId?: string;
/**
* Freie Blattbreite in mm überschreibt `paper`/A4·A3, siehe
* {@link MasterLayout.customWidthMm}. Bei gesetztem `masterId` erbt das Blatt
* ohnehin die Master-Grösse; eigene Custom-Werte gelten nur ohne Master.
*/
customWidthMm?: number;
/** Freie Blatthöhe in mm (siehe {@link Layout.customWidthMm}). */
customHeightMm?: number;
viewports: LayoutViewport[];
/**
* 2D-Markups direkt auf dem Blatt (Linie/Rechteck/Text), siehe
* {@link LayoutAnnotation}. Additiv Alt-Layouts ohne dieses Feld laden
* unverändert (Editor/CRUD behandeln ein fehlendes Array wie ein leeres).
*/
annotations?: LayoutAnnotation[];
}
/**
* Ein Ordner im Layouts-Baum (DOSSIER A3). Layouts verweisen per `folderId` auf
* ihren Ordner; `parentId` erlaubt Verschachtelung (Ordner in Ordner). Fehlt
* `parentId`, liegt der Ordner auf der Wurzelebene. Masterlayouts liegen NICHT
* in Ordnern (sie sind Vorlagen). Additiv Alt-Projekte laden unverändert.
*/
export interface LayoutFolder {
id: string;
name: string;
/** Übergeordneter Ordner; ohne → Wurzelebene. */
parentId?: string;
/**
* Baum-Zugehörigkeit: `"layout"`-Ordner tragen Layouts, `"master"`-Ordner
* tragen Masterlayouts. Die beiden Bäume bleiben getrennt (ein Master-Ordner
* erscheint nie im Layout-Baum und umgekehrt). Fehlt das Feld, gilt `"layout"`
* (rückwärtskompatibel Alt-Projekte ohne `kind` bleiben Layout-Ordner).
*/
kind?: "layout" | "master";
}
export interface Project {
id: string;
name: string;
@@ -1177,6 +1775,20 @@ export interface Project {
* `Ceiling.wallTypeId` gegen `wallTypes` auf (siehe `getCeilingType`).
*/
ceilingTypes?: CeilingType[];
/**
* Türtypen-Bibliothek (Türstile), analog `wallTypes`. Optional, damit
* bestehende Projekte/Tests ohne `doorTypes` gültig bleiben; Türen ohne
* `typeId` verhalten sich wie bisher (Inline-Felder).
*/
doorTypes?: DoorType[];
/**
* Fenstertypen-Bibliothek (Fensterstile). Optional (siehe `doorTypes`).
*/
windowTypes?: WindowType[];
/**
* Treppentypen-Bibliothek (Treppenstile). Optional (siehe `doorTypes`).
*/
stairTypes?: StairType[];
/** Oberste Schnitte: Geschosse + Schnitte/Ansichten. */
drawingLevels: DrawingLevel[];
/** Grafik-Kategorie-Baum (geschossübergreifend). */
@@ -1213,6 +1825,18 @@ export interface Project {
* Projekte/Tests ohne `context` gültig bleiben (Default: leer behandeln).
*/
context?: ContextObject[];
/**
* Extrudierte Körper (truck-Integration) geschlossene 2D-Profile mit Höhe,
* siehe {@link ExtrudedSolid}. Optional, damit bestehende Projekte/Tests ohne
* `extrudedSolids` gültig bleiben (Default: leer behandeln).
*/
extrudedSolids?: ExtrudedSolid[];
/**
* Stützen (Columns) geschossgebundene Tragwerk-Bauteile (platzierte
* Profil-Extrusionen), siehe {@link Column}. Optional, damit bestehende
* Projekte/Tests ohne `columns` gültig bleiben (Default: leer behandeln).
*/
columns?: Column[];
/**
* Bibliothek parametrischer Wand-Regelwerke. Optional, damit bestehende
* Projekte ohne `parametricWalls` gültig bleiben (Default: leer behandeln).
@@ -1220,6 +1844,45 @@ export interface Project {
* aufgelöst generiert Wall[]-Objekte bei Bedarf.
*/
parametricWalls?: ParametricWall[];
/**
* Grafische Override-Regeln (Regel-Engine, Reihenfolge = Priorität, oben
* gewinnt) reines Rendering-Overlay, siehe {@link OverrideRule}. Optional,
* damit bestehende Projekte/Tests ohne `overrideRules` gültig bleiben
* (Default: leer behandeln).
*/
overrideRules?: OverrideRule[];
/**
* Ausschnitte / View-Snapshots (DOSSIER A2) benannte, wiederherstellbare
* Ansichten (siehe {@link ViewSnapshot}). Optional, damit bestehende
* Projekte/Tests ohne `viewSnapshots` unverändert gültig bleiben (Default:
* leer behandeln). Gehört ins Dokument (nicht localStorage).
*/
viewSnapshots?: ViewSnapshot[];
/**
* Ordner des Ausschnitte-Baums (DOSSIER A2) Gruppierung der
* {@link ViewSnapshot}s per `folderId`. Optional, damit bestehende
* Projekte/Tests ohne `viewSnapshotFolders` gültig bleiben (Default: leer
* alle Ausschnitte auf Wurzelebene).
*/
viewSnapshotFolders?: ViewSnapshotFolder[];
/**
* Layout-Blätter (DOSSIER A3) Druck-/Plan-Blätter mit platzierten Viewports
* (siehe {@link Layout}). Optional, damit bestehende Projekte/Tests ohne
* `layouts` gültig bleiben (Default: leer behandeln). Gehört ins Dokument.
*/
layouts?: Layout[];
/**
* Masterlayouts (DOSSIER A3) gemeinsame Blatt-Elemente (Titelblock/Rahmen),
* die einzelne {@link Layout}s erben (siehe {@link MasterLayout}). Optional,
* damit bestehende Projekte/Tests ohne `masterLayouts` gültig bleiben.
*/
masterLayouts?: MasterLayout[];
/**
* Ordner des Layouts-Baums (DOSSIER A3) Gruppierung der {@link Layout}s per
* `folderId`. Optional, damit bestehende Projekte/Tests ohne `layoutFolders`
* gültig bleiben (Default: leer alle Layouts auf Wurzelebene).
*/
layoutFolders?: LayoutFolder[];
/**
* Referenzhöhe des Erdgeschosses in Metern über Meer (m ü. M.), editierbar
* im Einstellungs-Fenster. Optional, damit bestehende Projekte ohne diesen
@@ -1270,6 +1933,22 @@ export const ceilingThickness = (project: Project, ceiling: Ceiling): number =>
}
};
/**
* Löst den {@link DoorType} einer Tür-Öffnung auf, oder `undefined` (keine
* `typeId` bzw. unbekannt Inline-Verhalten). Bewusst NICHT werfend der
* Typ ist eine optionale Übersteuerung, kein Pflichtbezug wie beim Wandtyp.
*/
export const getDoorType = (project: Project, opening: Opening): DoorType | undefined =>
opening.typeId ? (project.doorTypes ?? []).find((t) => t.id === opening.typeId) : undefined;
/** Löst den {@link WindowType} einer Fenster-Öffnung auf, oder `undefined`. */
export const getWindowType = (project: Project, opening: Opening): WindowType | undefined =>
opening.typeId ? (project.windowTypes ?? []).find((t) => t.id === opening.typeId) : undefined;
/** Löst den {@link StairType} einer Treppe auf, oder `undefined`. */
export const getStairType = (project: Project, stair: Stair): StairType | undefined =>
stair.typeId ? (project.stairTypes ?? []).find((t) => t.id === stair.typeId) : undefined;
/** Alle Öffnungen einer Wand (leere Liste, wenn keine oder `openings` fehlt). */
export const openingsOfWall = (project: Project, wallId: string): Opening[] =>
(project.openings ?? []).filter((o) => o.hostWallId === wallId);
@@ -1291,6 +1970,18 @@ export const stairLabel = (s: Stair): string => {
return `${shape} ${s.stepCount} STG`;
};
/** Alle Stützen eines Geschosses (leere Liste, wenn keine oder `columns` fehlt). */
export const columnsOfFloor = (project: Project, floorId: string): Column[] =>
(project.columns ?? []).filter((c) => c.floorId === floorId);
/** Menschenlesbarer Standardname einer Stütze (Profil + Masse in cm). */
export const columnLabel = (c: Column): string => {
if (c.profile.kind === "round") {
return `Stütze Ø${(c.profile.radius * 200).toFixed(0)}`;
}
return `Stütze ${(c.profile.width * 100).toFixed(0)}×${(c.profile.depth * 100).toFixed(0)}`;
};
/** Alle Räume eines Geschosses (leere Liste, wenn keine oder `rooms` fehlt). */
export const roomsOfFloor = (project: Project, floorId: string): Room[] =>
(project.rooms ?? []).filter((r) => r.floorId === floorId);
+115
View File
@@ -0,0 +1,115 @@
// Unit-Tests für die Wand-/Decken-Resolver (wallVerticalExtent /
// ceilingVerticalExtent): reine Datenschicht, kein React/Store.
import { describe, it, expect } from "vitest";
import { ceilingVerticalExtent } from "./wall";
import type { Ceiling, Project } from "./types";
/** Minimalprojekt mit einem Geschoss (baseElevation 0, floorHeight 2.6) und
* einem einschichtigen Wandtyp (0.2 m), den die Decke als Aufbau nutzt. */
function makeProject(overrides?: Partial<Project>): Project {
return {
id: "t",
name: "T",
lineStyles: [],
hatches: [],
components: [{ id: "c", name: "C", color: "#ccc", hatchId: "none", joinPriority: 10 }],
wallTypes: [
{ id: "dt", name: "Decke", layers: [{ componentId: "c", thickness: 0.2 }] },
],
drawingLevels: [
{
id: "eg",
name: "EG",
kind: "floor",
visible: true,
locked: false,
floorHeight: 2.6,
cutHeight: 1.0,
baseElevation: 0,
},
{
id: "og",
name: "OG",
kind: "floor",
visible: true,
locked: false,
floorHeight: 2.6,
cutHeight: 1.0,
baseElevation: 2.8,
},
],
layers: [{ code: "30", name: "Decken", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }],
walls: [],
doors: [],
openings: [],
ceilings: [],
stairs: [],
rooms: [],
drawings2d: [],
context: [],
...overrides,
};
}
function makeCeiling(overrides?: Partial<Ceiling>): Ceiling {
return {
id: "cl1",
type: "ceiling",
floorId: "eg",
categoryCode: "30",
outline: [
{ x: 0, y: 0 },
{ x: 4, y: 0 },
{ x: 4, y: 4 },
{ x: 0, y: 4 },
],
wallTypeId: "dt",
...overrides,
};
}
describe("ceilingVerticalExtent", () => {
it("ohne top/bottom: OK = Geschoss-Oberkante, UK = OK Dicke (heutiges Verhalten)", () => {
const project = makeProject();
const ceiling = makeCeiling();
const { zBottom, zTop } = ceilingVerticalExtent(project, ceiling);
expect(zTop).toBeCloseTo(2.6); // baseElevation(0) + floorHeight(2.6)
expect(zBottom).toBeCloseTo(2.4); // zTop thickness(0.2)
});
it("mit top: custom übersteuert die Oberkante; UK bleibt OK Dicke", () => {
const project = makeProject();
const ceiling = makeCeiling({ top: { mode: "custom", z: 3.0 } });
const { zBottom, zTop } = ceilingVerticalExtent(project, ceiling);
expect(zTop).toBeCloseTo(3.0);
expect(zBottom).toBeCloseTo(2.8);
});
it("mit bottom: custom übersteuert die Unterkante — gewinnt über OK Dicke", () => {
const project = makeProject();
const ceiling = makeCeiling({ bottom: { mode: "custom", z: 2.2 } });
const { zBottom, zTop } = ceilingVerticalExtent(project, ceiling);
expect(zTop).toBeCloseTo(2.6); // OK unverändert (Geschoss-Default)
expect(zBottom).toBeCloseTo(2.2); // UK-Override statt OK Dicke (2.4)
});
it("mit top UND bottom: beide Overrides wirken unabhängig (Dicke wird ignoriert)", () => {
const project = makeProject();
const ceiling = makeCeiling({
top: { mode: "custom", z: 5.0 },
bottom: { mode: "custom", z: 4.5 },
});
const { zBottom, zTop } = ceilingVerticalExtent(project, ceiling);
expect(zTop).toBeCloseTo(5.0);
expect(zBottom).toBeCloseTo(4.5);
});
it("bottom: floor bindet die Unterkante an ein Geschoss (+ optionalem offset)", () => {
const project = makeProject();
const ceiling = makeCeiling({ bottom: { mode: "floor", floorId: "og", offset: 0.1 } });
const { zBottom, zTop } = ceilingVerticalExtent(project, ceiling);
expect(zTop).toBeCloseTo(2.6);
expect(zBottom).toBeCloseTo(2.9); // baseElevation(og)=2.8 + offset(0.1)
});
});
+29 -3
View File
@@ -5,7 +5,7 @@
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import type { Ceiling, Project, Stair, VerticalAnchor, Wall } from "./types";
import type { Ceiling, Column, Project, Stair, VerticalAnchor, Wall } from "./types";
import { ceilingThickness, getFloor } from "./types";
/**
@@ -74,7 +74,9 @@ export function wallVerticalExtent(
* 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).
* zBottom: aus `ceiling.bottom`, sonst zTop Deckendicke (die Decke
* wächst nach UNTEN heutiges Verhalten). Die `thickness` bleibt bei
* gesetztem `bottom` rein informativ (dient nicht mehr der UK-Ableitung).
* Fehlt das Geschoss, dient 0 als sicherer Rückfall für die Oberkante.
*/
export function ceilingVerticalExtent(
@@ -89,7 +91,9 @@ export function ceilingVerticalExtent(
floorTop = 0;
}
const zTop = resolveAnchor(project, ceiling.top) ?? floorTop;
const zBottom = zTop - ceilingThickness(project, ceiling);
const zBottom =
resolveAnchor(project, ceiling.bottom) ??
zTop - ceilingThickness(project, ceiling);
return { zBottom, zTop };
}
@@ -124,6 +128,28 @@ export function stairVerticalExtent(
return { zBottom: base, zTop: base + rise };
}
/**
* Vertikale Ausdehnung einer Stütze (absolute Z-Werte in Metern) dieselbe
* Regel wie {@link wallVerticalExtent}:
* zBottom: aus `column.bottom`, sonst baseElevation des Geschosses.
* zTop: aus `column.top`, sonst `zBottom + column.height`.
* Fehlt das Geschoss, dient 0 als sicherer Rückfall für die Unterkante.
*/
export function columnVerticalExtent(
project: Project,
column: Column,
): { zBottom: number; zTop: number } {
let floorBase = 0;
try {
floorBase = getFloor(project, column.floorId).baseElevation ?? 0;
} catch {
floorBase = 0;
}
const zBottom = resolveAnchor(project, column.bottom) ?? floorBase;
const zTop = resolveAnchor(project, column.top) ?? zBottom + column.height;
return { zBottom, zTop };
}
/**
* Das Geschoss DIREKT ÜBER dem Wand-Geschoss (in Dokumentreihenfolge der
* "floor"-Ebenen) oder `undefined`, wenn die Wand im obersten Geschoss liegt.
+189
View File
@@ -0,0 +1,189 @@
// Unit-Tests der Override-Regel-Engine (reine Auswertung, src/overrides/engine.ts):
// Operatoren, Reihenfolge (oberste Regel gewinnt pro Feld), additive
// Teilaktionen, deaktivierte Regeln, layer_name gegen Name UND Code.
import { describe, it, expect } from "vitest";
import type { OverrideRule } from "../model/types";
import {
EMPTY_OVERRIDES,
effectiveOverrides,
hasOverrides,
matchesCondition,
} from "./engine";
/** Kurzform: Regel mit Defaults. */
function rule(partial: Partial<OverrideRule> & Pick<OverrideRule, "condition" | "actions">): OverrideRule {
return {
id: partial.id ?? "r",
name: partial.name ?? "Regel",
enabled: partial.enabled ?? true,
condition: partial.condition,
actions: partial.actions,
};
}
const WALL_SUBJECT = { layerName: "Wände", layerCode: "20", objectName: "AW 24" };
describe("overrides/engine — matchesCondition", () => {
it("equals: case-insensitiv, trimmt den Regelwert", () => {
expect(
matchesCondition(
{ type: "layer_name", operator: "equals", value: " wände " },
WALL_SUBJECT,
),
).toBe(true);
expect(
matchesCondition(
{ type: "layer_name", operator: "equals", value: "Decken" },
WALL_SUBJECT,
),
).toBe(false);
});
it("layer_name matcht Namen ODER Code der Ebene", () => {
expect(
matchesCondition(
{ type: "layer_name", operator: "equals", value: "20" },
WALL_SUBJECT,
),
).toBe(true);
});
it("contains und starts_with auf dem Objektnamen", () => {
expect(
matchesCondition(
{ type: "object_name", operator: "contains", value: "24" },
WALL_SUBJECT,
),
).toBe(true);
expect(
matchesCondition(
{ type: "object_name", operator: "starts_with", value: "aw" },
WALL_SUBJECT,
),
).toBe(true);
expect(
matchesCondition(
{ type: "object_name", operator: "starts_with", value: "24" },
WALL_SUBJECT,
),
).toBe(false);
});
it("not_equals trifft nur, wenn WEDER Name noch Code gleich sind", () => {
expect(
matchesCondition(
{ type: "layer_name", operator: "not_equals", value: "20" },
WALL_SUBJECT,
),
).toBe(false);
expect(
matchesCondition(
{ type: "layer_name", operator: "not_equals", value: "Wände" },
WALL_SUBJECT,
),
).toBe(false);
expect(
matchesCondition(
{ type: "layer_name", operator: "not_equals", value: "Decken" },
WALL_SUBJECT,
),
).toBe(true);
});
it("fehlende Subjekt-Eigenschaft: positive Operatoren treffen nicht, not_equals schon", () => {
expect(
matchesCondition(
{ type: "object_name", operator: "contains", value: "x" },
{ layerName: "Wände", layerCode: "20" },
),
).toBe(false);
expect(
matchesCondition(
{ type: "object_name", operator: "not_equals", value: "x" },
{ layerName: "Wände", layerCode: "20" },
),
).toBe(true);
});
});
describe("overrides/engine — effectiveOverrides", () => {
it("ohne Regeln (oder undefined) kommt EMPTY_OVERRIDES zurück", () => {
expect(effectiveOverrides(undefined, WALL_SUBJECT)).toBe(EMPTY_OVERRIDES);
expect(effectiveOverrides([], WALL_SUBJECT)).toBe(EMPTY_OVERRIDES);
expect(hasOverrides(EMPTY_OVERRIDES)).toBe(false);
});
it("oberste Regel gewinnt pro Aktions-Feld", () => {
const rules = [
rule({
id: "top",
condition: { type: "layer_name", operator: "equals", value: "20" },
actions: { color: "#ff0000" },
}),
rule({
id: "below",
condition: { type: "layer_name", operator: "equals", value: "20" },
actions: { color: "#00ff00" },
}),
];
expect(effectiveOverrides(rules, WALL_SUBJECT).color).toBe("#ff0000");
});
it("additive Teilaktionen: untere Regeln füllen offene Felder", () => {
const rules = [
rule({
id: "color-only",
condition: { type: "layer_name", operator: "equals", value: "20" },
actions: { color: "#ff0000" },
}),
rule({
id: "weight-only",
condition: { type: "object_name", operator: "starts_with", value: "AW" },
actions: { lineweight: 0.7, color: "#0000ff" },
}),
];
const eff = effectiveOverrides(rules, WALL_SUBJECT);
// Farbe aus der obersten Regel, Strichstärke additiv aus der zweiten.
expect(eff).toEqual({ color: "#ff0000", lineweight: 0.7 });
});
it("deaktivierte Regeln werden übersprungen", () => {
const rules = [
rule({
id: "off",
enabled: false,
condition: { type: "layer_name", operator: "equals", value: "20" },
actions: { color: "#ff0000" },
}),
rule({
id: "on",
condition: { type: "layer_name", operator: "equals", value: "20" },
actions: { color: "#00ff00" },
}),
];
expect(effectiveOverrides(rules, WALL_SUBJECT).color).toBe("#00ff00");
});
it("nicht matchende Regeln tragen nichts bei", () => {
const rules = [
rule({
id: "other-layer",
condition: { type: "layer_name", operator: "equals", value: "30" },
actions: { color: "#ff0000" },
}),
];
expect(effectiveOverrides(rules, WALL_SUBJECT)).toBe(EMPTY_OVERRIDES);
});
it("linetypeId wird wie die anderen Felder aufgelöst", () => {
const rules = [
rule({
id: "lt",
condition: { type: "layer_name", operator: "contains", value: "wän" },
actions: { linetypeId: "dashed" },
}),
];
expect(effectiveOverrides(rules, WALL_SUBJECT).linetypeId).toBe("dashed");
});
});
+131
View File
@@ -0,0 +1,131 @@
// Grafische Overrides — reine Auswertungs-Engine (Regel-Engine, DOSSIER A1).
//
// Wertet die Regelliste `Project.overrideRules` für ein Element aus und
// liefert die effektiven Aktionen (Farbe/Strichstärke/Linienstil). Die Regeln
// werden ADDITIV angewendet: pro Aktions-Feld gewinnt die OBERSTE (erste)
// aktive Regel, deren Bedingung matcht; spätere Regeln füllen nur noch die
// verbleibenden Felder. Deaktivierte Regeln werden übersprungen.
//
// Reines Rendering-Overlay: die Engine kennt weder Project noch Renderer —
// der Aufrufer (plan/generatePlan.ts) baut das Subjekt aus Ebene + Element
// und legt das Ergebnis als oberste Schicht ÜBER die reguläre
// By-Layer/By-Object-Attribut-Auflösung. Elementdaten bleiben unverändert.
import type {
OverrideActions,
OverrideCondition,
OverrideRule,
} from "../model/types";
/**
* Das Subjekt einer Auswertung die vergleichbaren Eigenschaften EINES
* Elements. `layer_name`-Bedingungen matchen gegen Namen ODER Code der Ebene
* (LayerCategory), `object_name`-Bedingungen gegen den Element-/Typ-Namen.
*/
export interface OverrideSubject {
/** Name der Ebene (LayerCategory.name) des Elements. */
layerName?: string;
/** Code der Ebene (LayerCategory.code / Element.categoryCode). */
layerCode?: string;
/** Element-/Typ-Name (z. B. Wandtyp-Name, Raum-Name, Öffnungs-Label). */
objectName?: string;
}
/** Leere Aktionen (kein Override) — geteilte Konstante für den Schnellpfad. */
export const EMPTY_OVERRIDES: OverrideActions = Object.freeze({});
/** Case-insensitiver Feld-Vergleich eines Operators. */
function matchValue(
operator: OverrideCondition["operator"],
candidate: string,
value: string,
): boolean {
switch (operator) {
case "equals":
return candidate === value;
case "contains":
return candidate.includes(value);
case "starts_with":
return candidate.startsWith(value);
case "not_equals":
return candidate !== value;
}
}
/**
* Prüft, ob eine Bedingung auf ein Subjekt zutrifft (case-insensitiv,
* Wert getrimmt). `layer_name` vergleicht gegen Ebenen-NAME und -CODE:
* positive Operatoren (equals/contains/starts_with) treffen, wenn EINER der
* beiden passt; `not_equals` trifft nur, wenn KEINER der beiden gleich ist
* (sonst würde der Code fast jede Namens-Ungleichheit erfüllen).
*/
export function matchesCondition(
condition: OverrideCondition,
subject: OverrideSubject,
): boolean {
const value = condition.value.trim().toLowerCase();
const candidates: string[] = [];
if (condition.type === "layer_name") {
if (subject.layerName != null) candidates.push(subject.layerName.trim().toLowerCase());
if (subject.layerCode != null) candidates.push(subject.layerCode.trim().toLowerCase());
} else {
if (subject.objectName != null) candidates.push(subject.objectName.trim().toLowerCase());
}
if (candidates.length === 0) {
// Subjekt trägt die Eigenschaft nicht: positive Operatoren können nicht
// treffen; „ungleich" ist trivial erfüllt.
return condition.operator === "not_equals";
}
if (condition.operator === "not_equals") {
return candidates.every((c) => c !== value);
}
return candidates.some((c) => matchValue(condition.operator, c, value));
}
/**
* Kollabiert die Regelliste für ein Subjekt zu den effektiven Aktionen.
* Additiv, oberste Regel gewinnt PRO FELD: die erste aktive, matchende Regel,
* die ein Feld setzt, bestimmt es; weitere Regeln füllen nur offene Felder.
* Ohne Treffer (oder ohne Regeln) kommt {@link EMPTY_OVERRIDES} zurück.
*/
export function effectiveOverrides(
rules: readonly OverrideRule[] | undefined,
subject: OverrideSubject,
): OverrideActions {
if (!rules || rules.length === 0) return EMPTY_OVERRIDES;
let out: OverrideActions | null = null;
for (const rule of rules) {
if (!rule.enabled) continue;
if (!matchesCondition(rule.condition, subject)) continue;
const a = rule.actions;
if (a.color === undefined && a.lineweight === undefined && a.linetypeId === undefined) {
continue;
}
if (!out) out = {};
if (out.color === undefined && a.color !== undefined) out.color = a.color;
if (out.lineweight === undefined && a.lineweight !== undefined) {
out.lineweight = a.lineweight;
}
if (out.linetypeId === undefined && a.linetypeId !== undefined) {
out.linetypeId = a.linetypeId;
}
// Alle Felder belegt → keine weitere Regel kann noch etwas beitragen.
if (
out.color !== undefined &&
out.lineweight !== undefined &&
out.linetypeId !== undefined
) {
break;
}
}
return out ?? EMPTY_OVERRIDES;
}
/** True, wenn mindestens eine Aktion gesetzt ist (Schnelltest für Aufrufer). */
export function hasOverrides(actions: OverrideActions): boolean {
return (
actions.color !== undefined ||
actions.lineweight !== undefined ||
actions.linetypeId !== undefined
);
}
+4
View File
@@ -39,6 +39,8 @@ import {
OpeningSection,
StairSection,
RoomSection,
ExtrudedSolidSection,
ColumnSection,
} from "./ObjectInfoPanel";
/** UI-Zustand des 3-Optionen-Quellen-Dropdowns (nicht 1:1 das Modell-Feld:
@@ -315,6 +317,8 @@ export function AttributesPanel() {
{sel.opening && <OpeningSection opening={sel.opening} host={host} />}
{sel.stair && <StairSection stair={sel.stair} host={host} />}
{sel.room && <RoomSection room={sel.room} roomId={sel.id} host={host} />}
{sel.extrudedSolid && <ExtrudedSolidSection solid={sel.extrudedSolid} host={host} />}
{sel.column && <ColumnSection column={sel.column} host={host} />}
</div>
);
}
+287
View File
@@ -0,0 +1,287 @@
// Inhalts-Panel „Elemente" — BIM-Baum Geschoss → Bauteilklasse → Element
// (ROADMAP §11 „Element-Übersicht", Phase 1).
//
// Datenquelle: `scheduleRows()` (export/exportSchedule.ts) — dieselbe
// Auswertung wie die Bauteilliste (CSV-Export), hier aber live zu einem
// klick-/zoombaren Baum gruppiert statt exportiert. Legacy-„Tür"-Datensätze
// (project.doors) werden ausgeblendet: dafür existiert aktuell KEIN
// Auswahl-Kanal (nichts legt sie mehr an, siehe evalDoor-Kommentar) — echte
// Türen laufen als Öffnung (`Opening.kind === "door"`) und erscheinen hier
// als Bauteilklasse „Öffnung".
//
// Klick selektiert das Element (leert alle anderen Auswahl-Kanäle, wechselt
// bei Bedarf das Geschoss); Shift-Klick oder Doppelklick selektiert UND passt
// die Plan-Ansicht ein. Auf-/zuklappbare Geschoss- und Klassen-Knoten, ein
// Textfilter oben durchsucht Name/Typ/Klasse.
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import { useMemo, useState } from "react";
import { usePanelHost } from "./host";
import type { ScheduleKind, ScheduleRow } from "./host";
import { scheduleRows } from "../export/exportSchedule";
import { t } from "../i18n";
/** Bauteilklassen, die im Baum erscheinen (Reihenfolge = Anzeigereihenfolge). */
const CLASS_ORDER: Exclude<ScheduleKind, "Tür">[] = [
"Wand",
"Decke",
"Fenster",
"Öffnung",
"Treppe",
"Extrusion",
];
/** i18n-Key des Klassen-Labels je Bauteilklasse. */
const CLASS_LABEL_KEY: Record<Exclude<ScheduleKind, "Tür">, string> = {
Wand: "elements.kind.wall",
Decke: "elements.kind.ceiling",
Fenster: "elements.kind.window",
Öffnung: "elements.kind.opening",
Treppe: "elements.kind.stair",
Extrusion: "elements.kind.extrusion",
};
/** Kurzform einer ID für die Anzeige (voller Wert bleibt im Tooltip). */
function shortId(id: string): string {
return id.length <= 14 ? id : `${id.slice(0, 4)}${id.slice(-6)}`;
}
/** Ein Geschoss-Knoten des Baums (auch für Elemente ohne bekanntes Geschoss). */
interface FloorGroup {
/** Gruppierungsschlüssel: die Geschoss-ID, oder ein Namens-Fallback. */
key: string;
/** Anzeigename (Geschoss-Name bzw. bereits aufgelöster Fallback aus row.floor). */
name: string;
rows: ScheduleRow[];
}
/** Gruppiert Zeilen nach Geschoss, in der Reihenfolge der Zeichnungsebenen (oben→unten). */
function buildFloorGroups(
rows: ScheduleRow[],
levels: { id: string; name: string }[],
): FloorGroup[] {
const byKey = new Map<string, ScheduleRow[]>();
for (const r of rows) {
const key = r.floorId ?? `name:${r.floor}`;
const list = byKey.get(key);
if (list) list.push(r);
else byKey.set(key, [r]);
}
// Oberstes Geschoss zuoberst, wie im Zeichnungsebenen-Panel.
const levelsTopDown = [...levels].reverse();
const groups: FloorGroup[] = [];
const used = new Set<string>();
for (const lvl of levelsTopDown) {
const list = byKey.get(lvl.id);
if (list && list.length) {
groups.push({ key: lvl.id, name: lvl.name, rows: list });
used.add(lvl.id);
}
}
// Rest (Elemente ohne bekannte Zeichnungsebene, z. B. verwaiste Referenz).
for (const [key, list] of byKey) {
if (used.has(key)) continue;
groups.push({ key, name: list[0].floor, rows: list });
}
return groups;
}
/** Ob eine Zeile zum Suchtext passt (Name/Typ/Klasse, case-insensitive). */
function matchesQuery(row: ScheduleRow, classLabel: string, query: string): boolean {
const haystack = `${row.typeName} ${classLabel} ${row.id}`.toLowerCase();
return haystack.includes(query);
}
export function ElementTreePanel() {
const host = usePanelHost();
const [query, setQuery] = useState("");
const [collapsedFloors, setCollapsedFloors] = useState<Set<string>>(new Set());
const [collapsedClasses, setCollapsedClasses] = useState<Set<string>>(new Set());
const allRows = useMemo(
() => scheduleRows(host.project).filter((r) => r.kind !== "Tür"),
[host.project],
);
const q = query.trim().toLowerCase();
const searching = q.length > 0;
const visibleRows = useMemo(() => {
if (!searching) return allRows;
return allRows.filter((r) =>
matchesQuery(r, t(CLASS_LABEL_KEY[r.kind as Exclude<ScheduleKind, "Tür">]), q),
);
}, [allRows, q, searching]);
const floorGroups = useMemo(
() => buildFloorGroups(visibleRows, host.project.drawingLevels),
[visibleRows, host.project.drawingLevels],
);
const toggleFloor = (key: string) =>
setCollapsedFloors((s) => {
const next = new Set(s);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
const toggleClass = (key: string) =>
setCollapsedClasses((s) => {
const next = new Set(s);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
const onRowSelect = (row: ScheduleRow, zoom: boolean) => {
host.onSelectScheduleRow(row.kind, row.id, row.floorId, { zoom });
};
if (allRows.length === 0) {
return (
<section className="nav-group">
<header className="nav-group-head">
<span className="nav-group-title">{t("elements.title")}</span>
</header>
<div style={{ padding: "10px 10px", fontSize: 12, color: "var(--muted)" }}>
{t("elements.empty")}
</div>
</section>
);
}
return (
<section className="nav-group">
<header className="nav-group-head">
<span className="nav-group-title">{t("elements.title")}</span>
</header>
<div style={{ padding: "0 6px 6px" }}>
<input
type="text"
value={query}
placeholder={t("elements.search")}
onChange={(e) => setQuery(e.target.value)}
style={{
width: "100%",
boxSizing: "border-box",
background: "var(--input)",
border: "1px solid var(--border)",
borderRadius: 6,
color: "var(--label)",
fontFamily: "inherit",
fontSize: 11,
padding: "4px 8px",
}}
/>
</div>
{floorGroups.length === 0 ? (
<div style={{ padding: "10px 10px", fontSize: 12, color: "var(--muted)" }}>
{t("elements.noMatch")}
</div>
) : (
floorGroups.map((floor) => {
const floorOpen = searching || !collapsedFloors.has(floor.key);
return (
<div className="level-cat" key={floor.key}>
<header
className="level-cat-head"
onClick={() => toggleFloor(floor.key)}
>
<span
className={`level-cat-chevron${floorOpen ? " open" : ""}`}
aria-hidden="true"
>
</span>
<span className="level-cat-title">{floor.name}</span>
<span style={{ fontSize: 10, color: "var(--muted)" }}>
{floor.rows.length}
</span>
</header>
{floorOpen && (
<div className="nav-list">
{CLASS_ORDER.map((kind) => {
const rowsOfKind = floor.rows.filter((r) => r.kind === kind);
if (rowsOfKind.length === 0) return null;
const classKey = `${floor.key}::${kind}`;
const classOpen = searching || !collapsedClasses.has(classKey);
return (
<div key={classKey}>
<header
className="level-cat-head"
style={{ paddingLeft: 20, minHeight: 20 }}
onClick={() => toggleClass(classKey)}
>
<span
className={`level-cat-chevron${classOpen ? " open" : ""}`}
aria-hidden="true"
>
</span>
<span className="level-cat-title" style={{ fontSize: 9 }}>
{t(CLASS_LABEL_KEY[kind])}
</span>
<span style={{ fontSize: 10, color: "var(--muted)" }}>
{rowsOfKind.length}
</span>
</header>
{classOpen && (
<div className="nav-list">
{rowsOfKind.map((row) => (
<div
key={row.id}
className="nav-row"
style={{ paddingLeft: 34 }}
title={row.id}
onClick={(e) => onRowSelect(row, e.shiftKey)}
onDoubleClick={() => onRowSelect(row, true)}
>
<span className="nav-label">{row.typeName}</span>
<span className="nav-elev">{shortId(row.id)}</span>
</div>
))}
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
})
)}
</section>
);
}
/** Tab-Symbol „Elemente" — kleiner Baum (Ast + drei Blätter). */
export function ElementsTabIcon() {
return (
<svg
width={16}
height={16}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.4}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
<line x1="3" y1="2.5" x2="3" y2="13.5" />
<line x1="3" y1="5" x2="8" y2="5" />
<line x1="3" y1="8" x2="8" y2="8" />
<line x1="3" y1="11" x2="8" y2="11" />
<circle cx="11" cy="5" r="1.6" />
<circle cx="11" cy="8" r="1.6" />
<circle cx="11" cy="11" r="1.6" />
</svg>
);
}
+912
View File
@@ -0,0 +1,912 @@
// Inhalts-Panel „Layouts" — Druck-/Plan-Blätter als ORDNER-/BAUM-Struktur
// (DOSSIER A3, ROADMAP §11). Ordner enthalten Layouts (Verschachtelung möglich);
// Masterlayouts bilden einen eigenen Abschnitt (Vorlagen, kein Ordner-Mitglied).
//
// Ein einziges „+"-Menü oben legt Ordner/Layout/Masterlayout an; Layout/Master
// öffnen zuvor einen Grössen-Dialog. Neu angelegte Elemente landen im aktiven
// Ordner (Klick auf eine Ordnerzeile) und werden SOFORT inline umbenennbar
// (autofocus, Enter bestätigt — KEIN window.prompt, im Tauri-WKWebView deaktiviert).
// Ein Ordner lässt sich als EIN Mehrseiten-PDF exportieren.
//
// Reine Verdrahtung über den Host-Context (usePanelHost); Mutationslogik in
// App.tsx (setProject), pure CRUD/Baum-Aufbau in panels/layoutModel.ts.
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import { useState } from "react";
import { usePanelHost } from "./host";
import type {
Layout,
LayoutOrientation,
LayoutPaperFormat,
MasterLayout,
} from "../model/types";
import {
buildLayoutTree,
buildMasterTree,
PAPER_FORMAT_GROUPS,
type FolderNode,
type MasterFolderNode,
} from "./layoutModel";
import {
NewLayoutDialog,
NewMasterLayoutDialog,
type LayoutSizeStart,
type NewLayoutStart,
} from "../ui/LayoutCreateDialogs";
import { t } from "../i18n";
// ── Baum-Icons (inline-SVG im Panel-Stil: 14px, stroke currentColor 1.4) ──────
/** Gemeinsame SVG-Hülle der kleinen Baum-Icons. */
function TreeIcon({ children }: { children: React.ReactNode }) {
return (
<svg
width={14}
height={14}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.4}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
style={{ flex: "0 0 auto" }}
>
{children}
</svg>
);
}
/** Ordner-Symbol — geschlossen (Registerlasche) bzw. offen (aufgeklappter Deckel). */
function FolderIcon({ open }: { open: boolean }) {
return (
<TreeIcon>
{open ? (
<path d="M2 5.5l1-1.5h3.2l1.3 1.5H14v1.5H2.9L1.5 13h11l1.6-5H4" />
) : (
<path d="M2 4.2l1-1.2h3.4l1.3 1.5H14v7.3H2z" />
)}
</TreeIcon>
);
}
/** Layout-Blatt — Papier-Rechteck mit ein paar Inhaltslinien. */
function LayoutSheetIcon() {
return (
<TreeIcon>
<rect x="3.5" y="2" width="9" height="12" rx="1" />
<line x1="5.5" y1="5" x2="10.5" y2="5" />
<line x1="5.5" y1="7.5" x2="10.5" y2="7.5" />
<line x1="5.5" y1="10" x2="8.5" y2="10" />
</TreeIcon>
);
}
/** Masterlayout — Papier mit Stern-Akzent (Vorlage), hebt sich vom Blatt ab. */
function MasterSheetIcon() {
return (
<TreeIcon>
<rect x="3" y="2" width="10" height="12" rx="1" />
<path d="M8 5l0.9 1.9 2 0.2-1.5 1.4 0.5 2L8 9.6 6.1 10.5l0.5-2L5.1 7.1l2-0.2z" />
</TreeIcon>
);
}
/** „Ordner erstellen" — geschlossener Ordner mit kleinem Plus oben rechts. */
function FolderPlusIcon() {
return (
<TreeIcon>
<path d="M2 4.2l1-1.2h3.4l1.3 1.5H14v3" />
<path d="M2 4.2v9.3h6.5" />
<line x1="11.5" y1="9" x2="11.5" y2="14" />
<line x1="9" y1="11.5" x2="14" y2="11.5" />
</TreeIcon>
);
}
/** „Erstellen" — reines Plus (Layout/Masterlayout). */
function PlusIcon() {
return (
<TreeIcon>
<line x1="8" y1="3" x2="8" y2="13" />
<line x1="3" y1="8" x2="13" y2="8" />
</TreeIcon>
);
}
/** Welche Zeilenart gerade inline umbenannt wird (Commit-Handler unterscheidet sich). */
type RenameKind = "folder" | "layout" | "master";
interface Renaming {
id: string;
kind: RenameKind;
}
/** Ein Inline-Rename-Input (Muster wie ViewSnapshotsPanel: autoFocus/Enter/Escape/Blur). */
function RenameInput({
value,
onChange,
onCommit,
onCancel,
}: {
value: string;
onChange: (v: string) => void;
onCommit: () => void;
onCancel: () => void;
}) {
return (
<input
className="settings-num-input"
style={{ flex: 1, minWidth: 0 }}
type="text"
autoFocus
spellCheck={false}
value={value}
onClick={(e) => e.stopPropagation()}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") onCommit();
else if (e.key === "Escape") onCancel();
}}
onBlur={onCommit}
/>
);
}
/** Kompakte Paper/Orientation-Auswahl (Master-Editor). Setzt Custom zurück. */
function PaperControls({
paper,
orientation,
onPaper,
onOrientation,
}: {
paper: LayoutPaperFormat;
orientation: LayoutOrientation;
onPaper: (p: LayoutPaperFormat) => void;
onOrientation: (o: LayoutOrientation) => void;
}) {
return (
<>
<select
className="res-select"
value={paper}
title={t("layouts.paper")}
onChange={(e) => onPaper(e.target.value as LayoutPaperFormat)}
>
{PAPER_FORMAT_GROUPS.map((g) => (
<optgroup
key={g.label}
label={g.label === "A" ? t("layouts.paperGroup.a") : t("layouts.paperGroup.b")}
>
{g.formats.map((f) => (
<option key={f} value={f}>
{f.toUpperCase()}
</option>
))}
</optgroup>
))}
</select>
<select
className="res-select"
value={orientation}
title={t("layouts.orientation")}
onChange={(e) => onOrientation(e.target.value as LayoutOrientation)}
>
<option value="portrait">{t("layouts.portrait")}</option>
<option value="landscape">{t("layouts.landscape")}</option>
</select>
</>
);
}
function MasterRow({
master,
depth,
renaming,
renameDraft,
setRenameDraft,
startRename,
commitRename,
cancelRename,
}: {
master: MasterLayout;
depth: number;
renaming: boolean;
renameDraft: string;
setRenameDraft: (v: string) => void;
startRename: (e: React.MouseEvent) => void;
commitRename: () => void;
cancelRename: () => void;
}) {
const host = usePanelHost();
const [open, setOpen] = useState(false);
const tb = master.titleBlock;
const onDelete = (e: React.MouseEvent) => {
e.stopPropagation();
host.onDeleteMasterLayout(master.id);
};
const field = (key: keyof typeof tb, label: string) => (
<label
style={{
display: "flex",
alignItems: "center",
gap: 6,
fontSize: 11,
color: "var(--muted)",
}}
>
<span style={{ flex: "0 0 82px" }}>{label}</span>
<input
type="text"
className="res-input"
style={{ flex: 1 }}
value={tb[key] ?? ""}
placeholder={t("layouts.tb.autoPlaceholder")}
onChange={(e) =>
host.onPatchMasterLayout(master.id, {
titleBlock: { ...tb, [key]: e.target.value },
})
}
/>
</label>
);
return (
<div>
<div
className="nav-row"
onClick={() => setOpen((o) => !o)}
style={{
display: "flex",
alignItems: "center",
gap: 4,
cursor: "pointer",
paddingLeft: 10 + depth * 14,
}}
>
{renaming ? (
<RenameInput
value={renameDraft}
onChange={setRenameDraft}
onCommit={commitRename}
onCancel={cancelRename}
/>
) : (
<span
className="nav-label"
style={{ flex: 1, minWidth: 0, display: "flex", alignItems: "center", gap: 5 }}
>
<span style={{ flex: "0 0 auto" }}>{open ? "▾" : "▸"}</span>
<MasterSheetIcon />
{master.name}
</span>
)}
<button
className="nav-add"
style={{ padding: "1px 5px" }}
title={t("layouts.rename")}
onClick={startRename}
>
</button>
<button
className="nav-add"
style={{ padding: "1px 5px" }}
title={t("layouts.delete")}
onClick={onDelete}
>
</button>
</div>
{open && (
<div
style={{ display: "flex", flexDirection: "column", gap: 5, padding: "6px 10px 10px" }}
>
<div style={{ display: "flex", gap: 6 }}>
<PaperControls
paper={master.paper}
orientation={master.orientation}
onPaper={(p) =>
host.onPatchMasterLayout(master.id, {
paper: p,
customWidthMm: undefined,
customHeightMm: undefined,
})
}
onOrientation={(o) =>
host.onPatchMasterLayout(master.id, {
orientation: o,
customWidthMm: undefined,
customHeightMm: undefined,
})
}
/>
<label
style={{
display: "flex",
alignItems: "center",
gap: 4,
fontSize: 11,
color: "var(--muted)",
}}
>
<input
type="checkbox"
checked={master.border ?? true}
onChange={(e) =>
host.onPatchMasterLayout(master.id, { border: e.target.checked })
}
/>
{t("layouts.border")}
</label>
</div>
{field("projectName", t("layouts.tb.projectName"))}
{field("sheetName", t("layouts.tb.sheetName"))}
{field("scale", t("layouts.tb.scale"))}
{field("date", t("layouts.tb.date"))}
{field("author", t("layouts.tb.author"))}
</div>
)}
</div>
);
}
export function LayoutsPanel() {
const host = usePanelHost();
const layouts = host.project.layouts ?? [];
const masters = host.project.masterLayouts ?? [];
const folders = host.project.layoutFolders ?? [];
// Getrennte Bäume je `kind` — Layout- und Master-Ordner werden NIE vermischt.
const layoutFolders = folders.filter((f) => f.kind !== "master");
const masterFolders = folders.filter((f) => f.kind === "master");
const tree = buildLayoutTree(layoutFolders, layouts);
const masterTree = buildMasterTree(masterFolders, masters);
// Aktiver Ordner = Ziel neuer Elemente (Klick auf eine Ordnerzeile). Null = Wurzel.
const [activeFolderId, setActiveFolderId] = useState<string | null>(null);
// Aktiver Master-Ordner (eigener Zustand — Ziel neuer Master/Master-Ordner).
const [activeMasterFolderId, setActiveMasterFolderId] = useState<string | null>(null);
// Auf-/zugeklappte Ordner.
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
// Inline-Rename (welche Zeile + Entwurf).
const [renaming, setRenaming] = useState<Renaming | null>(null);
const [renameDraft, setRenameDraft] = useState("");
// Offener Erstell-Dialog.
const [dialog, setDialog] = useState<null | "layout" | "master">(null);
// Drag&Drop: gezogenes Element + gerade überfahrenes Drop-Ziel (Ordner-Id
// oder "root" für die Wurzel-Ablagefläche).
const [dragItem, setDragItem] = useState<{
kind: "layout" | "folder";
id: string;
} | null>(null);
const [dragOverId, setDragOverId] = useState<string | "root" | null>(null);
const endDrag = () => {
setDragItem(null);
setDragOverId(null);
};
const dropOnFolder = (targetFolderId: string) => {
if (!dragItem) return;
if (dragItem.kind === "layout") {
host.onMoveLayoutToFolder(dragItem.id, targetFolderId);
} else if (dragItem.id !== targetFolderId) {
// Zyklen (Ordner in eigenen Nachfahren) verwirft der Host (No-op).
host.onMoveFolderToFolder(dragItem.id, targetFolderId);
}
expandFolder(targetFolderId);
endDrag();
};
const dropOnRoot = () => {
if (!dragItem) return;
if (dragItem.kind === "layout") host.onMoveLayoutToFolder(dragItem.id, null);
else host.onMoveFolderToFolder(dragItem.id, null);
endDrag();
};
const isExpanded = (id: string) => !collapsed.has(id);
const toggleFolder = (id: string) =>
setCollapsed((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
const expandFolder = (id: string) =>
setCollapsed((prev) => {
if (!prev.has(id)) return prev;
const next = new Set(prev);
next.delete(id);
return next;
});
const beginRename = (id: string, kind: RenameKind, currentName: string) => {
setRenaming({ id, kind });
setRenameDraft(currentName);
};
const commitRename = () => {
if (!renaming) return;
const name = renameDraft.trim();
if (name) {
if (renaming.kind === "folder") host.onRenameLayoutFolder(renaming.id, name);
else if (renaming.kind === "layout") host.onRenameLayout(renaming.id, name);
else host.onRenameMasterLayout(renaming.id, name);
}
setRenaming(null);
};
const cancelRename = () => setRenaming(null);
const renamingRow = (id: string, kind: RenameKind) =>
renaming?.id === id && renaming.kind === kind;
// ── Anlegen (zwei getrennte Buttons je Abschnitt) ─────────────────────────
const addFolder = () => {
const id = host.onAddLayoutFolder(activeFolderId ?? undefined);
if (activeFolderId) expandFolder(activeFolderId);
beginRename(id, "folder", t("layouts.folder.defaultName"));
};
const addMasterFolder = () => {
const id = host.onAddMasterFolder(activeMasterFolderId ?? undefined);
if (activeMasterFolderId) expandFolder(activeMasterFolderId);
beginRename(id, "folder", t("layouts.folder.defaultName"));
};
const createLayoutFromDialog = (start: NewLayoutStart) => {
const id = host.onCreateLayout({
folderId: activeFolderId ?? undefined,
masterId: start.masterId,
paper: start.paper,
orientation: start.orientation,
customWidthMm: start.customWidthMm,
customHeightMm: start.customHeightMm,
});
if (activeFolderId) expandFolder(activeFolderId);
setDialog(null);
beginRename(id, "layout", t("layouts.defaultName"));
};
const createMasterFromDialog = (start: LayoutSizeStart) => {
const id = host.onCreateMasterLayout({
folderId: activeMasterFolderId ?? undefined,
paper: start.paper,
orientation: start.orientation,
customWidthMm: start.customWidthMm,
customHeightMm: start.customHeightMm,
});
if (activeMasterFolderId) expandFolder(activeMasterFolderId);
setDialog(null);
beginRename(id, "master", t("layouts.defaultMasterName"));
};
// ── Baum-Zeilen ─────────────────────────────────────────────────────────
const LayoutRow = (layout: Layout, depth: number) => (
<div
key={layout.id}
className="nav-row"
title={t("layouts.openHint", { name: layout.name })}
draggable={!renamingRow(layout.id, "layout")}
onDragStart={(e) => {
e.stopPropagation();
setDragItem({ kind: "layout", id: layout.id });
e.dataTransfer.effectAllowed = "move";
}}
onDragEnd={endDrag}
onClick={() =>
renamingRow(layout.id, "layout") ? undefined : host.onOpenLayout(layout.id)
}
onDoubleClick={() => host.onOpenLayout(layout.id)}
style={{ display: "flex", alignItems: "center", gap: 4, paddingLeft: 10 + depth * 14 }}
>
{renamingRow(layout.id, "layout") ? (
<RenameInput
value={renameDraft}
onChange={setRenameDraft}
onCommit={commitRename}
onCancel={cancelRename}
/>
) : (
<span
className="nav-label"
style={{ flex: 1, minWidth: 0, display: "flex", alignItems: "center", gap: 5 }}
>
<LayoutSheetIcon />
{layout.name}
</span>
)}
<span style={{ fontSize: 9, color: "var(--muted)" }}>
{(layout.customWidthMm && layout.customHeightMm
? `${Math.round(layout.customWidthMm)}×${Math.round(layout.customHeightMm)}`
: layout.paper.toUpperCase())}{" "}
· {layout.viewports.length}
</span>
<button
className="nav-add"
style={{ padding: "1px 5px" }}
title={t("layouts.rename")}
onClick={(e) => {
e.stopPropagation();
beginRename(layout.id, "layout", layout.name);
}}
>
</button>
<button
className="nav-add"
style={{ padding: "1px 5px" }}
title={t("layouts.delete")}
onClick={(e) => {
e.stopPropagation();
host.onDeleteLayout(layout.id);
}}
>
</button>
</div>
);
const FolderRowTree = (node: FolderNode, depth: number): React.ReactNode => {
const { folder } = node;
const expanded = isExpanded(folder.id);
const active = activeFolderId === folder.id;
const empty = node.layouts.length === 0 && node.subfolders.length === 0;
const isDragged = dragItem?.kind === "folder" && dragItem.id === folder.id;
const dragOver = dragOverId === folder.id && !isDragged;
return (
<div key={folder.id}>
<div
className={`nav-row${active ? " active" : ""}${dragOver ? " drag-over" : ""}`}
title={folder.name}
draggable={!renamingRow(folder.id, "folder")}
onDragStart={(e) => {
e.stopPropagation();
setDragItem({ kind: "folder", id: folder.id });
e.dataTransfer.effectAllowed = "move";
}}
onDragEnd={endDrag}
onDragOver={(e) => {
if (!dragItem) return;
e.stopPropagation();
if (isDragged) {
if (dragOverId !== null) setDragOverId(null);
return;
}
e.preventDefault();
e.dataTransfer.dropEffect = "move";
if (dragOverId !== folder.id) setDragOverId(folder.id);
}}
onDragLeave={() => {
if (dragOverId === folder.id) setDragOverId(null);
}}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
dropOnFolder(folder.id);
}}
onClick={() => setActiveFolderId(active ? null : folder.id)}
style={{ display: "flex", alignItems: "center", gap: 4, paddingLeft: 10 + depth * 14 }}
>
<button
className="nav-add"
style={{ padding: "0 3px", visibility: empty ? "hidden" : "visible" }}
title={expanded ? "▾" : "▸"}
onClick={(e) => {
e.stopPropagation();
toggleFolder(folder.id);
}}
>
{expanded ? "▾" : "▸"}
</button>
{renamingRow(folder.id, "folder") ? (
<RenameInput
value={renameDraft}
onChange={setRenameDraft}
onCommit={commitRename}
onCancel={cancelRename}
/>
) : (
<span
className="nav-label"
style={{
flex: 1,
minWidth: 0,
fontWeight: 600,
display: "flex",
alignItems: "center",
gap: 5,
}}
>
<FolderIcon open={expanded} />
{folder.name}
</span>
)}
<button
className="nav-add"
style={{ padding: "1px 5px" }}
title={t("layouts.folder.export")}
onClick={(e) => {
e.stopPropagation();
host.onExportFolderPdf(folder.id);
}}
>
</button>
<button
className="nav-add"
style={{ padding: "1px 5px" }}
title={t("layouts.folder.rename")}
onClick={(e) => {
e.stopPropagation();
beginRename(folder.id, "folder", folder.name);
}}
>
</button>
<button
className="nav-add"
style={{ padding: "1px 5px" }}
title={t("layouts.folder.delete")}
onClick={(e) => {
e.stopPropagation();
if (activeFolderId === folder.id) setActiveFolderId(null);
host.onDeleteLayoutFolder(folder.id);
}}
>
</button>
</div>
{expanded && (
<>
{node.subfolders.map((sub) => FolderRowTree(sub, depth + 1))}
{node.layouts.map((l) => LayoutRow(l, depth + 1))}
{empty && (
<div
style={{
padding: "2px 10px 4px",
paddingLeft: 10 + (depth + 1) * 14,
fontSize: 11,
color: "var(--muted)",
}}
>
{t("layouts.folder.empty")}
</div>
)}
</>
)}
</div>
);
};
// ── Master-Baum-Zeilen (eigener Baum; kein Drag&Drop, kein PDF-Export) ─────
const renderMasterRow = (m: MasterLayout, depth: number) => (
<MasterRow
key={m.id}
master={m}
depth={depth}
renaming={renamingRow(m.id, "master")}
renameDraft={renameDraft}
setRenameDraft={setRenameDraft}
startRename={(e) => {
e.stopPropagation();
beginRename(m.id, "master", m.name);
}}
commitRename={commitRename}
cancelRename={cancelRename}
/>
);
const MasterFolderRowTree = (node: MasterFolderNode, depth: number): React.ReactNode => {
const { folder } = node;
const expanded = isExpanded(folder.id);
const active = activeMasterFolderId === folder.id;
const empty = node.masters.length === 0 && node.subfolders.length === 0;
return (
<div key={folder.id}>
<div
className={`nav-row${active ? " active" : ""}`}
title={folder.name}
onClick={() => setActiveMasterFolderId(active ? null : folder.id)}
style={{ display: "flex", alignItems: "center", gap: 4, paddingLeft: 10 + depth * 14 }}
>
<button
className="nav-add"
style={{ padding: "0 3px", visibility: empty ? "hidden" : "visible" }}
title={expanded ? "▾" : "▸"}
onClick={(e) => {
e.stopPropagation();
toggleFolder(folder.id);
}}
>
{expanded ? "▾" : "▸"}
</button>
{renamingRow(folder.id, "folder") ? (
<RenameInput
value={renameDraft}
onChange={setRenameDraft}
onCommit={commitRename}
onCancel={cancelRename}
/>
) : (
<span
className="nav-label"
style={{
flex: 1,
minWidth: 0,
fontWeight: 600,
display: "flex",
alignItems: "center",
gap: 5,
}}
>
<FolderIcon open={expanded} />
{folder.name}
</span>
)}
<button
className="nav-add"
style={{ padding: "1px 5px" }}
title={t("layouts.folder.rename")}
onClick={(e) => {
e.stopPropagation();
beginRename(folder.id, "folder", folder.name);
}}
>
</button>
<button
className="nav-add"
style={{ padding: "1px 5px" }}
title={t("layouts.folder.delete")}
onClick={(e) => {
e.stopPropagation();
if (activeMasterFolderId === folder.id) setActiveMasterFolderId(null);
host.onDeleteMasterFolder(folder.id);
}}
>
</button>
</div>
{expanded && (
<>
{node.subfolders.map((sub) => MasterFolderRowTree(sub, depth + 1))}
{node.masters.map((m) => renderMasterRow(m, depth + 1))}
{empty && (
<div
style={{
padding: "2px 10px 4px",
paddingLeft: 10 + (depth + 1) * 14,
fontSize: 11,
color: "var(--muted)",
}}
>
{t("layouts.folder.empty")}
</div>
)}
</>
)}
</div>
);
};
const treeEmpty = tree.rootFolders.length === 0 && tree.rootLayouts.length === 0;
const masterTreeEmpty =
masterTree.rootFolders.length === 0 && masterTree.rootMasters.length === 0;
return (
<section className="nav-group">
<header className="nav-group-head">
<span className="nav-group-title">{t("layouts.title")}</span>
<span className="nav-group-actions">
<button
className="nav-add"
style={{ display: "inline-flex", padding: "1px 5px" }}
title={t("layouts.add.folder")}
onClick={addFolder}
>
<FolderPlusIcon />
</button>
<button
className="nav-add"
style={{ display: "inline-flex", padding: "1px 5px" }}
title={t("layouts.add.layout")}
onClick={() => setDialog("layout")}
>
<PlusIcon />
</button>
</span>
</header>
{treeEmpty ? (
<div style={{ padding: "8px 10px", fontSize: 12, color: "var(--muted)" }}>
{t("layouts.tree.empty")}
</div>
) : (
<div
className={`nav-list${dragOverId === "root" ? " drag-over-root" : ""}`}
onDragOver={(e) => {
if (!dragItem) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
if (dragOverId !== "root") setDragOverId("root");
}}
onDragLeave={(e) => {
// Nur löschen, wenn der Zeiger den Container wirklich verlässt.
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
if (dragOverId === "root") setDragOverId(null);
}
}}
onDrop={(e) => {
e.preventDefault();
dropOnRoot();
}}
>
{tree.rootFolders.map((node) => FolderRowTree(node, 0))}
{tree.rootLayouts.map((l) => LayoutRow(l, 0))}
</div>
)}
{/* Master-Sektion — eigener Ordner-Baum (Master-Ordner, kind:"master"). */}
<header className="nav-group-head" style={{ marginTop: 6 }}>
<span className="nav-group-title">{t("layouts.masters")}</span>
<span className="nav-group-actions">
<button
className="nav-add"
style={{ display: "inline-flex", padding: "1px 5px" }}
title={t("layouts.add.folder")}
onClick={addMasterFolder}
>
<FolderPlusIcon />
</button>
<button
className="nav-add"
style={{ display: "inline-flex", padding: "1px 5px" }}
title={t("layouts.add.master")}
onClick={() => setDialog("master")}
>
<PlusIcon />
</button>
</span>
</header>
{masterTreeEmpty ? (
<div style={{ padding: "4px 10px 8px", fontSize: 12, color: "var(--muted)" }}>
{t("layouts.noMasters")}
</div>
) : (
<div className="nav-list">
{masterTree.rootFolders.map((node) => MasterFolderRowTree(node, 0))}
{masterTree.rootMasters.map((m) => renderMasterRow(m, 0))}
</div>
)}
{dialog === "layout" && (
<NewLayoutDialog
masters={masters}
onCreate={createLayoutFromDialog}
onClose={() => setDialog(null)}
/>
)}
{dialog === "master" && (
<NewMasterLayoutDialog
onCreate={createMasterFromDialog}
onClose={() => setDialog(null)}
/>
)}
</section>
);
}
/** Tab-Symbol „Layouts" — gestapelte Blätter. */
export function LayoutsTabIcon() {
return (
<svg
width={16}
height={16}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.4}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
<rect x="4.5" y="2.5" width="9" height="11" rx="1" />
<path d="M2.5 4.5v9h8" />
</svg>
);
}
+382 -63
View File
@@ -19,6 +19,7 @@ import { t } from "../i18n";
import { formatM } from "../model/types";
import type {
SiaCategory,
SliceTermination,
StairShape,
VerticalAnchor,
WallReferenceLine,
@@ -28,6 +29,8 @@ import { Dropdown } from "../ui/Dropdown";
import { usePanelHost } from "./host";
import type {
CeilingInfo,
ColumnInfo,
ExtrudedSolidInfo,
OpeningInfo,
RoomInfo,
StairInfo,
@@ -68,11 +71,24 @@ export function ObjectInfoPanel() {
const width = bbox.maxX - bbox.minX;
const height = bbox.maxY - bbox.minY;
// X/Y des gewählten Bezugspunkts (Modell-Meter). Read-only: Der Kontrakt
// bietet keinen Positions-Setter, daher wird hier kein Verschieben gefaket.
// X/Y des gewählten Bezugspunkts (Modell-Meter). Editierbar: Commit
// verschiebt die Selektion so, dass der gewählte Bezugspunkt auf die
// eingegebene Koordinate wandert (Delta = neu aktuell → generischer Move
// über den Host). Bei Elementarten, die der Transform-Kern nicht kennt
// (Decke/Öffnung/Treppe/Raum), ist der Host-Aufruf ein No-op — das Feld
// zeigt danach wieder den unveränderten Wert (wie bei Breite/Höhe oben).
const px = bbox.minX + anchor.fx * width;
const py = bbox.minY + anchor.fy * height;
// Rohe Drawing2D-Geometrie der Selektion (nur bei kind==="drawing2d") — für
// die Linien-Länge/Kreis-Radius-Felder unten; die Selection-Sicht trägt
// keine Geometrieform, daher direkter (Lese-)Zugriff aufs Projekt.
const rawDrawing =
sel.kind === "drawing2d"
? host.project.drawings2d.find((d) => d.id === sel.id)
: undefined;
const drawingShape = rawDrawing?.geom.shape;
// ── Maß-Commit ──────────────────────────────────────────────────────────────
// Liest beide aktuellen Maße aus den Feldern und skaliert um den aktiven
// Anker. Negative/ungültige Werte werden ignoriert (kein Resize).
@@ -94,44 +110,57 @@ export function ObjectInfoPanel() {
<div className="objinfo-sep" />
{/* Bezugspunkt-Würfel (3×3). */}
{/* Bezugspunkt-Würfel (3×3, immer quadratisch) links, X/Y/Z als Spalte
rechts daneben (Nutzer-Wunsch). */}
<div className="objinfo-section-label">{t("objinfo.refpoint")}</div>
<div className="objinfo-cube" role="group" aria-label={t("objinfo.refpoint")}>
{FRACTIONS.map((fy) =>
FRACTIONS.map((fx) => {
const active = anchor.fx === fx && anchor.fy === fy;
return (
<button
key={anchorKey(fx, fy)}
type="button"
className={"objinfo-anchor" + (active ? " active" : "")}
title={t("objinfo.setRefpoint")}
aria-pressed={active}
onClick={() => setAnchor({ fx, fy })}
>
<span className="objinfo-dot" />
</button>
);
}),
)}
</div>
<div className="objinfo-refrow">
<div className="objinfo-cube" role="group" aria-label={t("objinfo.refpoint")}>
{FRACTIONS.map((fy) =>
FRACTIONS.map((fx) => {
const active = anchor.fx === fx && anchor.fy === fy;
return (
<button
key={anchorKey(fx, fy)}
type="button"
className={"objinfo-anchor" + (active ? " active" : "")}
title={t("objinfo.setRefpoint")}
aria-pressed={active}
onClick={() => setAnchor({ fx, fy })}
>
<span className="objinfo-dot" />
</button>
);
}),
)}
</div>
{/* X / Y des gewählten Bezugspunkts (read-only). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.x")}</span>
<span className="objinfo-fval">{formatM(px)}</span>
</div>
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.y")}</span>
<span className="objinfo-fval">{formatM(py)}</span>
</div>
{/* Z ehrlich: Das Modell ist 2D pro Ebene; es gibt keine Z am Element.
Daher " statt einer gefakten 0, mit kurzem Hinweis im Titel. */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.z")}</span>
<span className="objinfo-fval objinfo-muted" title={t("objinfo.zHint")}>
</span>
{/* X / Y des gewählten Bezugspunkts (editierbar) + Z (read-only) + ein
Dreh-Feld, als Spalte. */}
<div className="objinfo-xyzcol">
<DimensionField
label={t("objinfo.x")}
value={px}
min={-Infinity}
onCommit={(nx) => host.onMoveSelectionBy(nx - px, 0)}
/>
<DimensionField
label={t("objinfo.y")}
value={py}
min={-Infinity}
onCommit={(ny) => host.onMoveSelectionBy(0, ny - py)}
/>
{/* Z ehrlich: Das Modell ist 2D pro Ebene; es gibt keine Z am Element.
Daher " statt einer gefakten 0, mit kurzem Hinweis im Titel. */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.z")}</span>
<span className="objinfo-fval objinfo-muted" title={t("objinfo.zHint")}>
</span>
</div>
{/* Drehen: Delta-Winkel (Grad) um den gewählten Bezugspunkt kein
persistenter Zustand, das Feld zeigt nach dem Commit wieder 0". */}
<RotateField onCommit={(deg) => host.onRotateSelectionAround(px, py, deg)} />
</div>
</div>
<div className="objinfo-sep" />
@@ -148,6 +177,23 @@ export function ObjectInfoPanel() {
value={height}
onCommit={(h) => commitSize(width, h)}
/>
{/* Linien-Länge bzw. Kreis-Radius (nur wenn die Selektion genau eine
Linie/ein Kreis ist das Modell gibt das direkt her, andere Formen
bleiben ohne Zusatzfeld). */}
{drawingShape === "line" && (
<DimensionField
label={t("objinfo.drawing.length")}
value={Math.hypot(width, height)}
onCommit={(v) => host.onSetDrawingLineLength(v)}
/>
)}
{drawingShape === "circle" && (
<DimensionField
label={t("objinfo.drawing.radius")}
value={width / 2}
onCommit={(v) => host.onSetDrawingCircleRadius(v)}
/>
)}
{/* Die element-spezifischen Abschnitte (Wand/Decke/Öffnung/Treppe/Raum)
werden jetzt im Attribute-Panel gerendert (AttributesPanel importiert
die exportierten *Section-Komponenten). */}
@@ -228,6 +274,153 @@ export function RoomSection({
);
}
// ── Extrusions-Attribut-Abschnitt (truck-Integration) ───────────────────────
// Höhe editierbar (löst Re-Extrusion aus); Grundfläche + Geschoss read-only.
export function ExtrudedSolidSection({
solid,
host,
}: {
solid: ExtrudedSolidInfo;
host: ReturnType<typeof usePanelHost>;
}) {
return (
<>
<div className="objinfo-sep" />
<div className="objinfo-section-label">{t("objinfo.extrudedSolid.section")}</div>
{/* Höhe (editierbar, > 0) — löst eine Re-Extrusion (truck-WASM) aus. */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.extrudedSolid.height")}</span>
<input
type="number"
className="objinfo-text"
step={0.01}
min={0.01}
value={solid.height}
onChange={(e) => {
const v = Number(e.target.value);
if (Number.isFinite(v) && v > 0) host.onSetExtrudedSolidHeight(v);
}}
title={t("objinfo.extrudedSolid.height")}
/>
</div>
{/* Verjüngung (editierbar, 0..1) — löst eine Re-Extrusion (truck-WASM) aus. */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.extrudedSolid.taper")}</span>
<input
type="number"
className="objinfo-text"
step={0.05}
min={0}
max={1}
value={solid.taper}
onChange={(e) => {
const v = Number(e.target.value);
if (Number.isFinite(v)) host.onSetExtrudedSolidTaper(v);
}}
title={t("objinfo.extrudedSolid.taper")}
/>
</div>
{/* Grundfläche (read-only, abgeleitet). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.extrudedSolid.area")}</span>
<span className="objinfo-fval">{solid.area.toFixed(2)} m²</span>
</div>
{/* Geschoss (read-only). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.extrudedSolid.floor")}</span>
<span className="objinfo-fval">{solid.floorName}</span>
</div>
</>
);
}
// ── Stützen-Attribut-Abschnitt (Tragwerk) ───────────────────────────────────
// Profil (Rechteck/Kreis) + Masse, Höhe, Drehung editierbar; Geschoss + UK/OK
// read-only.
export function ColumnSection({
column,
host,
}: {
column: ColumnInfo;
host: ReturnType<typeof usePanelHost>;
}) {
return (
<>
<div className="objinfo-sep" />
<div className="objinfo-section-label">{t("objinfo.column.section")}</div>
{/* Profil-Dropdown (Rechteck/Kreis). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.column.profile")}</span>
<Dropdown
value={column.profile.kind}
onChange={(v) => host.onSetColumnProfileKind(v as "rect" | "round")}
options={[
{ value: "rect", label: t("objinfo.column.profile.rect") },
{ value: "round", label: t("objinfo.column.profile.round") },
]}
title={t("objinfo.column.profile")}
width={130}
/>
</div>
{/* Editierbare Masse je Profil. */}
{column.profile.kind === "round" ? (
<DimensionField
label={t("objinfo.column.radius")}
value={column.profile.radius}
onCommit={(v) => host.onSetColumnRadius(v)}
/>
) : (
<>
<DimensionField
label={t("objinfo.column.width")}
value={column.profile.kind === "rect" ? column.profile.width : 0}
onCommit={(v) => host.onSetColumnWidth(v)}
/>
<DimensionField
label={t("objinfo.column.depth")}
value={column.profile.kind === "rect" ? column.profile.depth : 0}
onCommit={(v) => host.onSetColumnDepth(v)}
/>
</>
)}
<DimensionField
label={t("objinfo.column.height")}
value={column.height}
onCommit={(v) => host.onSetColumnHeight(v)}
/>
{/* Drehung (Grad). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.column.rotation")}</span>
<input
type="number"
className="objinfo-text"
step={1}
value={Number(column.rotationDeg.toFixed(1))}
onChange={(e) => {
const v = Number(e.target.value);
if (Number.isFinite(v)) host.onSetColumnRotation(v);
}}
title={t("objinfo.column.rotation")}
/>
</div>
{/* Geschoss (read-only). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.column.floor")}</span>
<span className="objinfo-fval">{column.floorName}</span>
</div>
</>
);
}
// ── Treppen-Attribut-Abschnitt ───────────────────────────────────────────────
// Grundform (gerade/L/Wendel), Laufbreite, Stufenanzahl, Steighöhe (+ abgeleitete
// Steigungshöhe/Auftrittstiefe), Laufrichtung; Referenzgeschoss + UK/OK read-only.
@@ -243,6 +436,24 @@ export function StairSection({
<div className="objinfo-sep" />
<div className="objinfo-section-label">{t("objinfo.stair.section")}</div>
{/* Treppentyp (Bibliothek) — treibt u. a. die 3D-Tragart (massiv/offen). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.stair.type")}</span>
<Dropdown
value={stair.typeId ?? ""}
onChange={(v) => host.onSetStairType(v)}
options={[
{ value: "", label: t("objinfo.type.none") },
...(host.project.stairTypes ?? []).map((st) => ({
value: st.id,
label: st.name,
})),
]}
title={t("objinfo.stair.type")}
width={130}
/>
</div>
{/* Grundform-Dropdown. */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.stair.shape")}</span>
@@ -360,6 +571,24 @@ export function OpeningSection({
<div className="objinfo-sep" />
<div className="objinfo-section-label">{t("objinfo.opening.section")}</div>
{/* Tür-/Fenstertyp (Bibliothek) — je nach Art aus doorTypes bzw. windowTypes. */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.opening.type")}</span>
<Dropdown
value={opening.typeId ?? ""}
onChange={(v) => host.onSetOpeningType(v)}
options={[
{ value: "", label: t("objinfo.type.none") },
...(isDoor
? host.project.doorTypes ?? []
: host.project.windowTypes ?? []
).map((ty) => ({ value: ty.id, label: ty.name })),
]}
title={t("objinfo.opening.type")}
width={130}
/>
</div>
{/* Art-Segment Fenster/Tür. */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.opening.kind")}</span>
@@ -596,11 +825,15 @@ export function CeilingSection({
<span className="objinfo-fval">{ceiling.floorName}</span>
</div>
{/* Fläche (read-only). */}
{/* Fläche + Volumen (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-field">
<span className="objinfo-flabel">{t("objinfo.volume")}</span>
<span className="objinfo-fval">{ceiling.volume.toFixed(2)} m³</span>
</div>
<div className="objinfo-sep" />
@@ -614,11 +847,16 @@ export function CeilingSection({
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>
{/* UK der Decke: an Geschoss gebunden ODER eigene Höhe; ohne Override
bleibt es beim heutigen Verhalten UK = OK Dicke. */}
<VerticalAnchorRow
label={t("objinfo.ceiling.bottom")}
anchor={ceiling.bottom}
defaultZ={ceiling.zBottom}
floors={ceiling.floors}
defaultFloorId={ceiling.floorId}
onChange={(a) => host.onSetCeilingBottom?.(a)}
/>
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.ceiling.height")}</span>
<span className="objinfo-fval">{formatM(ceiling.zTop - ceiling.zBottom)}</span>
@@ -678,6 +916,23 @@ export function WallSection({
/>
</div>
{/* Terminierung am Deckenanschluss (Zuschnitt, NICHT Priorität): ob die Wand
an der Decke endet oder oberhalb wieder auftaucht. Default "both". */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.wall.sliceTermination")}</span>
<Dropdown
value={wall.sliceTermination}
onChange={(v) => host.onSetWallSliceTermination(v as SliceTermination)}
options={[
{ value: "both", label: t("objinfo.wall.sliceTermination.both") },
{ value: "below", label: t("objinfo.wall.sliceTermination.below") },
{ value: "above", label: t("objinfo.wall.sliceTermination.above") },
]}
title={t("objinfo.wall.sliceTermination")}
width={120}
/>
</div>
{/* Aufbau-Typ-Segment. */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.wall.buildup")}</span>
@@ -730,6 +985,26 @@ export function WallSection({
<span className="objinfo-fval">{wall.floorName}</span>
</div>
{/* Länge + Volumen (read-only). Volumen ist NETTO Fenster/Türen
abgezogen; der Titel zeigt zusätzlich das Bruttovolumen. */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.wall.length")}</span>
<span className="objinfo-fval">{formatM(wall.length)}</span>
</div>
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.volume")}</span>
<span
className="objinfo-fval"
title={
wall.openingVolume > 0
? `${t("objinfo.wall.volumeGross")}: ${wall.grossVolume.toFixed(2)} ${wall.openingVolume.toFixed(2)}`
: undefined
}
>
{wall.netVolume.toFixed(2)} m³
</span>
</div>
{/* Verknüpfung zum oberen Geschoss: OK an nächstes Geschoss binden. */}
<label className="objinfo-field objinfo-check-field">
<span className="objinfo-flabel">{t("objinfo.wall.linkAbove")}</span>
@@ -755,15 +1030,8 @@ export function WallSection({
<div className="objinfo-sep" />
{/* UK & OK getrennt: je „an Geschoss gebunden" ODER „eigene Höhe". */}
<VerticalAnchorRow
label={t("objinfo.wall.bottom")}
anchor={wall.bottom}
defaultZ={wall.zBottom}
floors={wall.floors}
defaultFloorId={wall.floorId}
onChange={(a) => host.onSetWallBottom(a)}
/>
{/* OK & UK getrennt: je an Geschoss gebunden" ODER eigene Höhe". OK
(oben) steht oben, UK (unten) darunter räumlich passend (Nutzer). */}
<VerticalAnchorRow
label={t("objinfo.wall.top")}
anchor={wall.top}
@@ -772,6 +1040,14 @@ export function WallSection({
defaultFloorId={wall.floorAbove?.id ?? wall.floorId}
onChange={(a) => host.onSetWallTop(a)}
/>
<VerticalAnchorRow
label={t("objinfo.wall.bottom")}
anchor={wall.bottom}
defaultZ={wall.zBottom}
floors={wall.floors}
defaultFloorId={wall.floorId}
onChange={(a) => host.onSetWallBottom(a)}
/>
{/* Höhe = OK UK (abgeleitet, read-only). */}
<div className="objinfo-field">
@@ -836,25 +1112,27 @@ function VerticalAnchorRow({
</button>
</div>
</div>
{/* Unterzeile in normaler Label-/Wert-Struktur: Label links, Control in
der Wert-Spalte (Nutzer-Wunsch vorher hing das Control ohne Label
im Einzug der linken Spalte). */}
{mode === "floor" ? (
<div className="objinfo-field objinfo-subfield">
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.anchor.refFloor")}</span>
<Dropdown
value={anchor?.mode === "floor" ? anchor.floorId : defaultFloorId}
onChange={(id) => onChange({ mode: "floor", floorId: id })}
options={floors.map((f) => ({ value: f.id, label: f.name }))}
title={label}
title={t("objinfo.anchor.refFloor")}
width={150}
/>
</div>
) : (
<div className="objinfo-subfield">
<DimensionField
label={t("objinfo.z")}
value={anchor?.mode === "custom" ? anchor.z : defaultZ}
min={-Infinity}
onCommit={(z) => onChange({ mode: "custom", z })}
/>
</div>
<DimensionField
label={t("objinfo.anchor.customHeight")}
value={anchor?.mode === "custom" ? anchor.z : defaultZ}
min={-Infinity}
onCommit={(z) => onChange({ mode: "custom", z })}
/>
)}
</div>
);
@@ -915,3 +1193,44 @@ function DimensionField({
</label>
);
}
// ── Dreh-Feld (Delta-Winkel in Grad) ─────────────────────────────────────────
// Anders als DimensionField zeigt dieses Feld KEINEN persistenten Host-Wert —
// eine „aktuelle Rotation" gibt es für die Selektion generell nicht (Wand ist
// nur eine Achse, Drawing2D/Extrusion tragen keinen Rotationswinkel). Das Feld
// ist daher ein Delta: Eingabe = Drehung ab jetzt, danach zeigt es wieder „0".
function RotateField({ onCommit }: { onCommit: (deg: number) => void }) {
const [draft, setDraft] = useState<string | null>(null);
const shown = draft ?? "0";
function commit() {
if (draft === null) return;
const v = Number(draft);
setDraft(null);
if (isFinite(v) && v !== 0) onCommit(v);
}
return (
<label className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.rotate")}</span>
<input
className="objinfo-input"
type="number"
step={1}
value={shown}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") {
commit();
(e.target as HTMLInputElement).blur();
} else if (e.key === "Escape") {
setDraft(null);
(e.target as HTMLInputElement).blur();
}
}}
/>
<span className="objinfo-unit">°</span>
</label>
);
}
+34
View File
@@ -183,6 +183,25 @@ export function RoomStampEditor({ value, onApply, onCancel }: RoomStampEditorPro
/>
</label>
<label className="roomstamp-field">
<span className="roomstamp-label">{t("roomStamp.roundingStep")}</span>
<select
className="roomstamp-input"
value={draft.roundingStep ?? ""}
disabled={!draft.showFloorArea}
onChange={(e) => {
const v = e.target.value;
patch({ roundingStep: v === "" ? undefined : Number(v) });
}}
>
<option value="">{t("roomStamp.roundingStepDefault")}</option>
<option value="0.01">0.01 m²</option>
<option value="0.1">0.1 m²</option>
<option value="0.5">0.5 m²</option>
<option value="1">1 m²</option>
</select>
</label>
<div className="roomstamp-row">
<label className="roomstamp-check">
<input
@@ -200,6 +219,21 @@ export function RoomStampEditor({ value, onApply, onCancel }: RoomStampEditorPro
onChange={(usageAlign) => patch({ usageAlign })}
/>
</div>
<label className="roomstamp-field">
<span className="roomstamp-label">{t("roomStamp.occupancy")}</span>
<input
type="number"
min={0}
step={1}
className="roomstamp-input"
value={draft.occupancy ?? ""}
onChange={(e) => {
const v = e.target.value;
patch({ occupancy: v === "" ? undefined : Number(v) });
}}
/>
</label>
</div>
<div className="texteditor-foot">
+5 -3
View File
@@ -18,6 +18,7 @@ import { usePanelHost } from "./host";
import type { ContextObject } from "../model/types";
import { ContextImportDialog } from "../ui/ContextImportDialog";
import { Dropdown } from "../ui/Dropdown";
import { useStore } from "../state/appStore";
/** Typ-spezifisches Badge-Label (i18n-Key) je Kontext-Objekt-Art. */
function typeBadgeKey(obj: ContextObject): TranslationKey {
@@ -33,6 +34,7 @@ function typeBadgeKey(obj: ContextObject): TranslationKey {
export function SitePanel() {
const host = usePanelHost();
const notify = useStore((s) => s.notify);
const objects = host.contextObjects;
const contourSets = objects.filter((o) => o.type === "contourSet");
@@ -50,9 +52,9 @@ export function SitePanel() {
if (!effectiveSet) return;
const id = host.onGenerateTerrain(effectiveSet);
if (id === null) {
// Kein sinnvolles TIN — ruhiger Hinweis (keine harte Fehlerbox).
// eslint-disable-next-line no-alert
alert(t("site.terrainFailed"));
// Kein sinnvolles TIN — ruhiger Hinweis als Toast (keine harte Fehlerbox;
// window.alert wäre im Tauri-WKWebView ohnehin wirkungslos).
notify(t("site.terrainFailed"), "error");
}
};
+22 -1
View File
@@ -124,6 +124,24 @@ export function ToolIcon({ id }: { id: ToolId }) {
<path d="M 2.5 12 A 5.5 5.5 0 0 1 13.5 12" fill="none" />
</svg>
);
case "text":
// Serifen-„A" als Text-Symbol.
return (
<svg {...common}>
<path d="M 4 13 L 8 3 L 12 13" fill="none" />
<path d="M 5.5 9.5 L 10.5 9.5" fill="none" />
</svg>
);
case "textbox":
// Spalten-Rahmen mit drei Textzeilen (Absatztext).
return (
<svg {...common}>
<rect x="2.5" y="3" width="11" height="10" fill="none" />
<line x1="4.5" y1="6" x2="11.5" y2="6" />
<line x1="4.5" y1="8.5" x2="11.5" y2="8.5" />
<line x1="4.5" y1="11" x2="9" y2="11" />
</svg>
);
default:
return null;
}
@@ -140,7 +158,10 @@ export function ToolsPanel() {
{TOOL_ORDER.map((id) => {
const tool = getTool(id);
const active = host.activeTool === id;
const disabled = id !== "select" && !host.toolsEnabled;
// Nur Boden-gebundene (`floorOnly`) Werkzeuge sind ohne aktives Geschoss
// gesperrt; freie 2D-Werkzeuge (Linie/Kreis/Text/…) bleiben auf
// Zeichnungsebenen nutzbar — Parität mit dem Ribbon (RibbonBar).
const disabled = !!tool.floorOnly && !host.toolsEnabled;
const title = disabled
? t("tool.floorOnlyDisabled")
: t(tool.hintKey(tool.init()));
+667
View File
@@ -0,0 +1,667 @@
// Inhalts-Panel „Ausschnitte" — benannte, gespeicherte Ansichten (View-Snapshots,
// DOSSIER A2, ROADMAP §2c/§11) als ORDNER-/BAUM-Struktur (spiegelbildlich zum
// Layouts-Panel).
//
// Ein Ausschnitt fängt den kompletten Darstellungszustand ein (Ansichtstyp,
// Kamera-Preset, Massstab, Detailgrad, Ebenen-/Zeichnungssichtbarkeit, aktive
// Overrides, aktives Geschoss) und stellt ihn per Klick wieder her. Das Panel
// zeigt Ordner (auf-/zuklappbar) mit Ausschnitten als Blättern; zwei Header-
// Buttons legen einen Ordner bzw. einen neuen Ausschnitt im AKTIVEN Ordner an
// (Klick auf eine Ordnerzeile = aktiver Ordner; ohne Auswahl = Wurzel). Neue
// Ordner werden sofort inline umbenannt (autofocus/Enter — KEIN window.prompt,
// im Tauri-WKWebView deaktiviert). Ausschnitte/Ordner lassen sich per HTML5-
// Drag&Drop zwischen Ordnern verschieben (Zyklus-Schutz im Host).
//
// Die Footer-Bar (Massstab/Kombis/Overrides/Umbenennen bei angewähltem
// Ausschnitt) und die Anwahl-Persistenz (`selectedViewSnapshotId`) bleiben
// unverändert erhalten — sie hängen unter dem Baum.
//
// Reine Verdrahtung über den Host-Context (usePanelHost); die Erfassungs-/
// Anwendungslogik lebt in App.tsx (Setter/rAF) bzw. state/viewSnapshots.ts, die
// pure Baum-/Ordner-Logik in state/viewSnapshotFolders.ts.
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import { useMemo, useState } from "react";
import { usePanelHost } from "./host";
import type { ViewSnapshot } from "../model/types";
import {
buildViewSnapshotTree,
type ViewSnapshotFolderNode,
} from "../state/viewSnapshotFolders";
import { t } from "../i18n";
// ── Baum-Icons (inline-SVG im Panel-Stil: 14px, stroke currentColor 1.4) ──────
/** Gemeinsame SVG-Hülle der kleinen Baum-Icons. */
function TreeIcon({ children }: { children: React.ReactNode }) {
return (
<svg
width={14}
height={14}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.4}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
style={{ flex: "0 0 auto" }}
>
{children}
</svg>
);
}
/** Ordner-Symbol — geschlossen (Registerlasche) bzw. offen (aufgeklappter Deckel). */
function FolderIcon({ open }: { open: boolean }) {
return (
<TreeIcon>
{open ? (
<path d="M2 5.5l1-1.5h3.2l1.3 1.5H14v1.5H2.9L1.5 13h11l1.6-5H4" />
) : (
<path d="M2 4.2l1-1.2h3.4l1.3 1.5H14v7.3H2z" />
)}
</TreeIcon>
);
}
/** „Ordner erstellen" — geschlossener Ordner mit kleinem Plus oben rechts. */
function FolderPlusIcon() {
return (
<TreeIcon>
<path d="M2 4.2l1-1.2h3.4l1.3 1.5H14v3" />
<path d="M2 4.2v9.3h6.5" />
<line x1="11.5" y1="9" x2="11.5" y2="14" />
<line x1="9" y1="11.5" x2="14" y2="11.5" />
</TreeIcon>
);
}
/** „Erstellen" — reines Plus (neuer Ausschnitt). */
function PlusIcon() {
return (
<TreeIcon>
<line x1="8" y1="3" x2="8" y2="13" />
<line x1="3" y1="8" x2="13" y2="8" />
</TreeIcon>
);
}
/** Ausschnitt-Blatt — Kamera-Sucherrahmen (passt zum Tab-Symbol). */
function SnapshotIcon() {
return (
<TreeIcon>
<rect x="2.5" y="4" width="11" height="8" rx="1" />
<circle cx="8" cy="8" r="2" />
<line x1="5" y1="4" x2="5.8" y2="2.8" />
</TreeIcon>
);
}
/** Ein Inline-Rename-Input (autoFocus/Enter/Escape/Blur). */
function RenameInput({
value,
onChange,
onCommit,
onCancel,
}: {
value: string;
onChange: (v: string) => void;
onCommit: () => void;
onCancel: () => void;
}) {
return (
<input
className="settings-num-input"
style={{ flex: 1, minWidth: 0 }}
type="text"
autoFocus
spellCheck={false}
value={value}
onClick={(e) => e.stopPropagation()}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") onCommit();
else if (e.key === "Escape") onCancel();
}}
onBlur={onCommit}
/>
);
}
/** Deep-Equal zweier `Record<string, boolean>`-Maps (gleiche Keys UND Werte). */
function boolMapEqual(
a: Record<string, boolean>,
b: Record<string, boolean>,
): boolean {
const ka = Object.keys(a);
if (ka.length !== Object.keys(b).length) return false;
for (const k of ka) {
if (a[k] !== b[k]) return false;
}
return true;
}
/**
* Sucht unter den gespeicherten Kombinationen (`names` + `load`) diejenige,
* deren Map EXAKT `target` entspricht liefert den Namen oder `null`. Grundlage
* der Footer-Anzeige welche Ebenen-/Zeichnungskombi war aktiv".
*/
function matchingComboName(
names: string[],
load: (name: string) => Record<string, boolean> | null,
target: Record<string, boolean>,
): string | null {
for (const name of names) {
const map = load(name);
if (map && boolMapEqual(map, target)) return name;
}
return null;
}
/** Welche Zeilenart gerade inline umbenannt wird (Commit unterscheidet sich). */
type RenameKind = "folder" | "snapshot";
interface Renaming {
id: string;
kind: RenameKind;
}
export function ViewSnapshotsPanel() {
const host = usePanelHost();
const snaps = host.project.viewSnapshots ?? [];
const folders = host.project.viewSnapshotFolders ?? [];
const tree = useMemo(
() => buildViewSnapshotTree(folders, snaps),
[folders, snaps],
);
// In-App-Eingabe statt window.prompt (im Tauri-WKWebView deaktiviert → gäbe
// null zurück, nichts würde gespeichert). `naming` = neuer Ausschnitt.
const [naming, setNaming] = useState(false);
const [draft, setDraft] = useState("");
// Inline-Rename einer Baumzeile (Ordner ODER Ausschnitt) + Entwurf.
const [renaming, setRenaming] = useState<Renaming | null>(null);
const [renameDraft, setRenameDraft] = useState("");
// Inline-Umbenennen DIREKT in der Footer-Bar (eigener Draft, damit Liste und
// Footer sich nicht ins Gehege kommen). `footerRenaming` = Bearbeitung aktiv.
const [footerRenaming, setFooterRenaming] = useState(false);
const [footerDraft, setFooterDraft] = useState("");
// Aktiver Ordner = Ziel neuer Ausschnitte/Ordner (Klick auf Ordnerzeile). Null = Wurzel.
const [activeFolderId, setActiveFolderId] = useState<string | null>(null);
// Auf-/zugeklappte Ordner.
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
// Drag&Drop: gezogenes Element + überfahrenes Drop-Ziel (Ordner-Id oder "root").
const [dragItem, setDragItem] = useState<{
kind: "snapshot" | "folder";
id: string;
} | null>(null);
const [dragOverId, setDragOverId] = useState<string | "root" | null>(null);
// Der aktuell „angewählte" Ausschnitt (bleibt markiert, bis App bei einer
// Abweichung `selectedViewSnapshotId` auf null setzt). Footer-Bar nur sichtbar,
// solange dieser existiert.
const selectedId = host.selectedViewSnapshotId;
const selectedSnap = useMemo(
() => (selectedId ? snaps.find((s) => s.id === selectedId) ?? null : null),
[selectedId, snaps],
);
// Footer-Auflösung: Massstab, passende Ebenen-/Zeichnungskombi (Deep-Equal auf
// die gespeicherte Map) und die Namen der aktiven Override-Regeln.
const footer = useMemo(() => {
if (!selectedSnap) return null;
const layerCombo = matchingComboName(
host.listLayerCombos(),
host.loadLayerCombo,
selectedSnap.layerVisibility,
);
const drawingCombo = matchingComboName(
host.listDrawingCombos(),
host.loadDrawingCombo,
selectedSnap.drawingVisibility,
);
const ids = new Set(selectedSnap.enabledOverrideRuleIds ?? []);
const overrideNames = (host.project.overrideRules ?? [])
.filter((r) => ids.has(r.id))
.map((r) => r.name);
return { layerCombo, drawingCombo, overrideNames };
}, [selectedSnap, host]);
// ── Auf-/Zuklappen ────────────────────────────────────────────────────────
const isExpanded = (id: string) => !collapsed.has(id);
const toggleFolder = (id: string) =>
setCollapsed((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
const expandFolder = (id: string) =>
setCollapsed((prev) => {
if (!prev.has(id)) return prev;
const next = new Set(prev);
next.delete(id);
return next;
});
// ── Inline-Rename (Baumzeilen) ──────────────────────────────────────────────
const beginRename = (id: string, kind: RenameKind, currentName: string) => {
setRenaming({ id, kind });
setRenameDraft(currentName);
};
const commitRename = () => {
if (!renaming) return;
const name = renameDraft.trim();
if (name) {
if (renaming.kind === "folder")
host.onRenameViewSnapshotFolder(renaming.id, name);
else host.onRenameViewSnapshot(renaming.id, name);
}
setRenaming(null);
};
const cancelRename = () => setRenaming(null);
const renamingRow = (id: string, kind: RenameKind) =>
renaming?.id === id && renaming.kind === kind;
// ── Anlegen (zwei Header-Buttons) ──────────────────────────────────────────
const addFolder = () => {
const id = host.onAddViewSnapshotFolder(activeFolderId ?? undefined);
if (activeFolderId) expandFolder(activeFolderId);
beginRename(id, "folder", t("viewsnap.folder.defaultName"));
};
const commitSave = () => {
const name = draft.trim();
if (name) host.onCaptureViewSnapshot(name, activeFolderId ?? undefined);
if (activeFolderId) expandFolder(activeFolderId);
setNaming(false);
setDraft("");
};
// ── Drag&Drop ──────────────────────────────────────────────────────────────
const endDrag = () => {
setDragItem(null);
setDragOverId(null);
};
const dropOnFolder = (targetFolderId: string) => {
if (!dragItem) return;
if (dragItem.kind === "snapshot") {
host.onMoveViewSnapshotToFolder(dragItem.id, targetFolderId);
} else if (dragItem.id !== targetFolderId) {
// Zyklen (Ordner in eigenen Nachfahren) verwirft der Host (No-op).
host.onMoveViewSnapshotFolder(dragItem.id, targetFolderId);
}
expandFolder(targetFolderId);
endDrag();
};
const dropOnRoot = () => {
if (!dragItem) return;
if (dragItem.kind === "snapshot")
host.onMoveViewSnapshotToFolder(dragItem.id, null);
else host.onMoveViewSnapshotFolder(dragItem.id, null);
endDrag();
};
// ── Footer-Rename ──────────────────────────────────────────────────────────
const startFooterRename = () => {
if (!selectedSnap) return;
setFooterRenaming(true);
setFooterDraft(selectedSnap.name);
};
const commitFooterRename = () => {
if (!selectedSnap) return;
const name = footerDraft.trim();
if (name && name !== selectedSnap.name)
host.onRenameViewSnapshot(selectedSnap.id, name);
setFooterRenaming(false);
};
// ── Baum-Zeilen ────────────────────────────────────────────────────────────
const SnapshotRow = (snap: ViewSnapshot, depth: number) => (
<div
key={snap.id}
className={"nav-row" + (snap.id === selectedId ? " active" : "")}
title={t("viewsnap.applyHint", { name: snap.name })}
draggable={!renamingRow(snap.id, "snapshot")}
onDragStart={(e) => {
e.stopPropagation();
setDragItem({ kind: "snapshot", id: snap.id });
e.dataTransfer.effectAllowed = "move";
}}
onDragEnd={endDrag}
onClick={() =>
renamingRow(snap.id, "snapshot")
? undefined
: host.onApplyViewSnapshot(snap.id)
}
style={{ display: "flex", alignItems: "center", gap: 4, paddingLeft: 10 + depth * 14 }}
>
{renamingRow(snap.id, "snapshot") ? (
<RenameInput
value={renameDraft}
onChange={setRenameDraft}
onCommit={commitRename}
onCancel={cancelRename}
/>
) : (
<span
className="nav-label"
style={{ flex: 1, minWidth: 0, display: "flex", alignItems: "center", gap: 5 }}
>
<SnapshotIcon />
{snap.name}
</span>
)}
<button
className="nav-add"
style={{ padding: "1px 5px" }}
title={t("viewsnap.rename")}
onClick={(e) => {
e.stopPropagation();
beginRename(snap.id, "snapshot", snap.name);
}}
>
</button>
<button
className="nav-add"
style={{ padding: "1px 5px" }}
title={t("viewsnap.delete")}
onClick={(e) => {
e.stopPropagation();
host.onDeleteViewSnapshot(snap.id);
}}
>
</button>
</div>
);
const FolderRowTree = (
node: ViewSnapshotFolderNode,
depth: number,
): React.ReactNode => {
const { folder } = node;
const expanded = isExpanded(folder.id);
const active = activeFolderId === folder.id;
const empty = node.snapshots.length === 0 && node.subfolders.length === 0;
const isDragged = dragItem?.kind === "folder" && dragItem.id === folder.id;
const dragOver = dragOverId === folder.id && !isDragged;
return (
<div key={folder.id}>
<div
className={`nav-row${active ? " active" : ""}${dragOver ? " drag-over" : ""}`}
title={folder.name}
draggable={!renamingRow(folder.id, "folder")}
onDragStart={(e) => {
e.stopPropagation();
setDragItem({ kind: "folder", id: folder.id });
e.dataTransfer.effectAllowed = "move";
}}
onDragEnd={endDrag}
onDragOver={(e) => {
if (!dragItem) return;
e.stopPropagation();
if (isDragged) {
if (dragOverId !== null) setDragOverId(null);
return;
}
e.preventDefault();
e.dataTransfer.dropEffect = "move";
if (dragOverId !== folder.id) setDragOverId(folder.id);
}}
onDragLeave={() => {
if (dragOverId === folder.id) setDragOverId(null);
}}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
dropOnFolder(folder.id);
}}
onClick={() => setActiveFolderId(active ? null : folder.id)}
style={{ display: "flex", alignItems: "center", gap: 4, paddingLeft: 10 + depth * 14 }}
>
<button
className="nav-add"
style={{ padding: "0 3px", visibility: empty ? "hidden" : "visible" }}
title={expanded ? "▾" : "▸"}
onClick={(e) => {
e.stopPropagation();
toggleFolder(folder.id);
}}
>
{expanded ? "▾" : "▸"}
</button>
{renamingRow(folder.id, "folder") ? (
<RenameInput
value={renameDraft}
onChange={setRenameDraft}
onCommit={commitRename}
onCancel={cancelRename}
/>
) : (
<span
className="nav-label"
style={{
flex: 1,
minWidth: 0,
fontWeight: 600,
display: "flex",
alignItems: "center",
gap: 5,
}}
>
<FolderIcon open={expanded} />
{folder.name}
</span>
)}
<button
className="nav-add"
style={{ padding: "1px 5px" }}
title={t("viewsnap.folder.rename")}
onClick={(e) => {
e.stopPropagation();
beginRename(folder.id, "folder", folder.name);
}}
>
</button>
<button
className="nav-add"
style={{ padding: "1px 5px" }}
title={t("viewsnap.folder.delete")}
onClick={(e) => {
e.stopPropagation();
if (activeFolderId === folder.id) setActiveFolderId(null);
host.onDeleteViewSnapshotFolder(folder.id);
}}
>
</button>
</div>
{expanded && (
<>
{node.subfolders.map((sub) => FolderRowTree(sub, depth + 1))}
{node.snapshots.map((s) => SnapshotRow(s, depth + 1))}
{empty && (
<div
style={{
padding: "2px 10px 4px",
paddingLeft: 10 + (depth + 1) * 14,
fontSize: 11,
color: "var(--muted)",
}}
>
{t("viewsnap.folder.empty")}
</div>
)}
</>
)}
</div>
);
};
const treeEmpty =
tree.rootFolders.length === 0 && tree.rootSnapshots.length === 0;
return (
<section className="nav-group">
<header className="nav-group-head">
<span className="nav-group-title">{t("viewsnap.title")}</span>
<span className="nav-group-actions">
<button
className="nav-add"
style={{ display: "inline-flex", padding: "1px 5px" }}
title={t("viewsnap.add.folder")}
onClick={addFolder}
>
<FolderPlusIcon />
</button>
<button
className="nav-add"
style={{ display: "inline-flex", padding: "1px 5px" }}
title={t("viewsnap.add.snapshot")}
onClick={() => {
setNaming(true);
setDraft("");
}}
>
<PlusIcon />
</button>
</span>
</header>
{naming && (
<div className="nav-row" style={{ display: "flex", gap: 4, padding: "4px 8px" }}>
<input
className="settings-num-input"
style={{ flex: 1, minWidth: 0 }}
type="text"
autoFocus
spellCheck={false}
placeholder={t("viewsnap.savePrompt")}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") commitSave();
else if (e.key === "Escape") {
setNaming(false);
setDraft("");
}
}}
/>
<button className="nav-add" onClick={commitSave} title={t("viewsnap.add.snapshot")}>
</button>
</div>
)}
{treeEmpty ? (
<div style={{ padding: "10px 10px", fontSize: 12, color: "var(--muted)" }}>
{t("viewsnap.empty")}
</div>
) : (
<div
className={`nav-list${dragOverId === "root" ? " drag-over-root" : ""}`}
onDragOver={(e) => {
if (!dragItem) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
if (dragOverId !== "root") setDragOverId("root");
}}
onDragLeave={(e) => {
// Nur löschen, wenn der Zeiger den Container wirklich verlässt.
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
if (dragOverId === "root") setDragOverId(null);
}
}}
onDrop={(e) => {
e.preventDefault();
dropOnRoot();
}}
>
{tree.rootFolders.map((node) => FolderRowTree(node, 0))}
{tree.rootSnapshots.map((s) => SnapshotRow(s, 0))}
</div>
)}
{/* Footer-Bar: sichtbar, sobald ein Ausschnitt angewählt ist. Zeigt den
erfassten Massstab, die passende Ebenen-/Zeichnungskombi und die
aktiven Overrides und erlaubt das Umbenennen direkt hier. */}
{selectedSnap && footer && (
<div className="viewsnap-footer">
<div className="viewsnap-footer-name">
{footerRenaming ? (
<input
className="settings-num-input"
style={{ flex: 1, minWidth: 0 }}
type="text"
autoFocus
spellCheck={false}
value={footerDraft}
onChange={(e) => setFooterDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") commitFooterRename();
else if (e.key === "Escape") setFooterRenaming(false);
}}
onBlur={commitFooterRename}
/>
) : (
<>
<span className="viewsnap-footer-title">{selectedSnap.name}</span>
<button
className="nav-add"
style={{ padding: "1px 5px" }}
title={t("viewsnap.rename")}
onClick={startFooterRename}
>
</button>
</>
)}
</div>
<dl className="viewsnap-footer-grid">
<dt>{t("viewsnap.footer.scale")}</dt>
<dd>1:{selectedSnap.scaleDenominator}</dd>
<dt>{t("viewsnap.footer.layerCombo")}</dt>
<dd>{footer.layerCombo ?? t("viewsnap.footer.none")}</dd>
<dt>{t("viewsnap.footer.drawingCombo")}</dt>
<dd>{footer.drawingCombo ?? t("viewsnap.footer.none")}</dd>
<dt>{t("viewsnap.footer.overrides")}</dt>
<dd>
{footer.overrideNames.length
? footer.overrideNames.join(", ")
: t("viewsnap.footer.none")}
</dd>
</dl>
</div>
)}
</section>
);
}
/** Tab-Symbol „Ausschnitte" — Kamera-Blende / Sucherrahmen. */
export function ViewSnapshotsTabIcon() {
return (
<svg
width={16}
height={16}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={1.4}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
<rect x="2" y="3.5" width="12" height="9" rx="1.5" />
<circle cx="8" cy="8" r="2.4" />
<line x1="4.5" y1="3.5" x2="5.5" y2="2" />
<line x1="11.5" y1="3.5" x2="10.5" y2="2" />
</svg>
);
}
+35
View File
@@ -22,6 +22,9 @@ import { AttributesPanel } from "./AttributesPanel";
import { ObjectInfoPanel } from "./ObjectInfoPanel";
import { SitePanel } from "./SitePanel";
import { RoomBalancePanel } from "./RoomBalancePanel";
import { ElementTreePanel, ElementsTabIcon } from "./ElementTreePanel";
import { ViewSnapshotsPanel, ViewSnapshotsTabIcon } from "./ViewSnapshotsPanel";
import { LayoutsPanel, LayoutsTabIcon } from "./LayoutsPanel";
import {
ToolsTabIcon,
AttributesTabIcon,
@@ -41,6 +44,9 @@ export const BUILTIN_PANEL_IDS = {
objectInfo: "object-info",
site: "site",
roomBalance: "room-balance",
elements: "elements",
viewSnapshots: "view-snapshots",
layouts: "layouts",
} as const;
// Werkzeug-Palette (Symbol + Name je Werkzeug, ArchiCAD/Vectorworks-Stil).
@@ -105,3 +111,32 @@ registerPanel({
hasDisplayMode: false,
render: () => <RoomBalancePanel />,
});
// Element-Baum (ROADMAP §11) — Geschoss → Bauteilklasse → Element, Suche + Klick-Selektion.
registerPanel({
id: BUILTIN_PANEL_IDS.elements,
title: "elements.title",
icon: <ElementsTabIcon />,
hasDisplayMode: false,
render: () => <ElementTreePanel />,
});
// Ausschnitte / View-Snapshots (DOSSIER A2, ROADMAP §2c/§11) — benannte,
// wiederherstellbare Ansichten (Darstellungszustand als Preset).
registerPanel({
id: BUILTIN_PANEL_IDS.viewSnapshots,
title: "viewsnap.title",
icon: <ViewSnapshotsTabIcon />,
hasDisplayMode: false,
render: () => <ViewSnapshotsPanel />,
});
// Layouts / Masterlayouts (DOSSIER A3, ROADMAP §11) — Druck-/Plan-Blätter mit
// platzierten Viewports (an Ausschnitte gebunden), Editor als schwebendes Fenster.
registerPanel({
id: BUILTIN_PANEL_IDS.layouts,
title: "layouts.panelTitle",
icon: <LayoutsTabIcon />,
hasDisplayMode: false,
render: () => <LayoutsPanel />,
});
+224
View File
@@ -22,9 +22,13 @@ import type {
DrawingLevel,
HatchStyle,
LayerCategory,
LayoutOrientation,
LayoutPaperFormat,
LineStyle,
MasterLayout,
Project,
SiaCategory,
SliceTermination,
StairShape,
VerticalAnchor,
WallReferenceLine,
@@ -32,6 +36,7 @@ import type {
} from "../model/types";
import type { SnapSettings, ToolId } from "../tools/types";
import type { Selection } from "../state/selectionInfo";
import type { ScheduleKind } from "../export/exportSchedule";
// ── Darstellungsmodus-Steuerung je Dock-Inhalt ────────────────────────────
@@ -196,10 +201,38 @@ export interface PanelHostValue {
h: number,
anchor: { fx: number; fy: number },
) => void;
/**
* Verschiebt die Selektion um (dx,dy) Meter generischer Move über das
* Transform-System (analog dem Move-Werkzeug/-Befehl), deckt Wand/Drawing2D/
* Extrusionskörper ab. Bei Decke/Öffnung/Treppe/Raum (Transform-Kern kennt
* diese Elementarten nicht) ein No-op.
*/
onMoveSelectionBy: (dx: number, dy: number) => void;
/**
* Dreht die Selektion um `deg` Grad um den Weltpunkt (cx,cy) generischer
* Rotate über das Transform-System; deckt dieselben Selektionsarten ab wie
* {@link onMoveSelectionBy}.
*/
onRotateSelectionAround: (cx: number, cy: number, deg: number) => void;
/**
* Setzt die Länge einer Linien-Drawing2D (Endpunkt entlang der Richtung neu
* skaliert, Startpunkt bleibt fix). No-op, wenn die Selektion keine Linie ist.
*/
onSetDrawingLineLength: (length: number) => void;
/**
* Setzt den Radius einer Kreis-Drawing2D (Mittelpunkt bleibt fix). No-op,
* wenn die Selektion kein Kreis ist.
*/
onSetDrawingCircleRadius: (radius: number) => void;
// ── Wand-Attribute (Object-Info-Panel; wirken NUR auf die selektierte Wand) ─
/** Setzt die Lage der Wandachse über die Dicke (außen/mitte/innen). */
onSetWallReferenceLine: (ref: WallReferenceLine) => void;
/**
* Setzt die Terminierungs-Regel am Deckenanschluss (Zuschnitt, NICHT Priorität):
* "both" = heutiges Verhalten, "below"/"above" = die Wand endet an der Decke.
*/
onSetWallSliceTermination: (termination: SliceTermination) => void;
/** Weist der Wand einen Wandtyp-Preset (mehrschichtiger Aufbau) zu. */
onSetWallType: (wallTypeId: string) => void;
/** Setzt die Gesamtdicke einer einschichtigen Wand (Meter). */
@@ -216,6 +249,12 @@ export interface PanelHostValue {
onSetCeilingThickness: (thickness: number) => void;
/** Setzt/entfernt die OK-Bindung der Decke (`null` = Geschoss-Oberkante). */
onSetCeilingTop: (anchor: VerticalAnchor | null) => void;
/**
* Setzt/entfernt die UK-Bindung der Decke (`null` = OK Dicke). Optional,
* da bestehende Hosts (App.tsx) diesen Setter ggf. noch nicht verdrahtet
* haben additive Erweiterung analog `onSetWallBottom`.
*/
onSetCeilingBottom?: (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. */
@@ -242,6 +281,8 @@ export interface PanelHostValue {
onSetOpeningDoorType: (doorType: "normal" | "wandoeffnung") => void;
/** Setzt die Sturzlinien-Darstellung der Tür. */
onSetOpeningLintelLines: (lintelLines: "keine" | "innen" | "aussen" | "beide") => void;
/** Weist der Öffnung einen Tür-/Fenstertyp (Bibliothek) zu; "" löst die Zuweisung. */
onSetOpeningType: (typeId: string) => void;
// ── Treppen-Attribute (Object-Info-Panel; nur die selektierte Treppe) ───
/** Setzt die Grundform (gerade/L/Wendel). */
@@ -256,6 +297,8 @@ export interface PanelHostValue {
onSetStairUp: (up: boolean) => void;
/** Setzt den Referenzpunkt der Laufbreite (links/mitte/rechts). */
onSetStairReferenz: (referenz: "links" | "mitte" | "rechts") => void;
/** Weist der Treppe einen Treppentyp (Bibliothek) zu; "" löst die Zuweisung. */
onSetStairType: (typeId: string) => void;
// ── Raum-Attribute (Object-Info-Panel; nur der selektierte Raum) ────────
/** Setzt den Raum-Namen. */
@@ -265,6 +308,26 @@ export interface PanelHostValue {
/** Öffnet den Rich-Text-Editor des Raum-Stempels (per ID). */
onEditRoomStamp: (roomId: string) => void;
// ── Extrusions-Attribute (truck-Integration; nur der selektierte Körper) ──
/** Setzt die Extrusionshöhe (Meter, > 0) — löst eine Re-Extrusion aus. */
onSetExtrudedSolidHeight: (height: number) => void;
/** Setzt die Verjüngung (0..1) — löst eine Re-Extrusion aus. */
onSetExtrudedSolidTaper: (taper: number) => void;
// ── Stützen-Attribute (Tragwerk; nur die selektierte Stütze) ────────────
/** Wechselt das Profil (Rechteck/Kreis); leitet ein sinnvolles Mass ab. */
onSetColumnProfileKind: (kind: "rect" | "round") => void;
/** Setzt die Breite (X) eines Rechteckprofils (Meter). */
onSetColumnWidth: (width: number) => void;
/** Setzt die Tiefe (Y) eines Rechteckprofils (Meter). */
onSetColumnDepth: (depth: number) => void;
/** Setzt den Radius eines Kreisprofils (Meter). */
onSetColumnRadius: (radius: number) => void;
/** Setzt die Höhe der Stütze (Meter, > 0). */
onSetColumnHeight: (height: number) => void;
/** Setzt die Drehung der Stütze (Grad; intern in Radiant gespeichert). */
onSetColumnRotation: (deg: number) => void;
// ── Site-/Kontext-Schicht (SitePanel) ───────────────────────────────────
/** Aktuelle Kontext-Objekte (Meshes/Konturen/Gelände) aus `project.context`. */
contextObjects: ContextObject[];
@@ -279,6 +342,166 @@ export interface PanelHostValue {
onGenerateTerrain: (contourSetId: string) => string | null;
/** Öffnet den (in App montierten) versteckten DXF-Datei-Dialog. */
onImportDxf: () => void;
// ── Element-Baum (Elemente-Panel) ───────────────────────────────────────
/**
* Selektiert EIN Bauteil-Vorkommen aus der Elementliste (`scheduleRows`) im
* Baum: leert alle Auswahl-Kanäle und setzt genau dieses Element im
* passenden Kanal (Wand/Decke/Fenster+Öffnung/Treppe/Extrusion). Liegt das
* Element auf einem anderen Geschoss, wechselt zuerst die aktive
* Zeichnungsebene. Für Bauteilklassen ohne Auswahl-Kanal (aktuell nur die
* Legacy-Tür"-Datensätze aus `project.doors`, die kein Werkzeug mehr
* anlegt) ein No-op. `opts.zoom` (Shift-Klick/Doppelklick in der Baumzeile)
* passt die Plan-Ansicht zusätzlich ein.
*/
onSelectScheduleRow: (
kind: ScheduleKind,
id: string,
floorId: string | undefined,
opts?: { zoom?: boolean },
) => void;
// ── Ausschnitte / View-Snapshots (Ausschnitte-Panel, DOSSIER A2) ─────────
// Die Liste selbst liest das Panel über `project.viewSnapshots`. Die
// Erfassungs-/Anwendungslogik (Setter/rAF) lebt in App.tsx; hier nur die
// immutablen Handler.
/**
* Erfasst den aktuellen Darstellungszustand als neuen, benannten Ausschnitt.
* `folderId` legt ihn direkt im angegebenen Ordner ab (sonst Wurzelebene)
* additiv, Alt-Aufrufe ohne Ordner-Argument bleiben gültig.
*/
onCaptureViewSnapshot: (name: string, folderId?: string) => void;
/** Stellt alle Felder eines Ausschnitts wieder her (Geschoss/Sichtbarkeit/Overrides/Ansicht). */
onApplyViewSnapshot: (id: string) => void;
/** Benennt einen Ausschnitt um. */
onRenameViewSnapshot: (id: string, name: string) => void;
/** Löscht einen Ausschnitt. */
onDeleteViewSnapshot: (id: string) => void;
// ── Ausschnitte-Baum (Ordner, DOSSIER A2) ───────────────────────────────
// Die Listen liest das Panel über `project.viewSnapshots`/
// `project.viewSnapshotFolders`; pure CRUD in state/viewSnapshotFolders.ts.
/** Legt einen neuen Ordner an (optional in `parentId`); liefert die neue Id. */
onAddViewSnapshotFolder: (parentId?: string) => string;
/** Benennt einen Ausschnitte-Ordner um. */
onRenameViewSnapshotFolder: (id: string, name: string) => void;
/**
* Löscht einen Ausschnitte-Ordner (enthaltene Ausschnitte + Unterordner wandern
* auf die Elternebene, kein Datenverlust).
*/
onDeleteViewSnapshotFolder: (id: string) => void;
/**
* Verschiebt einen Ausschnitt per Drag&Drop in einen Ordner (`folderId`) bzw.
* auf die Wurzelebene (`null`).
*/
onMoveViewSnapshotToFolder: (snapshotId: string, folderId: string | null) => void;
/**
* Verschiebt einen Ordner per Drag&Drop unter einen Elternordner (`parentId`)
* bzw. auf die Wurzelebene (`null`). Zyklen (Ordner in sich/seinen Nachfahren)
* werden verworfen (No-op).
*/
onMoveViewSnapshotFolder: (folderId: string, parentId: string | null) => void;
/**
* Id des aktuell angewählten" Ausschnitts (Footer-Bar sichtbar) oder `null`.
* Bleibt gesetzt, solange der Live-Zustand exakt dem Ausschnitt entspricht;
* App löscht ihn, sobald der Nutzer eine erfasste Grösse ändert.
*/
selectedViewSnapshotId: string | null;
/** Namen aller gespeicherten Ebenen-Kombinationen (localStorage). */
listLayerCombos: () => string[];
/** Lädt die `codes`-Map einer Ebenen-Kombination (`null`, wenn unbekannt). */
loadLayerCombo: (name: string) => Record<string, boolean> | null;
/** Namen aller gespeicherten Zeichnungs-Kombinationen (localStorage). */
listDrawingCombos: () => string[];
/** Lädt die `ids`-Map einer Zeichnungs-Kombination (`null`, wenn unbekannt). */
loadDrawingCombo: (name: string) => Record<string, boolean> | null;
// ── Layouts / Masterlayouts (Layouts-Panel, DOSSIER A3) ─────────────────
// Die Listen liest das Panel über `project.layouts`/`project.masterLayouts`.
// Die Mutationslogik lebt in App.tsx (setProject); pure CRUD in
// panels/layoutModel.ts.
/** Legt ein neues, leeres Layout an und öffnet den Editor. */
onAddLayout: (
name: string,
paper: LayoutPaperFormat,
orientation: LayoutOrientation,
) => void;
/** Benennt ein Layout um. */
onRenameLayout: (id: string, name: string) => void;
/** Löscht ein Layout (schliesst ggf. den offenen Editor). */
onDeleteLayout: (id: string) => void;
/** Öffnet ein Layout im schwebenden Layout-Editor. */
onOpenLayout: (id: string) => void;
/** Legt ein neues Masterlayout an. */
onAddMasterLayout: (name: string) => void;
/** Benennt ein Masterlayout um. */
onRenameMasterLayout: (id: string, name: string) => void;
/** Löscht ein Masterlayout (löst Bindungen der Layouts). */
onDeleteMasterLayout: (id: string) => void;
/** Ändert Felder eines Masterlayouts (Titelblock/Papier/Rahmen). */
onPatchMasterLayout: (id: string, patch: Partial<MasterLayout>) => void;
// ── Layouts-Baum (Ordner) + Erstell-Dialoge (DOSSIER A3) ────────────────
/** Legt einen neuen Ordner an (optional in `parentId`); liefert die neue Id. */
onAddLayoutFolder: (parentId?: string) => string;
/**
* Legt einen neuen MASTER-Ordner (`kind:"master"`) an (optional in `parentId`);
* liefert die neue Id. Analog {@link onAddLayoutFolder}, aber im Master-Baum.
*/
onAddMasterFolder: (parentId?: string) => string;
/** Benennt einen Ordner um (Layout- wie Master-Ordner, per Id). */
onRenameLayoutFolder: (id: string, name: string) => void;
/** Löscht einen Ordner (Inhalt wandert auf die Elternebene, kein Datenverlust). */
onDeleteLayoutFolder: (id: string) => void;
/**
* Löscht einen Master-Ordner (enthaltene Masterlayouts + Unterordner wandern
* auf die Elternebene, kein Datenverlust). Analog {@link onDeleteLayoutFolder}.
*/
onDeleteMasterFolder: (id: string) => void;
/**
* Legt ein Layout mit expliziten Startwerten (Master-Vorlage ODER freie
* Grösse) an und liefert dessen Id (das Panel versetzt es in Inline-Rename).
* Öffnet das Blatt NICHT automatisch (anders als {@link onAddLayout}).
*/
onCreateLayout: (opts: CreateLayoutOptions) => string;
/** Legt ein Masterlayout mit Grösse an und liefert dessen Id. */
onCreateMasterLayout: (opts: CreateMasterLayoutOptions) => string;
/** Exportiert alle Layouts eines Ordners als EIN Mehrseiten-PDF. */
onExportFolderPdf: (folderId: string) => void;
/**
* Verschiebt ein Layout per Drag&Drop in einen Ordner (`folderId`) bzw. auf
* die Wurzelebene (`null`).
*/
onMoveLayoutToFolder: (layoutId: string, folderId: string | null) => void;
/**
* Verschiebt einen Ordner per Drag&Drop unter einen Elternordner (`parentId`)
* bzw. auf die Wurzelebene (`null`). Zyklen (Ordner in sich/seinen Nachfahren)
* werden verworfen (No-op).
*/
onMoveFolderToFolder: (folderId: string, parentId: string | null) => void;
}
/** Startwerte eines neuen Layouts (aus dem „Neues Layout"-Dialog). */
export interface CreateLayoutOptions {
/** Zielordner; ohne → Wurzelebene. */
folderId?: string;
/** Gebundenes Masterlayout (erbt dessen Grösse); ohne → freie Grösse. */
masterId?: string;
paper: LayoutPaperFormat;
orientation: LayoutOrientation;
customWidthMm?: number;
customHeightMm?: number;
}
/** Startwerte eines neuen Masterlayouts (aus dem „Neues Masterlayout"-Dialog). */
export interface CreateMasterLayoutOptions {
/** Ziel-Master-Ordner; ohne → Wurzelebene des Master-Baums. */
folderId?: string;
paper: LayoutPaperFormat;
orientation: LayoutOrientation;
customWidthMm?: number;
customHeightMm?: number;
}
// Re-Export der Modelltypen, die die Panels im selben Atemzug brauchen — so
@@ -286,6 +509,7 @@ export interface PanelHostValue {
export type { DrawingLevel, LayerCategory };
export type { SnapSettings, ToolId };
export type { Selection } from "../state/selectionInfo";
export type { ScheduleKind, ScheduleRow } from "../export/exportSchedule";
// ── Hook ───────────────────────────────────────────────────────────────────
+8 -2
View File
@@ -34,7 +34,13 @@ import type {
// v8: Werkzeug-Sidebar aus dem Default-Layout entfernt (Ribbon-UI Phase 3) →
// Version-Bump, damit gespeicherte Layouts auf den neuen Standard zurückfallen
// (Attribute volle linke Höhe statt Werkzeug-Palette oben).
export const LAYOUT_VERSION = 8;
// v9: Element-Baum ("elements", ROADMAP Paragraf 11) als weiterer Tab in der
// rechten unteren Gruppe (neben Zeichnungsebenen/Ebenen/Umgebung).
// v10: Ausschnitte-Panel ("view-snapshots", DOSSIER A2 / ROADMAP Paragraf 2c)
// als weiterer Tab in der rechten unteren Gruppe.
// v11: Layouts-Panel ("layouts", DOSSIER A3 / ROADMAP Paragraf 11) als weiterer
// Tab in der rechten unteren Gruppe.
export const LAYOUT_VERSION = 11;
/** localStorage-Schlüssel des zuletzt benutzten Layouts. */
const CURRENT_KEY = "cad.layout";
@@ -68,7 +74,7 @@ const DEFAULT_LEFT_GROUPS: DockGroup[] = [
const DEFAULT_RIGHT_GROUPS: DockGroup[] = [
{ tabs: ["object-info", "room-balance"], activeTab: "object-info", weight: 1 },
{
tabs: ["drawing-levels", "layers", "site"],
tabs: ["drawing-levels", "layers", "site", "elements", "view-snapshots", "layouts"],
activeTab: "drawing-levels",
weight: 1.5,
},
+654
View File
@@ -0,0 +1,654 @@
// Tests der reinen Layout-Logik (DOSSIER A3): Blatt-Geometrie, Fabriken,
// immutables Viewport-CRUD, Master-Vererbung der Titelblock-Werte,
// Viewport→Ausschnitt-Auflösung + Massstab, Sichtbarkeits-Ableitung.
import { describe, expect, it } from "vitest";
import type {
Layout,
LayoutFolder,
MasterLayout,
Project,
ViewSnapshot,
} from "../model/types";
import {
addAnnotation,
addViewport,
buildLayoutTree,
buildMasterTree,
collectFolderLayouts,
createAnnotation,
createLayout,
createLayoutFolder,
createMasterLayout,
createViewport,
deleteFolder,
deleteMasterFolder,
effectiveSheetSizeMm,
findMaster,
findSnapshot,
folderPdfPages,
isDescendant,
moveFolderToFolder,
moveLayoutToFolder,
PAPER_FORMATS,
patchAnnotation,
patchViewport,
removeAnnotation,
removeViewport,
resolveTitleBlock,
resolveViewportScale,
sheetSizeMm,
visibleCodesFromSnapshot,
} from "./layoutModel";
/** Minimaler Ausschnitt (nur die im Layout-System genutzten Felder tragen Werte). */
function snap(id: string, patch?: Partial<ViewSnapshot>): ViewSnapshot {
return {
id,
name: `Snap ${id}`,
viewType: "grundriss",
view3d: "top",
scaleDenominator: 100,
detail: "mittel",
activeLevelId: "floor-0",
layerVisibility: { A: true, B: false, C: true },
drawingVisibility: {},
...patch,
};
}
/** Minimales Projekt-Gerüst mit optionalen Layout-Feldern. */
function project(patch?: Partial<Project>): Project {
return {
id: "p1",
name: "Villa Test",
lineStyles: [],
hatches: [],
components: [],
wallTypes: [],
drawingLevels: [],
layers: [],
walls: [],
doors: [],
drawings2d: [],
...patch,
};
}
describe("sheetSizeMm", () => {
it("liefert A4/A3 in Hoch- und Querformat", () => {
expect(sheetSizeMm("a4", "portrait")).toEqual({ w: 210, h: 297 });
expect(sheetSizeMm("a4", "landscape")).toEqual({ w: 297, h: 210 });
expect(sheetSizeMm("a3", "portrait")).toEqual({ w: 297, h: 420 });
expect(sheetSizeMm("a3", "landscape")).toEqual({ w: 420, h: 297 });
});
});
describe("Fabriken", () => {
it("createLayout: leer, ohne Master", () => {
const l = createLayout("l1", "Blatt 1", "a3", "landscape");
expect(l).toEqual({
id: "l1",
name: "Blatt 1",
paper: "a3",
orientation: "landscape",
viewports: [],
});
expect(l.masterId).toBeUndefined();
});
it("createMasterLayout: leerer Titelblock + Rahmen an", () => {
const m = createMasterLayout("m1", "Master", "a4", "portrait");
expect(m.titleBlock).toEqual({});
expect(m.border).toBe(true);
});
it("createViewport: Defaults + optionaler Massstab", () => {
const v = createViewport("v1", "s1");
expect(v).toMatchObject({ id: "v1", snapshotId: "s1", xMm: 20, yMm: 20, widthMm: 120, heightMm: 90 });
expect(v.scaleDenominator).toBeUndefined();
const v2 = createViewport("v2", "s1", { xMm: 5, scaleDenominator: 50 });
expect(v2.xMm).toBe(5);
expect(v2.scaleDenominator).toBe(50);
});
});
describe("Viewport-CRUD (immutabel)", () => {
const base = addViewport(createLayout("l1", "L", "a4", "portrait"), createViewport("v1", "s1"));
it("addViewport verändert das Original nicht", () => {
const before = createLayout("l1", "L", "a4", "portrait");
const after = addViewport(before, createViewport("v1", "s1"));
expect(before.viewports).toHaveLength(0);
expect(after.viewports).toHaveLength(1);
expect(after).not.toBe(before);
});
it("removeViewport entfernt per Id", () => {
const two = addViewport(base, createViewport("v2", "s2"));
const one = removeViewport(two, "v1");
expect(one.viewports.map((v) => v.id)).toEqual(["v2"]);
});
it("patchViewport ändert nur das Ziel, immutabel", () => {
const patched = patchViewport(base, "v1", { xMm: 42, scaleDenominator: 50 });
expect(patched.viewports[0]).toMatchObject({ xMm: 42, scaleDenominator: 50 });
expect(base.viewports[0].xMm).toBe(20);
// Unbekannte Id → No-op (gleiche Werte, neue Liste).
const noop = patchViewport(base, "nope", { xMm: 1 });
expect(noop.viewports[0].xMm).toBe(20);
});
});
// ── Annotationen (Linie/Rechteck/Text) ───────────────────────────────────────
describe("createAnnotation — Defaults je Art", () => {
it("line: Default-Geometrie ohne Farbe/Strichstärke", () => {
const a = createAnnotation("a1", "line");
expect(a).toEqual({ id: "a1", kind: "line", x1Mm: 20, y1Mm: 20, x2Mm: 80, y2Mm: 20 });
});
it("line: init überschreibt einzelne Felder + optionale Farbe/Strichstärke", () => {
const a = createAnnotation("a1", "line", { x1Mm: 5, color: "#f00", weightMm: 0.5 });
expect(a).toMatchObject({ x1Mm: 5, y1Mm: 20, color: "#f00", weightMm: 0.5 });
});
it("rect: Default-Grösse", () => {
const a = createAnnotation("a2", "rect");
expect(a).toEqual({ id: "a2", kind: "rect", xMm: 20, yMm: 20, widthMm: 40, heightMm: 30 });
});
it("text: Default-Zeilenhöhe 3.5mm, leerer Text", () => {
const a = createAnnotation("a3", "text");
expect(a).toEqual({ id: "a3", kind: "text", xMm: 20, yMm: 20, text: "", heightMm: 3.5 });
});
it("text: init setzt Inhalt + Höhe", () => {
const a = createAnnotation("a3", "text", { text: "EG", heightMm: 5 });
expect(a).toMatchObject({ text: "EG", heightMm: 5 });
});
});
describe("Annotations-CRUD (immutabel)", () => {
const base = createLayout("l1", "L", "a4", "portrait");
it("addAnnotation verändert das Original nicht", () => {
const after = addAnnotation(base, createAnnotation("a1", "line"));
expect(base.annotations).toBeUndefined();
expect(after.annotations).toHaveLength(1);
expect(after).not.toBe(base);
});
it("addAnnotation baut auf einer fehlenden `annotations`-Liste auf (Alt-Layout)", () => {
const legacy: Layout = { id: "old", name: "Alt", paper: "a4", orientation: "portrait", viewports: [] };
const after = addAnnotation(legacy, createAnnotation("a1", "rect"));
expect(after.annotations).toHaveLength(1);
expect(legacy.annotations).toBeUndefined();
});
it("removeAnnotation entfernt per Id", () => {
let l = addAnnotation(base, createAnnotation("a1", "line"));
l = addAnnotation(l, createAnnotation("a2", "rect"));
const removed = removeAnnotation(l, "a1");
expect(removed.annotations!.map((a) => a.id)).toEqual(["a2"]);
});
it("removeAnnotation auf Alt-Layout ohne `annotations` bleibt heil (No-op)", () => {
const legacy: Layout = { id: "old", name: "Alt", paper: "a4", orientation: "portrait", viewports: [] };
const r = removeAnnotation(legacy, "nope");
expect(r.annotations).toEqual([]);
});
it("patchAnnotation ändert nur das Ziel, immutabel", () => {
const l = addAnnotation(base, createAnnotation("a1", "line", { color: "#111" }));
const patched = patchAnnotation(l, "a1", { x1Mm: 42, color: "#0f0" });
expect(patched.annotations![0]).toMatchObject({ x1Mm: 42, color: "#0f0" });
expect(l.annotations![0].color).toBe("#111");
});
it("patchAnnotation: unbekannte Id → No-op (gleiche Werte, neue Liste)", () => {
const l = addAnnotation(base, createAnnotation("a1", "line"));
const noop = patchAnnotation(l, "nope", { x1Mm: 1 });
expect((noop.annotations![0] as { x1Mm: number }).x1Mm).toBe(20);
});
it("patchAnnotation auf einer Text-Annotation ändert den Inhalt", () => {
const l = addAnnotation(base, createAnnotation("t1", "text", { text: "EG" }));
const patched = patchAnnotation(l, "t1", { text: "1. OG" });
expect(patched.annotations![0]).toMatchObject({ text: "1. OG" });
});
});
describe("resolveViewportScale", () => {
const s = snap("s1", { scaleDenominator: 200 });
it("Viewport-Override gewinnt", () => {
expect(resolveViewportScale(createViewport("v", "s1", { scaleDenominator: 50 }), s)).toBe(50);
});
it("sonst Ausschnitt-Massstab", () => {
expect(resolveViewportScale(createViewport("v", "s1"), s)).toBe(200);
});
it("Fallback ohne beides", () => {
expect(resolveViewportScale(createViewport("v", "s1"), undefined, 100)).toBe(100);
});
});
describe("findSnapshot / findMaster", () => {
const p = project({
viewSnapshots: [snap("s1"), snap("s2")],
masterLayouts: [createMasterLayout("m1", "M1", "a4", "portrait")],
});
it("findSnapshot", () => {
expect(findSnapshot(p, "s2")?.id).toBe("s2");
expect(findSnapshot(p, "x")).toBeUndefined();
});
it("findMaster nur bei gesetzter masterId", () => {
const bound: Layout = { ...createLayout("l", "L", "a4", "portrait"), masterId: "m1" };
expect(findMaster(p, bound)?.id).toBe("m1");
expect(findMaster(p, createLayout("l2", "L2", "a4", "portrait"))).toBeUndefined();
// Dangling masterId → undefined.
expect(findMaster(p, { ...bound, masterId: "gone" })).toBeUndefined();
});
});
describe("resolveTitleBlock — Master-Vererbung + Defaults", () => {
const p = project({ name: "Villa Test", viewSnapshots: [snap("s1", { scaleDenominator: 50 })] });
it("ohne Master: Defaults aus Projekt/Layout + Massstab aus erstem Viewport", () => {
let l = createLayout("l1", "EG Grundriss", "a4", "portrait");
l = addViewport(l, createViewport("v1", "s1"));
const tb = resolveTitleBlock(p, l, undefined, "2026-07-07");
expect(tb.projectName).toBe("Villa Test");
expect(tb.sheetName).toBe("EG Grundriss");
expect(tb.scale).toBe("1:50");
expect(tb.date).toBe("2026-07-07");
expect(tb.author).toBe("");
});
it("Master-Werte übersteuern die Defaults", () => {
const master: MasterLayout = {
id: "m1",
name: "M",
paper: "a4",
orientation: "portrait",
titleBlock: { projectName: "Kundenprojekt", author: "GV", scale: "1:100" },
};
const l = createLayout("l1", "EG", "a4", "portrait");
const tb = resolveTitleBlock(p, l, master, "2026-07-07");
expect(tb.projectName).toBe("Kundenprojekt");
expect(tb.author).toBe("GV");
expect(tb.scale).toBe("1:100"); // Master-Wert schlägt den Viewport-Default
expect(tb.sheetName).toBe("EG"); // leer im Master → Layout-Name
});
it("leere/whitespace Master-Felder fallen auf Defaults zurück", () => {
const master: MasterLayout = {
id: "m1",
name: "M",
paper: "a4",
orientation: "portrait",
titleBlock: { projectName: " ", sheetName: "" },
};
const l = createLayout("l1", "OG", "a4", "portrait");
const tb = resolveTitleBlock(p, l, master, "2026-07-07");
expect(tb.projectName).toBe("Villa Test");
expect(tb.sheetName).toBe("OG");
expect(tb.scale).toBe("—"); // kein Viewport → kein Massstab
});
});
describe("visibleCodesFromSnapshot", () => {
it("nur sichtbare Codes landen im Set", () => {
const codes = visibleCodesFromSnapshot(snap("s1"));
expect([...codes].sort()).toEqual(["A", "C"]);
expect(codes.has("B")).toBe(false);
});
});
// ── Freie Grösse (custom übersteuert Papier) ──────────────────────────────────
describe("sheetSizeMm / effectiveSheetSizeMm — freie Grösse", () => {
it("custom übersteuert paper/orientation, wenn beide positiv gesetzt", () => {
expect(sheetSizeMm("a4", "portrait", 500, 300)).toEqual({ w: 500, h: 300 });
});
it("fällt auf paper zurück, wenn custom fehlt oder nicht positiv ist", () => {
expect(sheetSizeMm("a4", "portrait", undefined, 300)).toEqual({ w: 210, h: 297 });
expect(sheetSizeMm("a3", "landscape", 0, 300)).toEqual({ w: 420, h: 297 });
});
it("effectiveSheetSizeMm liest die Felder eines Layouts/Masters", () => {
expect(
effectiveSheetSizeMm({ paper: "a4", orientation: "portrait" }),
).toEqual({ w: 210, h: 297 });
expect(
effectiveSheetSizeMm({
paper: "a4",
orientation: "portrait",
customWidthMm: 250,
customHeightMm: 250,
}),
).toEqual({ w: 250, h: 250 });
});
});
describe("createMasterLayout — freie Grösse", () => {
it("übernimmt custom Breite/Höhe, wenn übergeben", () => {
const m = createMasterLayout("m", "M", "a4", "portrait", {
widthMm: 400,
heightMm: 250,
});
expect(m.customWidthMm).toBe(400);
expect(m.customHeightMm).toBe(250);
});
});
// ── Ordner-Baum (CRUD + Aufbau) ───────────────────────────────────────────────
function lay(id: string, folderId?: string): Layout {
return {
id,
name: id,
paper: "a4",
orientation: "portrait",
viewports: [],
...(folderId ? { folderId } : {}),
};
}
describe("createLayoutFolder", () => {
it("Wurzelordner ohne parentId", () => {
expect(createLayoutFolder("f1", "Ordner")).toEqual({ id: "f1", name: "Ordner" });
});
it("Unterordner mit parentId", () => {
expect(createLayoutFolder("f2", "Sub", "f1")).toEqual({
id: "f2",
name: "Sub",
parentId: "f1",
});
});
it("Master-Ordner trägt kind:\"master\"", () => {
expect(createLayoutFolder("m1", "Master-Ordner", undefined, "master")).toEqual({
id: "m1",
name: "Master-Ordner",
kind: "master",
});
});
it("kind \"layout\"/undefined wird NICHT gesetzt (rückwärtskompatibel)", () => {
expect(createLayoutFolder("l1", "Layout-Ordner", undefined, "layout")).toEqual({
id: "l1",
name: "Layout-Ordner",
});
expect(createLayoutFolder("l2", "Layout-Ordner", "p", undefined)).toEqual({
id: "l2",
name: "Layout-Ordner",
parentId: "p",
});
});
});
describe("buildLayoutTree", () => {
it("gruppiert Layouts in Ordner + Wurzel; Unterordner verschachtelt", () => {
const folders: LayoutFolder[] = [
createLayoutFolder("A", "A"),
createLayoutFolder("B", "B", "A"),
];
const layouts = [lay("root1"), lay("inA", "A"), lay("inB", "B")];
const tree = buildLayoutTree(folders, layouts);
expect(tree.rootLayouts.map((l) => l.id)).toEqual(["root1"]);
expect(tree.rootFolders).toHaveLength(1);
const a = tree.rootFolders[0];
expect(a.folder.id).toBe("A");
expect(a.layouts.map((l) => l.id)).toEqual(["inA"]);
expect(a.subfolders).toHaveLength(1);
expect(a.subfolders[0].folder.id).toBe("B");
expect(a.subfolders[0].layouts.map((l) => l.id)).toEqual(["inB"]);
});
it("verwaiste Referenzen landen auf der Wurzel", () => {
const tree = buildLayoutTree(
[createLayoutFolder("orphan", "O", "missing")],
[lay("ghost", "gone")],
);
// Ordner mit unbekanntem parent → Wurzel; Layout mit unbekanntem folder → Wurzel.
expect(tree.rootFolders.map((f) => f.folder.id)).toEqual(["orphan"]);
expect(tree.rootLayouts.map((l) => l.id)).toEqual(["ghost"]);
});
});
// ── Master-Baum + Layout/Master-Trennung (kind) ──────────────────────────────
function mas(id: string, folderId?: string): MasterLayout {
return {
id,
name: id,
paper: "a4",
orientation: "portrait",
titleBlock: {},
...(folderId ? { folderId } : {}),
};
}
describe("buildMasterTree", () => {
it("gruppiert Master in Master-Ordner + Wurzel; Unterordner verschachtelt", () => {
const folders: LayoutFolder[] = [
createLayoutFolder("A", "A", undefined, "master"),
createLayoutFolder("B", "B", "A", "master"),
];
const masters = [mas("root1"), mas("inA", "A"), mas("inB", "B")];
const tree = buildMasterTree(folders, masters);
expect(tree.rootMasters.map((m) => m.id)).toEqual(["root1"]);
expect(tree.rootFolders).toHaveLength(1);
const a = tree.rootFolders[0];
expect(a.folder.id).toBe("A");
expect(a.masters.map((m) => m.id)).toEqual(["inA"]);
expect(a.subfolders[0].masters.map((m) => m.id)).toEqual(["inB"]);
});
});
describe("Layout-/Master-Ordner bleiben getrennt (kind-Filter)", () => {
it("Master-Ordner erscheint NICHT im Layout-Baum und umgekehrt", () => {
const folders: LayoutFolder[] = [
createLayoutFolder("L", "Layout-Ordner"), // kind fehlt → Layout
createLayoutFolder("M", "Master-Ordner", undefined, "master"),
];
const layouts = [lay("l1", "L"), lay("lm", "M")]; // lm zeigt fälschlich auf Master-Ordner
const masters = [mas("m1", "M"), mas("ml", "L")]; // ml zeigt fälschlich auf Layout-Ordner
// Wie das Panel filtert: Layout-Baum nur kind!=="master", Master-Baum nur kind==="master".
const layoutTree = buildLayoutTree(
folders.filter((f) => f.kind !== "master"),
layouts,
);
const masterTree = buildMasterTree(
folders.filter((f) => f.kind === "master"),
masters,
);
// Layout-Baum kennt nur den Layout-Ordner; das auf M zeigende Layout fällt auf die Wurzel.
expect(layoutTree.rootFolders.map((f) => f.folder.id)).toEqual(["L"]);
expect(layoutTree.rootLayouts.map((l) => l.id)).toEqual(["lm"]);
// Master-Baum kennt nur den Master-Ordner; der auf L zeigende Master fällt auf die Wurzel.
expect(masterTree.rootFolders.map((f) => f.folder.id)).toEqual(["M"]);
expect(masterTree.rootMasters.map((m) => m.id)).toEqual(["ml"]);
expect(masterTree.rootFolders[0].masters.map((m) => m.id)).toEqual(["m1"]);
});
});
describe("deleteMasterFolder", () => {
it("zieht Master + Unterordner auf die Elternebene und erhält kind:\"master\"", () => {
const folders: LayoutFolder[] = [
createLayoutFolder("A", "A", undefined, "master"),
createLayoutFolder("B", "B", "A", "master"),
];
const masters = [mas("a1", "A"), mas("b1", "B")];
const r = deleteMasterFolder("A", folders, masters);
expect(r.folders.map((f) => f.id)).toEqual(["B"]);
// B auf Wurzel hochgezogen, aber weiterhin Master-Ordner.
expect(r.folders[0].parentId).toBeUndefined();
expect(r.folders[0].kind).toBe("master");
expect(r.masters.find((m) => m.id === "a1")!.folderId).toBeUndefined();
expect(r.masters.find((m) => m.id === "b1")!.folderId).toBe("B");
});
});
describe("collectFolderLayouts", () => {
it("liefert direkte Blätter zuerst, dann rekursiv Unterordner (PDF-Reihenfolge)", () => {
const folders: LayoutFolder[] = [
createLayoutFolder("A", "A"),
createLayoutFolder("B", "B", "A"),
];
const layouts = [lay("root1"), lay("a1", "A"), lay("a2", "A"), lay("b1", "B")];
expect(collectFolderLayouts("A", folders, layouts).map((l) => l.id)).toEqual([
"a1",
"a2",
"b1",
]);
expect(collectFolderLayouts("B", folders, layouts).map((l) => l.id)).toEqual([
"b1",
]);
});
it("unbekannter Ordner → leer", () => {
expect(collectFolderLayouts("nope", [], [lay("x")])).toEqual([]);
});
});
describe("deleteFolder", () => {
it("zieht Layouts + Unterordner auf die Elternebene (kein Datenverlust)", () => {
const folders: LayoutFolder[] = [
createLayoutFolder("A", "A"),
createLayoutFolder("B", "B", "A"),
];
const layouts = [lay("a1", "A"), lay("b1", "B")];
// A (Wurzel) löschen → a1 auf Wurzel, B verliert parent → Wurzel.
const r = deleteFolder("A", folders, layouts);
expect(r.folders.map((f) => f.id)).toEqual(["B"]);
expect(r.folders[0].parentId).toBeUndefined();
expect(r.layouts.find((l) => l.id === "a1")!.folderId).toBeUndefined();
expect(r.layouts.find((l) => l.id === "b1")!.folderId).toBe("B");
});
it("Unterordner löschen zieht Kinder zum Grosselter", () => {
const folders: LayoutFolder[] = [
createLayoutFolder("A", "A"),
createLayoutFolder("B", "B", "A"),
];
const layouts = [lay("b1", "B")];
const r = deleteFolder("B", folders, layouts);
expect(r.folders.map((f) => f.id)).toEqual(["A"]);
expect(r.layouts[0].folderId).toBe("A");
});
});
describe("folderPdfPages", () => {
it("plant Seiten in Baum-Reihenfolge mit effektiver Blattgrösse", () => {
const master: MasterLayout = createMasterLayout("m", "M", "a3", "landscape");
const layouts: Layout[] = [
{ ...lay("p1", "F"), masterId: "m" }, // erbt A3 quer vom Master
{ ...lay("p2", "F"), customWidthMm: 500, customHeightMm: 200 }, // freie Grösse
lay("p3", "F"), // A4 hoch (Default)
];
const p = project({
layoutFolders: [createLayoutFolder("F", "F")],
layouts,
masterLayouts: [master],
});
const pages = folderPdfPages(p, "F");
expect(pages.map((x) => x.layout.id)).toEqual(["p1", "p2", "p3"]);
expect(pages[0]).toMatchObject({ widthMm: 420, heightMm: 297 }); // Master A3 quer
expect(pages[1]).toMatchObject({ widthMm: 500, heightMm: 200 }); // custom
expect(pages[2]).toMatchObject({ widthMm: 210, heightMm: 297 }); // A4 hoch
});
it("leerer/unbekannter Ordner → keine Seiten", () => {
expect(folderPdfPages(project(), "nope")).toEqual([]);
});
});
describe("PAGE_MM / Standard-Formate A0A6 + B0B6", () => {
it("PAPER_FORMATS listet 14 Formate (A-Reihe, dann B-Reihe)", () => {
expect(PAPER_FORMATS).toEqual([
"a0", "a1", "a2", "a3", "a4", "a5", "a6",
"b0", "b1", "b2", "b3", "b4", "b5", "b6",
]);
});
it("liefert offizielle ISO-216-Masse (Hochformat, mm)", () => {
expect(sheetSizeMm("a0", "portrait")).toEqual({ w: 841, h: 1189 });
expect(sheetSizeMm("a1", "portrait")).toEqual({ w: 594, h: 841 });
expect(sheetSizeMm("a2", "portrait")).toEqual({ w: 420, h: 594 });
expect(sheetSizeMm("a5", "portrait")).toEqual({ w: 148, h: 210 });
expect(sheetSizeMm("a6", "portrait")).toEqual({ w: 105, h: 148 });
expect(sheetSizeMm("b0", "portrait")).toEqual({ w: 1000, h: 1414 });
expect(sheetSizeMm("b3", "portrait")).toEqual({ w: 353, h: 500 });
expect(sheetSizeMm("b6", "portrait")).toEqual({ w: 125, h: 176 });
});
it("Querformat dreht die Basis (Beispiel A0)", () => {
expect(sheetSizeMm("a0", "landscape")).toEqual({ w: 1189, h: 841 });
});
it("jedes Format hat eine definierte Grösse", () => {
for (const f of PAPER_FORMATS) {
const s = sheetSizeMm(f, "portrait");
expect(s.w).toBeGreaterThan(0);
expect(s.h).toBeGreaterThan(0);
}
});
});
describe("isDescendant — Zyklus-Schutz", () => {
const folders: LayoutFolder[] = [
createLayoutFolder("A", "A"),
createLayoutFolder("B", "B", "A"),
createLayoutFolder("C", "C", "B"),
createLayoutFolder("X", "X"),
];
it("ein Ordner ist sein eigener Nachfahre (self)", () => {
expect(isDescendant(folders, "A", "A")).toBe(true);
});
it("erkennt direkte + tiefe Nachfahren", () => {
expect(isDescendant(folders, "B", "A")).toBe(true);
expect(isDescendant(folders, "C", "A")).toBe(true);
});
it("nicht-verwandte Ordner sind keine Nachfahren", () => {
expect(isDescendant(folders, "X", "A")).toBe(false);
expect(isDescendant(folders, "A", "B")).toBe(false);
});
});
describe("moveLayoutToFolder", () => {
const layouts = [lay("l1", "A"), lay("l2")];
it("setzt folderId auf den Zielordner", () => {
const r = moveLayoutToFolder(layouts, "l2", "A");
expect(r.find((l) => l.id === "l2")!.folderId).toBe("A");
});
it("null → zurück auf Wurzel (folderId entfernt)", () => {
const r = moveLayoutToFolder(layouts, "l1", null);
expect(r.find((l) => l.id === "l1")!.folderId).toBeUndefined();
expect("folderId" in r.find((l) => l.id === "l1")!).toBe(false);
});
it("unbekannte Id → unverändert", () => {
expect(moveLayoutToFolder(layouts, "nope", "A")).toEqual(layouts);
});
});
describe("moveFolderToFolder", () => {
const folders: LayoutFolder[] = [
createLayoutFolder("A", "A"),
createLayoutFolder("B", "B", "A"),
createLayoutFolder("X", "X"),
];
it("setzt parentId auf den Zielordner", () => {
const r = moveFolderToFolder(folders, "X", "A");
expect(r.find((f) => f.id === "X")!.parentId).toBe("A");
});
it("null → zurück auf Wurzel (parentId entfernt)", () => {
const r = moveFolderToFolder(folders, "B", null);
expect(r.find((f) => f.id === "B")!.parentId).toBeUndefined();
});
it("kind bleibt beim Wurzel-Verschieben erhalten (Master-Ordner)", () => {
const withMaster: LayoutFolder[] = [
createLayoutFolder("P", "P", undefined, "master"),
createLayoutFolder("Q", "Q", "P", "master"),
];
const r = moveFolderToFolder(withMaster, "Q", null);
const q = r.find((f) => f.id === "Q")!;
expect(q.parentId).toBeUndefined();
expect(q.kind).toBe("master");
});
it("Zyklus (in eigenen Nachfahren) → No-op, Original zurück", () => {
expect(moveFolderToFolder(folders, "A", "B")).toEqual(folders);
});
it("in sich selbst → No-op", () => {
expect(moveFolderToFolder(folders, "A", "A")).toEqual(folders);
});
});
+673
View File
@@ -0,0 +1,673 @@
// Layout-Blätter mit Masterlayout (DOSSIER A3) — reine, testbare Logik.
//
// Enthält KEINE React-/DOM-Abhängigkeit: nur immutable CRUD auf Layouts/Master/
// Viewports, die Master-Vererbung der Titelblock-Werte, die Auflösung eines
// Viewports auf seinen gebundenen Ausschnitt und die Blatt-Geometrie (Papier-mm).
// Das UI-Wiring (App.tsx setProject, LayoutEditor-Rendering) baut hierauf auf.
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import type {
Layout,
LayoutAnnotation,
LayoutAnnotationPatch,
LayoutFolder,
LayoutOrientation,
LayoutPaperFormat,
LayoutTitleBlock,
LayoutViewport,
MasterLayout,
Project,
ViewSnapshot,
} from "../model/types";
// ── Blatt-Geometrie ──────────────────────────────────────────────────────────
/**
* Blattmasse je Format im Hochformat (mm) offizielle ISO-216-Masse (auf ganze
* mm gerundet). A-Reihe A0A6 und B-Reihe B0B6.
*/
const PAGE_MM: Record<LayoutPaperFormat, { w: number; h: number }> = {
a0: { w: 841, h: 1189 },
a1: { w: 594, h: 841 },
a2: { w: 420, h: 594 },
a3: { w: 297, h: 420 },
a4: { w: 210, h: 297 },
a5: { w: 148, h: 210 },
a6: { w: 105, h: 148 },
b0: { w: 1000, h: 1414 },
b1: { w: 707, h: 1000 },
b2: { w: 500, h: 707 },
b3: { w: 353, h: 500 },
b4: { w: 250, h: 353 },
b5: { w: 176, h: 250 },
b6: { w: 125, h: 176 },
};
/**
* Auswahlreihenfolge der Standard-Formate für Dropdowns (A-Reihe, dann B-Reihe).
* Gruppierbar über {@link PAPER_FORMAT_GROUPS} (A/B) für optgroups.
*/
export const PAPER_FORMATS: LayoutPaperFormat[] = [
"a0", "a1", "a2", "a3", "a4", "a5", "a6",
"b0", "b1", "b2", "b3", "b4", "b5", "b6",
];
/** Format-Liste je Reihe (A/B) — für gruppierte Dropdown-Anzeige (optgroup). */
export const PAPER_FORMAT_GROUPS: { label: string; formats: LayoutPaperFormat[] }[] = [
{ label: "A", formats: ["a0", "a1", "a2", "a3", "a4", "a5", "a6"] },
{ label: "B", formats: ["b0", "b1", "b2", "b3", "b4", "b5", "b6"] },
];
/**
* Blattmasse (mm) nach Format + Ausrichtung (Quer = Basis gedreht). Sind
* `customWidthMm`/`customHeightMm` (freie Grösse) gesetzt und positiv, gewinnen
* sie und `paper`/`orientation` werden ignoriert (additiv Alt-Aufrufe ohne
* Custom-Argumente verhalten sich unverändert).
*/
export function sheetSizeMm(
paper: LayoutPaperFormat,
orientation: LayoutOrientation,
customWidthMm?: number,
customHeightMm?: number,
): { w: number; h: number } {
if (
customWidthMm !== undefined &&
customHeightMm !== undefined &&
customWidthMm > 0 &&
customHeightMm > 0
) {
return { w: customWidthMm, h: customHeightMm };
}
const base = PAGE_MM[paper];
return orientation === "landscape" ? { w: base.h, h: base.w } : base;
}
/**
* Effektive Blattgrösse eines Layouts/Masters (mm) respektiert die freie
* Grösse (`customWidthMm`/`customHeightMm`), sonst `paper`+`orientation`. EIN
* Ort für die Custom übersteuert Papier"-Regel (Panel/PDF/Editor teilen ihn).
*/
export function effectiveSheetSizeMm(item: {
paper: LayoutPaperFormat;
orientation: LayoutOrientation;
customWidthMm?: number;
customHeightMm?: number;
}): { w: number; h: number } {
return sheetSizeMm(
item.paper,
item.orientation,
item.customWidthMm,
item.customHeightMm,
);
}
// ── Fabriken ─────────────────────────────────────────────────────────────────
/** Neues, leeres Layout (ohne Viewports, ohne Master). */
export function createLayout(
id: string,
name: string,
paper: LayoutPaperFormat,
orientation: LayoutOrientation,
): Layout {
return { id, name, paper, orientation, viewports: [] };
}
/** Neues Masterlayout mit leerem Titelblock + gezeichnetem Rahmen. */
export function createMasterLayout(
id: string,
name: string,
paper: LayoutPaperFormat,
orientation: LayoutOrientation,
custom?: { widthMm: number; heightMm: number },
): MasterLayout {
return {
id,
name,
paper,
orientation,
titleBlock: {},
border: true,
...(custom ? { customWidthMm: custom.widthMm, customHeightMm: custom.heightMm } : {}),
};
}
/**
* Neuer, leerer Ordner für den Layouts- ODER Master-Baum. `kind:"master"` markiert
* einen Master-Ordner; `"layout"`/`undefined` bleibt ein (rückwärtskompatibler)
* Layout-Ordner das Feld wird dann NICHT gesetzt (Alt-Projekte unverändert).
*/
export function createLayoutFolder(
id: string,
name: string,
parentId?: string,
kind?: "layout" | "master",
): LayoutFolder {
return {
id,
name,
...(parentId ? { parentId } : {}),
...(kind === "master" ? { kind } : {}),
};
}
// ── Layouts-Baum (Ordner + Blätter) ──────────────────────────────────────────
/** Ein Ordner-Knoten im Baum: der Ordner, seine Unterordner und direkten Blätter. */
export interface FolderNode {
folder: LayoutFolder;
subfolders: FolderNode[];
layouts: Layout[];
}
/** Der aufgebaute Baum: Wurzel-Blätter (ohne Ordner) + Wurzel-Ordner. */
export interface LayoutTree {
/** Layouts ohne (gültigen) `folderId` — auf Wurzelebene. */
rootLayouts: Layout[];
/** Ordner ohne (gültigen) `parentId` — auf Wurzelebene, je mit Unterbaum. */
rootFolders: FolderNode[];
}
/**
* Baut aus der flachen Ordner-/Layout-Liste den verschachtelten Baum. Robust
* gegen verwaiste Referenzen: ein Layout mit unbekanntem `folderId` bzw. ein
* Ordner mit unbekanntem `parentId` landet auf der Wurzelebene (so bleiben
* Alt-/Teilprojekte sichtbar). Die Eingabe-Reihenfolge bleibt je Ebene erhalten.
*/
export function buildLayoutTree(
folders: LayoutFolder[],
layouts: Layout[],
): LayoutTree {
const folderIds = new Set(folders.map((f) => f.id));
const nodes = new Map<string, FolderNode>();
for (const f of folders) {
nodes.set(f.id, { folder: f, subfolders: [], layouts: [] });
}
const rootFolders: FolderNode[] = [];
for (const f of folders) {
const node = nodes.get(f.id)!;
const parent =
f.parentId && folderIds.has(f.parentId) ? nodes.get(f.parentId) : undefined;
if (parent) parent.subfolders.push(node);
else rootFolders.push(node);
}
const rootLayouts: Layout[] = [];
for (const l of layouts) {
const node =
l.folderId && folderIds.has(l.folderId) ? nodes.get(l.folderId) : undefined;
if (node) node.layouts.push(l);
else rootLayouts.push(l);
}
return { rootLayouts, rootFolders };
}
// ── Master-Baum (Ordner + Masterlayouts) ─────────────────────────────────────
// Eigener Baum NEBEN dem Layout-Baum: Master-Ordner (`kind:"master"`) tragen
// Masterlayouts als Blätter. Bewusst KEINE gemeinsame Struktur mit dem Layout-
// Baum, damit Layout- und Master-Ordner nie vermischt werden.
/** Ein Master-Ordner-Knoten: der Ordner, seine Unterordner und direkten Master. */
export interface MasterFolderNode {
folder: LayoutFolder;
subfolders: MasterFolderNode[];
masters: MasterLayout[];
}
/** Der Master-Baum: Wurzel-Master (ohne Ordner) + Wurzel-Ordner. */
export interface MasterTree {
/** Masterlayouts ohne (gültigen) `folderId` — auf Wurzelebene. */
rootMasters: MasterLayout[];
/** Master-Ordner ohne (gültigen) `parentId` — je mit Unterbaum. */
rootFolders: MasterFolderNode[];
}
/**
* Baut aus der flachen Master-Ordner-/Master-Liste den verschachtelten Baum
* analog {@link buildLayoutTree}, aber mit Masterlayouts als Blättern. Der
* Aufrufer übergibt bereits die NUR-Master-Ordner (kind:"master"); verwaiste
* Referenzen landen robust auf der Wurzelebene.
*/
export function buildMasterTree(
folders: LayoutFolder[],
masters: MasterLayout[],
): MasterTree {
const folderIds = new Set(folders.map((f) => f.id));
const nodes = new Map<string, MasterFolderNode>();
for (const f of folders) {
nodes.set(f.id, { folder: f, subfolders: [], masters: [] });
}
const rootFolders: MasterFolderNode[] = [];
for (const f of folders) {
const node = nodes.get(f.id)!;
const parent =
f.parentId && folderIds.has(f.parentId) ? nodes.get(f.parentId) : undefined;
if (parent) parent.subfolders.push(node);
else rootFolders.push(node);
}
const rootMasters: MasterLayout[] = [];
for (const m of masters) {
const node =
m.folderId && folderIds.has(m.folderId) ? nodes.get(m.folderId) : undefined;
if (node) node.masters.push(m);
else rootMasters.push(m);
}
return { rootMasters, rootFolders };
}
/**
* Alle Layouts eines Ordners in Baum-Reihenfolge direkte Blätter zuerst, dann
* (rekursiv) die Blätter der Unterordner. Für den OrdnerMehrseiten-PDF-Export
* (jedes Layout = eine Seite, deterministische Reihenfolge).
*/
export function collectFolderLayouts(
folderId: string,
folders: LayoutFolder[],
layouts: Layout[],
): Layout[] {
const tree = buildLayoutTree(folders, layouts);
const find = (nodes: FolderNode[]): FolderNode | undefined => {
for (const n of nodes) {
if (n.folder.id === folderId) return n;
const deep = find(n.subfolders);
if (deep) return deep;
}
return undefined;
};
const node = find(tree.rootFolders);
if (!node) return [];
const out: Layout[] = [];
const walk = (n: FolderNode) => {
out.push(...n.layouts);
for (const sub of n.subfolders) walk(sub);
};
walk(node);
return out;
}
/** Eine geplante Ordner-PDF-Seite: das Layout + seine effektive Blattgrösse (mm). */
export interface FolderPdfPage {
layout: Layout;
widthMm: number;
heightMm: number;
}
/**
* Reine Seiten-Planung des OrdnerMehrseiten-PDF (ohne DOM/jsPDF): welche
* Layouts in welcher Reihenfolge mit welcher Blattgrösse. Reihenfolge =
* Baum-Reihenfolge (siehe {@link collectFolderLayouts}). Die effektive Grösse
* respektiert die freie Grösse (Custom übersteuert Papier); ein gebundener
* Master erzwingt die Blattgrösse (das Blatt erbt Papier/Ausrichtung des Masters).
*/
export function folderPdfPages(
project: Project,
folderId: string,
): FolderPdfPage[] {
const folders = project.layoutFolders ?? [];
const layouts = project.layouts ?? [];
return collectFolderLayouts(folderId, folders, layouts).map((layout) => {
const master = findMaster(project, layout);
const { w, h } = effectiveSheetSizeMm(master ?? layout);
return { layout, widthMm: w, heightMm: h };
});
}
/**
* Löscht einen Ordner (immutabel). Unterordner und Layouts des Ordners werden
* auf die Ebene des gelöschten Ordners (dessen `parentId`) hochgezogen, statt
* mitgelöscht zu werden es gehen keine Blätter verloren. Liefert die neuen
* `folders`/`layouts`-Listen.
*/
export function deleteFolder(
folderId: string,
folders: LayoutFolder[],
layouts: Layout[],
): { folders: LayoutFolder[]; layouts: Layout[] } {
const { folders: nextFolders, grandParentId } = foldersAfterDelete(folderId, folders);
const nextLayouts = layouts.map((l) =>
l.folderId === folderId
? grandParentId
? { ...l, folderId: grandParentId }
: { ...l, folderId: undefined }
: l,
);
return { folders: nextFolders, layouts: nextLayouts };
}
/**
* Löscht einen Master-Ordner (immutabel) analog {@link deleteFolder}, aber die
* enthaltenen Masterlayouts (und Unterordner) werden auf die Elternebene
* hochgezogen statt mitgelöscht. Liefert die neuen `folders`/`masters`-Listen.
*/
export function deleteMasterFolder(
folderId: string,
folders: LayoutFolder[],
masters: MasterLayout[],
): { folders: LayoutFolder[]; masters: MasterLayout[] } {
const { folders: nextFolders, grandParentId } = foldersAfterDelete(folderId, folders);
const nextMasters = masters.map((m) =>
m.folderId === folderId
? grandParentId
? { ...m, folderId: grandParentId }
: { ...m, folderId: undefined }
: m,
);
return { folders: nextFolders, masters: nextMasters };
}
/**
* Entfernt einen Ordner aus der flachen Liste und zieht dessen direkte
* Unterordner auf die Elternebene (`grandParentId`). Das `kind`-Feld bleibt dabei
* ERHALTEN (Spread statt Neu-Bau), damit ein hochgezogener Master-Ordner nicht
* versehentlich zum Layout-Ordner wird. Liefert die neue Liste + `grandParentId`
* (den die Blatt-Reparentierung der Aufrufer nutzt).
*/
function foldersAfterDelete(
folderId: string,
folders: LayoutFolder[],
): { folders: LayoutFolder[]; grandParentId?: string } {
const target = folders.find((f) => f.id === folderId);
const grandParentId = target?.parentId;
const nextFolders = folders
.filter((f) => f.id !== folderId)
.map((f) =>
f.parentId === folderId
? grandParentId
? { ...f, parentId: grandParentId }
: stripParentId(f)
: f,
);
return { folders: nextFolders, grandParentId };
}
/** Ordner auf die Wurzelebene setzen (parentId entfernen, `kind` bleibt erhalten). */
function stripParentId(folder: LayoutFolder): LayoutFolder {
const next = { ...folder };
delete next.parentId;
return next;
}
// ── Baum-Umsortieren (Drag & Drop) ───────────────────────────────────────────
/**
* Ist `candidateId` gleich `rootId` oder ein Nachfahre von `rootId`? Für den
* Zyklus-Schutz beim Ordner-in-Ordner-Verschieben: ein Ordner darf NICHT in sich
* selbst oder einen seiner Nachfahren wandern (sonst löst sich der Ast vom Baum).
* Robust gegen kaputte `parentId`-Ketten (bricht bei unbekannter Referenz ab).
*/
export function isDescendant(
folders: LayoutFolder[],
candidateId: string,
rootId: string,
): boolean {
if (candidateId === rootId) return true;
const byId = new Map(folders.map((f) => [f.id, f]));
const seen = new Set<string>();
let cur = byId.get(candidateId);
while (cur && cur.parentId && !seen.has(cur.id)) {
if (cur.parentId === rootId) return true;
seen.add(cur.id);
cur = byId.get(cur.parentId);
}
return false;
}
/**
* Verschiebt ein Layout in einen anderen Ordner (immutabel). `folderId = null`
* zurück auf die Wurzelebene. No-op, wenn die Layout-Id fehlt.
*/
export function moveLayoutToFolder(
layouts: Layout[],
layoutId: string,
folderId: string | null,
): Layout[] {
return layouts.map((l) => {
if (l.id !== layoutId) return l;
if (folderId) return { ...l, folderId };
const next = { ...l };
delete next.folderId;
return next;
});
}
/**
* Verschiebt einen Ordner unter einen neuen Elternordner (immutabel).
* `parentId = null` Wurzelebene. Verhindert Zyklen: ein Ordner kann nicht in
* sich selbst oder einen seiner Nachfahren wandern (dann No-op Original zurück).
*/
export function moveFolderToFolder(
folders: LayoutFolder[],
folderId: string,
parentId: string | null,
): LayoutFolder[] {
if (parentId && isDescendant(folders, parentId, folderId)) return folders;
return folders.map((f) => {
if (f.id !== folderId) return f;
if (parentId) return { ...f, parentId };
return stripParentId(f);
});
}
/**
* Neuer Viewport (Standardgrösse/-position). `snapshotId` bindet ihn an einen
* Ausschnitt; Position/Grösse in Papier-mm setzt der Aufrufer per {@link patchViewport}.
*/
export function createViewport(
id: string,
snapshotId: string,
init?: Partial<Omit<LayoutViewport, "id" | "snapshotId">>,
): LayoutViewport {
return {
id,
snapshotId,
xMm: init?.xMm ?? 20,
yMm: init?.yMm ?? 20,
widthMm: init?.widthMm ?? 120,
heightMm: init?.heightMm ?? 90,
...(init?.scaleDenominator !== undefined
? { scaleDenominator: init.scaleDenominator }
: {}),
};
}
// ── Immutable CRUD (Viewports auf einem Layout) ──────────────────────────────
/** Fügt einen Viewport an (immutabel). */
export function addViewport(layout: Layout, viewport: LayoutViewport): Layout {
return { ...layout, viewports: [...layout.viewports, viewport] };
}
/** Entfernt einen Viewport per Id (immutabel). */
export function removeViewport(layout: Layout, viewportId: string): Layout {
return {
...layout,
viewports: layout.viewports.filter((v) => v.id !== viewportId),
};
}
/** Ändert Felder eines Viewports (immutabel). No-op, wenn die Id fehlt. */
export function patchViewport(
layout: Layout,
viewportId: string,
patch: Partial<Omit<LayoutViewport, "id">>,
): Layout {
return {
...layout,
viewports: layout.viewports.map((v) =>
v.id === viewportId ? { ...v, ...patch } : v,
),
};
}
// ── Fabrik + immutable CRUD (Annotationen auf einem Layout) ─────────────────
// Analog zu Viewports: Linie/Rechteck/Text als vom Viewport-Baum unabhängige
// Markup-Schicht in Papier-mm. `annotations ?? []` ist überall die Basis, damit
// Alt-Layouts (ohne das Feld) unverändert bleiben (additiv).
/**
* Neue Annotation mit Default-Geometrie je Art (Linie/Rechteck: sichtbare
* Mindestgrösse; Text: 3.5 mm Zeilenhöhe, wie im Attribute-Editor üblich).
* `init` überschreibt einzelne Felder der Aufrufer (Editor) übergibt i. d. R.
* die vollständige, aus dem Zeichnen gewonnene Geometrie.
*/
export function createAnnotation(
id: string,
kind: LayoutAnnotation["kind"],
init: LayoutAnnotationPatch = {},
): LayoutAnnotation {
const colorPatch = init.color !== undefined ? { color: init.color } : {};
if (kind === "line") {
return {
id,
kind: "line",
x1Mm: init.x1Mm ?? 20,
y1Mm: init.y1Mm ?? 20,
x2Mm: init.x2Mm ?? 80,
y2Mm: init.y2Mm ?? 20,
...colorPatch,
...(init.weightMm !== undefined ? { weightMm: init.weightMm } : {}),
};
}
if (kind === "rect") {
return {
id,
kind: "rect",
xMm: init.xMm ?? 20,
yMm: init.yMm ?? 20,
widthMm: init.widthMm ?? 40,
heightMm: init.heightMm ?? 30,
...colorPatch,
...(init.weightMm !== undefined ? { weightMm: init.weightMm } : {}),
};
}
return {
id,
kind: "text",
xMm: init.xMm ?? 20,
yMm: init.yMm ?? 20,
text: init.text ?? "",
heightMm: init.heightMm ?? 3.5,
...colorPatch,
};
}
/** Fügt eine Annotation an (immutabel). */
export function addAnnotation(layout: Layout, annotation: LayoutAnnotation): Layout {
return { ...layout, annotations: [...(layout.annotations ?? []), annotation] };
}
/** Entfernt eine Annotation per Id (immutabel). */
export function removeAnnotation(layout: Layout, annotationId: string): Layout {
return {
...layout,
annotations: (layout.annotations ?? []).filter((a) => a.id !== annotationId),
};
}
/**
* Ändert Felder einer Annotation (immutabel). No-op, wenn die Id fehlt. Der
* Patch ist bewusst art-unabhängig ({@link LayoutAnnotationPatch}) es werden
* nur die tatsächlich übergebenen Felder gemergt.
*/
export function patchAnnotation(
layout: Layout,
annotationId: string,
patch: LayoutAnnotationPatch,
): Layout {
return {
...layout,
annotations: (layout.annotations ?? []).map((a) =>
a.id === annotationId ? ({ ...a, ...patch } as LayoutAnnotation) : a,
),
};
}
// ── Auflösungen ──────────────────────────────────────────────────────────────
/** Findet einen Ausschnitt (View-Snapshot) im Projekt. */
export function findSnapshot(
project: Project,
snapshotId: string,
): ViewSnapshot | undefined {
return (project.viewSnapshots ?? []).find((s) => s.id === snapshotId);
}
/** Findet das Masterlayout eines Layouts (falls gebunden). */
export function findMaster(
project: Project,
layout: Layout,
): MasterLayout | undefined {
if (!layout.masterId) return undefined;
return (project.masterLayouts ?? []).find((m) => m.id === layout.masterId);
}
/**
* Effektiver Massstab-Nenner eines Viewports: sein `scaleDenominator` übersteuert
* den des gebundenen Ausschnitts. Fällt auf `fallback` zurück, wenn weder
* Viewport noch Ausschnitt einen Wert tragen (defensiv, sollte nicht auftreten).
*/
export function resolveViewportScale(
viewport: LayoutViewport,
snapshot: ViewSnapshot | undefined,
fallback = 100,
): number {
return viewport.scaleDenominator ?? snapshot?.scaleDenominator ?? fallback;
}
/** Aufgelöste Titelblock-Werte (alle Felder gefüllt) für die Darstellung. */
export interface ResolvedTitleBlock {
projectName: string;
sheetName: string;
scale: string;
date: string;
author: string;
}
/**
* Master-Vererbung der Titelblock-Werte: der Master liefert die Vorlagen-Texte;
* leere Felder werden aus Projekt/Layout mit sinnvollen Defaults gefüllt
* (Projektname `Project.name`, Blattname `Layout.name`, Datum `today`).
* Ohne Master bleibt der Titelblock trotzdem sinnvoll befüllt (nur Defaults).
* Der `scale`-Default ergibt sich aus dem ERSTEN Viewport des Layouts (falls
* vorhanden), damit ein Ein-Viewport-Blatt gleich seinen Massstab trägt.
*/
export function resolveTitleBlock(
project: Project,
layout: Layout,
master: MasterLayout | undefined,
today: string = new Date().toISOString().slice(0, 10),
): ResolvedTitleBlock {
const tb: LayoutTitleBlock = master?.titleBlock ?? {};
const firstVp = layout.viewports[0];
const firstSnap = firstVp ? findSnapshot(project, firstVp.snapshotId) : undefined;
const scaleN = firstVp
? resolveViewportScale(firstVp, firstSnap)
: undefined;
const nonEmpty = (s: string | undefined): string | undefined =>
s && s.trim() ? s.trim() : undefined;
return {
projectName: nonEmpty(tb.projectName) ?? project.name,
sheetName: nonEmpty(tb.sheetName) ?? layout.name,
scale: nonEmpty(tb.scale) ?? (scaleN ? `1:${scaleN}` : "—"),
date: nonEmpty(tb.date) ?? today,
author: nonEmpty(tb.author) ?? "",
};
}
/**
* Sichtbare Kategorie-Codes eines Ausschnitts als Set (für `generatePlan`). Aus
* der gespeicherten `layerVisibility`-Map (Code sichtbar) abgeleitet.
*/
export function visibleCodesFromSnapshot(snap: ViewSnapshot): Set<string> {
const out = new Set<string>();
for (const [code, visible] of Object.entries(snap.layerVisibility)) {
if (visible) out.add(code);
}
return out;
}
+217 -13
View File
@@ -133,6 +133,12 @@ export interface PlanViewHandle {
pxPerMeter: () => number;
/** Mitte des aktuellen Ausschnitts in Modell-Metern (z. B. für Import-Platzierung). */
viewCenterModel: () => Vec2;
/**
* Auf eine beliebige Punktmenge (Modell-Meter) einpassen, z. B. die Bounding-
* Box neu importierter Kontext-Objekte (Auto-Zoom nach Geo-/DXF-Import).
* Leere Punktmenge No-op (kein Fit, kein Crash).
*/
fitBounds: (pts: Vec2[]) => void;
}
/** Ergebnis eines Links-Klicks (Auswahl) an einer Modellposition. */
@@ -175,6 +181,17 @@ export interface PlanSelection {
* sonst `null`.
*/
roomId: string | null;
/**
* ID des getroffenen extrudierten Körpers (truck-Integration, Footprint-
* Umriss), wenn der Klick ihn traf und KEIN Element höherer Priorität
* (Wand/2D/Decke/Öffnung/Treppe); sonst `null`.
*/
extrudedSolidId: string | null;
/**
* ID der getroffenen Stütze (Column, Grundriss-Querschnitt), wenn der Klick sie
* traf und KEIN Element höherer Priorität (Öffnung/Wand); sonst `null`.
*/
columnId: string | null;
/**
* War beim Klick Shift gedrückt? Dann ERWEITERT der Aufrufer (App) die
* Auswahl um das getroffene Element (bzw. schaltet es ab/zu), statt sie zu
@@ -263,6 +280,17 @@ export interface PlanViewProps {
* Fläche JEDES dieser Räume mit einer Akzentkontur.
*/
selectedRoomIds?: string[];
/**
* Aktuell ausgewählte IDs extrudierter Körper (truck-Integration; kontrolliert
* von App). Die Ansicht markiert den Footprint-Umriss JEDES dieser Körper mit
* einer Akzentkontur.
*/
selectedExtrudedSolidIds?: string[];
/**
* Aktuell ausgewählte Stützen-IDs (Tragwerk; kontrolliert von App). Die Ansicht
* markiert den Querschnitt-Umriss JEDER dieser Stützen mit einer Akzentkontur.
*/
selectedColumnIds?: string[];
/**
* Der Stempel-Griff des (einzeln) gewählten Raums: sein Anker (Meter) + ID.
* PlanView zeichnet einen ziehbaren Griff und meldet Bewegung/Doppelklick.
@@ -273,6 +301,8 @@ export interface PlanViewProps {
onStampMove?: (roomId: string, delta: Vec2) => void;
/** Doppelklick auf einen Raum-Stempel: Editor öffnen. */
onStampEdit?: (roomId: string) => void;
/** Doppelklick auf ein Text-2D-Element: Inhalt bearbeiten (Editor öffnen). */
onEditDrawingText?: (drawingId: string) => void;
/**
* Links-Drag (Marquee): meldet die im Auswahl-Rechteck getroffenen Wände nach
* der CAD-Konvention (siehe {@link MarqueeSelection}). Optional.
@@ -348,7 +378,11 @@ export interface GripHandlers {
* wird und koinzidente Enden der übrigen gewählten Elemente mitwandern. Fehlt
* `target`, gilt das einzeln selektierte Element (Rückwärtskompatibilität).
*/
onMoveBody(delta: Vec2, target?: { wallId?: string; drawingId?: string }): void;
onMoveBody(
delta: Vec2,
target?: { wallId?: string; drawingId?: string },
mods?: ToolMods,
): void;
/**
* Eine SEITE (Kante) ziehen: verschiebt beide Vertices der Kante senkrecht
* (nur die Normal-Komponente von `raw` wirkt; siehe App). `raw` ist der rohe
@@ -374,9 +408,12 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
selectedOpeningIds,
selectedStairIds,
selectedRoomIds,
selectedExtrudedSolidIds,
selectedColumnIds,
roomStamp,
onStampMove,
onStampEdit,
onEditDrawingText,
activeTool,
toolInputActive,
toolHandlers,
@@ -638,6 +675,10 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
const v = viewRef.current;
return { x: (v.x + v.w / 2) / PX_PER_M, y: -(v.y + v.h / 2) / PX_PER_M };
},
fitBounds: (pts) => {
if (pts.length === 0) return; // leerer Import → kein Fit, kein Crash
setView(fitBoxFor(pts, toScreenRef.current));
},
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
@@ -841,7 +882,34 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
bestId = p.drawingId;
}
}
return bestId;
if (bestId) return bestId;
// Kein Linien-Treffer: Text-Elemente prüfen (eigene Bounding-Box-Logik).
return pickDrawingText(clientX, clientY);
};
// Trifft der Klick ein Text-2D-Element (kind "drawingText")? Grosszügige Modell-
// Bounding-Box um den Ankerpunkt (Text hat keine Linien-Geometrie zum Anfassen);
// Winkel wird vernachlässigt (meist waagrecht). Liefert die drawingId oder null.
const pickDrawingText = (clientX: number, clientY: number): string | null => {
const m = rawModelAt(clientX, clientY);
for (const p of plan.primitives) {
if (p.kind !== "drawingText" || !p.drawingId) continue;
const singleW = Math.max(1, p.text.length) * p.heightM * 0.6;
// Textspalte: Breite = wrapWidth, Höhe = geschätzte Zeilenzahl · Zeilenhöhe.
const w = p.wrapWidth && p.wrapWidth > 0 ? p.wrapWidth : singleW;
const lines =
p.wrapWidth && p.wrapWidth > 0 ? Math.max(1, Math.ceil(singleW / p.wrapWidth)) : 1;
const pad = p.heightM * 0.6;
const minX = p.at.x - pad;
const maxX = p.at.x + w + pad;
// Baseline am Anker; Text läuft nach unten (Zeilen) und etwas nach oben (Oberlängen).
const minY = p.at.y - lines * p.heightM * 1.25 - pad;
const maxY = p.at.y + p.heightM * 0.4 + pad;
if (m.x >= minX && m.x <= maxX && m.y >= minY && m.y <= maxY) {
return p.drawingId;
}
}
return null;
};
// Nächstgelegene Öffnung (Fenster/Tür) zum Cursor: Linien-/Bogen-Primitive mit
@@ -917,6 +985,35 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
return null;
};
// Getroffener extrudierter Körper (truck-Integration): der Footprint-Umriss
// ist ein reiner Haarlinien-Umriss (fill:"none"), aber per Punkt-in-Polygon
// trotzdem als FLÄCHE anklickbar (analog Raum, nicht nur auf der Linie).
const pickExtrudedSolid = (clientX: number, clientY: number): string | null => {
const v = clientToView(clientX, clientY, view);
const m = viewToModel(v.x, v.y);
for (let i = plan.primitives.length - 1; i >= 0; i--) {
const p = plan.primitives[i];
if (p.kind === "polygon" && p.extrudedSolidId && pointInPolygon(m, p.pts)) {
return p.extrudedSolidId;
}
}
return null;
};
// Getroffene Stütze (Tragwerk): ihr Querschnitt ist eine gefüllte Poché
// (Polygon mit columnId) — per Punkt-in-Polygon als FLÄCHE anklickbar.
const pickColumn = (clientX: number, clientY: number): string | null => {
const v = clientToView(clientX, clientY, view);
const m = viewToModel(v.x, v.y);
for (let i = plan.primitives.length - 1; i >= 0; i--) {
const p = plan.primitives[i];
if (p.kind === "polygon" && p.columnId && pointInPolygon(m, p.pts)) {
return p.columnId;
}
}
return null;
};
// Liegt der Cursor auf dem KÖRPER eines GEWÄHLTEN Elements (Wand-Poché bzw.
// 2D-Linie)? Liefert das gegriffene Element (Wand-ID ODER Drawing-ID) für das
// Parallel-Verschieben — oder null. Bei MEHRFACHAUSWAHL wird das Element UNTER
@@ -1188,7 +1285,7 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
if (md && md.pointerId === p.pointerId) {
const delta = { x: raw.x - md.last.x, y: raw.y - md.last.y };
if (delta.x !== 0 || delta.y !== 0) {
gripHandlers!.onMoveBody(delta, md.target);
gripHandlers!.onMoveBody(delta, md.target, mods);
md.last = raw;
}
return;
@@ -1402,28 +1499,46 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
const openingId = pickOpening(e.clientX, e.clientY);
const wallId =
openingId ? null : hit && hit.kind === "polygon" ? hit.wallId ?? null : null;
// Traf der Klick KEINE Wand/Öffnung: zuerst eine getroffene gefüllte 2D-Fläche
// (Polygon mit drawingId), sonst die 2D-Linien (Drawing2D) per Nähe.
// Stütze (Tragwerk): ihre Querschnitt-Poché liegt ÜBER der Wand-Poché —
// ein direkter Klick auf sie trifft zuerst die Stütze (hit.columnId), sonst
// die Fläche per Punkt-in-Polygon. Priorität direkt nach Öffnung/Wand.
const polyColumnId =
hit && hit.kind === "polygon" ? hit.columnId ?? null : null;
const columnId =
openingId || wallId
? null
: polyColumnId ?? pickColumn(e.clientX, e.clientY);
// Traf der Klick KEINE Wand/Öffnung/Stütze: zuerst eine getroffene gefüllte
// 2D-Fläche (Polygon mit drawingId), sonst die 2D-Linien (Drawing2D) per Nähe.
const polyDrawingId =
hit && hit.kind === "polygon" ? hit.drawingId ?? null : null;
const drawingId =
openingId || wallId
openingId || wallId || columnId
? null
: polyDrawingId ?? pickDrawing(e.clientX, e.clientY);
// Extrudierter Körper (truck-Integration): sein Footprint-Umriss ersetzt
// die frühere Drawing2D-Fläche, also gleiche Priorität wie drawingId.
const polyExtrudedSolidId =
hit && hit.kind === "polygon" ? hit.extrudedSolidId ?? null : null;
const extrudedSolidId =
openingId || wallId || columnId || drawingId
? null
: polyExtrudedSolidId ?? pickExtrudedSolid(e.clientX, e.clientY);
// Decke nur, wenn weder Öffnung/Wand noch 2D-Element getroffen wurde. Da die
// Decken-Polygone UNTER der Wand-Poché liegen, trifft ein direkter Klick
// auf die Wand zuerst die Wand; freie Deckenfläche trifft die Decke.
const hitCeilingId =
hit && hit.kind === "polygon" ? hit.ceilingId ?? null : null;
const ceilingId = openingId || wallId || drawingId ? null : hitCeilingId;
const ceilingId =
openingId || wallId || columnId || drawingId || extrudedSolidId ? null : hitCeilingId;
// Treppe nur, wenn nichts anderes getroffen wurde (Tritte liegen frei).
const stairId =
openingId || wallId || drawingId || ceilingId
openingId || wallId || columnId || drawingId || extrudedSolidId || ceilingId
? null
: pickStair(e.clientX, e.clientY);
// Raum als letzte Priorität: die Raum-Fläche liegt UNTER allem anderen.
const roomId =
openingId || wallId || drawingId || ceilingId || stairId
openingId || wallId || columnId || drawingId || extrudedSolidId || ceilingId || stairId
? null
: pickRoom(e.clientX, e.clientY);
onSelect({
@@ -1435,6 +1550,8 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
openingId,
stairId,
roomId,
extrudedSolidId,
columnId,
shift: e.shiftKey,
});
}
@@ -1490,6 +1607,14 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
toolHandlers!.onToolCommit();
return;
}
// Doppelklick auf ein Text-2D-Element → Inhalt bearbeiten (Vorrang vor Raum).
if (onEditDrawingText) {
const tid = pickDrawingText(e.clientX, e.clientY);
if (tid) {
onEditDrawingText(tid);
return;
}
}
// Doppelklick auf einen Raum (Stempel-Griff ODER Fläche) → Stempel-Editor.
if (onStampEdit) {
const rid =
@@ -1614,6 +1739,33 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
);
}, [plan, selectedRoomIds]);
// Hervorhebung der selektierten extrudierten Körper (truck-Integration): der
// Footprint-Umriss (Polygon mit extrudedSolidId) mit Akzentkontur überzeichnen
// — analog zur Raum-Auswahl.
const highlightExtrudedSolidPolys = useMemo(() => {
const ids =
selectedExtrudedSolidIds && selectedExtrudedSolidIds.length
? new Set(selectedExtrudedSolidIds)
: null;
if (!ids) return [];
return plan.primitives.filter(
(p): p is Extract<Primitive, { kind: "polygon" }> =>
p.kind === "polygon" && p.extrudedSolidId != null && ids.has(p.extrudedSolidId),
);
}, [plan, selectedExtrudedSolidIds]);
// Hervorhebung der selektierten Stützen (Tragwerk): der Querschnitt-Umriss
// (Polygon mit columnId) mit Akzentkontur überzeichnen — analog Extrusion/Raum.
const highlightColumnPolys = useMemo(() => {
const ids =
selectedColumnIds && selectedColumnIds.length ? new Set(selectedColumnIds) : null;
if (!ids) return [];
return plan.primitives.filter(
(p): p is Extract<Primitive, { kind: "polygon" }> =>
p.kind === "polygon" && p.columnId != null && ids.has(p.columnId),
);
}, [plan, selectedColumnIds]);
// Zusammenhängende 2D-Zeichen-Linien (gleiche drawingId + Stil) zu Zügen
// bündeln, damit sie als EIN <polyline>/<polygon> gezeichnet werden können —
// so gehren dicke Ecken sauber (kein Butt-Cap-Sprung). generatePlan schiebt die
@@ -1819,6 +1971,30 @@ export const PlanView = forwardRef<PlanViewHandle, PlanViewProps>(
pointerEvents="none"
/>
))}
{/* Auswahl-Hervorhebung der gewählten extrudierten Körper (truck-Integration). */}
{highlightExtrudedSolidPolys.map((poly, i) => (
<polygon
key={`selex-${i}`}
className="plan-selected"
points={poly.pts
.map(toScreen)
.map((s) => `${s.x},${s.y}`)
.join(" ")}
pointerEvents="none"
/>
))}
{/* Auswahl-Hervorhebung der gewählten Stützen (Tragwerk). */}
{highlightColumnPolys.map((poly, i) => (
<polygon
key={`selcol-${i}`}
className="plan-selected"
points={poly.pts
.map(toScreen)
.map((s) => `${s.x},${s.y}`)
.join(" ")}
pointerEvents="none"
/>
))}
{/* Hervorhebung der selektierten Öffnungen (Fenster/Tür-Symbole). */}
{highlightOpeningPrims.map((p, i) =>
p.kind === "line" ? (
@@ -2024,7 +2200,7 @@ function DraftOverlay({
snapColor: string;
}) {
const vertSize = 4.5 * vbPerPx; // halbe Kantenlänge eines Stützpunkt-Quadrats (klein + einheitlich)
const markSize = 4.5 * vbPerPx; // halbe Kantenlänge des Snap-Markers (gleich groß wie Stützpunkt)
const markSize = 6.5 * vbPerPx; // halbe Kantenlänge des Snap-Markers (grösser = besser sichtbar)
return (
<g className="tool-overlay" pointerEvents="none">
{/* Vorschau-Formen. */}
@@ -3081,12 +3257,32 @@ function renderPrimitive(
);
}
case "drawingText": {
// Modellverankerter Einzeltext (DXF-Import): Höhe in Metern → viewBox-
// Einheiten (skaliert mit dem Zoom), Baseline am Ankerpunkt (links).
// Modellverankerter Einzeltext (DXF-Import / Text-Werkzeug): Höhe in Metern →
// viewBox-Einheiten (skaliert mit dem Zoom), Baseline am Ankerpunkt (links).
const c = toScreen(p.at);
const fontSize = p.heightM * PX_PER_M;
// Modell-Winkel CCW; Screen-Y ist gespiegelt und SVG-rotate CW-positiv → -deg.
const deg = -(p.angle * 180) / Math.PI;
// Spaltentext: bei gesetzter `wrapWidth` den Text wortweise auf die Breite
// umbrechen (Zeichenbreite ≈ 0.55·fontSize geschätzt), sonst eine Zeile.
const lineH = fontSize * 1.25;
const lines: string[] = [];
if (p.wrapWidth && p.wrapWidth > 0) {
const maxPx = p.wrapWidth * PX_PER_M;
const charPx = fontSize * 0.55;
const maxChars = Math.max(1, Math.floor(maxPx / charPx));
for (const paragraph of p.text.split("\n")) {
let line = "";
for (const word of paragraph.split(/\s+/)) {
if (line === "") line = word;
else if ((line.length + 1 + word.length) <= maxChars) line += " " + word;
else { lines.push(line); line = word; }
}
lines.push(line);
}
} else {
lines.push(p.text);
}
return (
<text
x={c.x}
@@ -3099,7 +3295,15 @@ function renderPrimitive(
transform={deg ? `rotate(${deg} ${c.x} ${c.y})` : undefined}
style={{ pointerEvents: "none", userSelect: "none" }}
>
{p.text}
{lines.length <= 1 ? (
p.text
) : (
lines.map((ln, li) => (
<tspan key={li} x={c.x} dy={li === 0 ? 0 : lineH}>
{ln === "" ? "" : ln}
</tspan>
))
)}
</text>
);
}
+162
View File
@@ -0,0 +1,162 @@
/**
* Unit-Tests für das Stützen-Bauteil (Column) DOSSIER-Audit A4 Tragwerk:
* 2D-Grundriss (generatePlan): eine Stütze erzeugt ein Poché-Polygon mit
* columnId an der richtigen Position; die Rotation dreht den Rechteck-
* Querschnitt.
* 3D (projectToModel3d/emitColumns): eine Stütze wird zu einem Prisma-Mesh
* (Rechteck-Box bzw. Kreis-Prisma) mit der erwarteten vertikalen Ausdehnung.
* Vertikale Lage (columnVerticalExtent): Geschoss-Default sowie UK/OK-Anker.
*/
import { describe, it, expect } from "vitest";
import { generatePlan } from "./generatePlan";
import { projectToModel3d } from "./toWalls3d";
import { columnVerticalExtent } from "../model/wall";
import { columnFootprint } from "../geometry/column";
import type { Column, Project } from "../model/types";
/** Minimalprojekt (zwei Geschosse gestapelt) mit einer konfigurierbaren Stütze. */
function projectWithColumn(column: Column): Project {
return {
id: "t",
name: "T",
lineStyles: [{ id: "thin", name: "d", weight: 0.13, color: "#111", dash: null }],
hatches: [{ id: "none", name: "Ohne", pattern: "none", scale: 1, angle: 0, color: "#111" }],
components: [{ id: "a", name: "A", color: "#c8c8cc", hatchId: "none", joinPriority: 10 }],
wallTypes: [{ id: "aw", name: "AW", layers: [{ componentId: "a", thickness: 0.3 }] }],
drawingLevels: [
{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 3, cutHeight: 1.0, baseElevation: 0 },
{ id: "og", name: "OG", kind: "floor", visible: true, locked: false, floorHeight: 3, cutHeight: 1.0, baseElevation: 3 },
],
layers: [
{ code: "20", name: "Wände", color: "#0a0a0a", lw: 0.5, visible: true, locked: false },
{ code: "50", name: "Tragwerk", color: "#0a0a0a", lw: 0.5, visible: true, locked: false },
],
walls: [],
doors: [],
openings: [],
ceilings: [],
stairs: [],
rooms: [],
columns: [column],
drawings2d: [],
context: [],
};
}
const visible = new Set(["20", "50"]);
/** Basis-Stütze: Rechteck 0.4×0.2 an (2,3), Höhe 3, keine Drehung. */
const rectColumn: Column = {
id: "C1",
type: "column",
floorId: "eg",
categoryCode: "50",
position: { x: 2, y: 3 },
profile: { kind: "rect", width: 0.4, depth: 0.2 },
rotation: 0,
height: 3,
};
describe("Stütze — 2D-Grundriss (generatePlan)", () => {
it("erzeugt ein Poché-Polygon mit columnId am Rechteck-Querschnitt", () => {
const plan = generatePlan(projectWithColumn(rectColumn), "eg", visible);
const polys = plan.primitives.filter(
(p) => p.kind === "polygon" && p.columnId === "C1",
);
expect(polys.length).toBe(1);
const poly = polys[0];
if (poly.kind !== "polygon") throw new Error("kein Polygon");
// Rechteck 0.4×0.2 um (2,3): Ecken bei x∈{1.8,2.2}, y∈{2.9,3.1}.
const xs = poly.pts.map((p) => p.x).sort((a, b) => a - b);
const ys = poly.pts.map((p) => p.y).sort((a, b) => a - b);
expect(poly.pts.length).toBe(4);
expect(xs[0]).toBeCloseTo(1.8, 6);
expect(xs[3]).toBeCloseTo(2.2, 6);
expect(ys[0]).toBeCloseTo(2.9, 6);
expect(ys[3]).toBeCloseTo(3.1, 6);
});
it("dreht den Querschnitt um 90° (Breite/Tiefe vertauscht)", () => {
const rotated: Column = { ...rectColumn, rotation: Math.PI / 2 };
const plan = generatePlan(projectWithColumn(rotated), "eg", visible);
const poly = plan.primitives.find(
(p) => p.kind === "polygon" && p.columnId === "C1",
);
if (!poly || poly.kind !== "polygon") throw new Error("kein Polygon");
// Nach 90°-Drehung: X-Ausdehnung = ±depth/2 = ±0.1, Y = ±width/2 = ±0.2.
const xs = poly.pts.map((p) => p.x).sort((a, b) => a - b);
const ys = poly.pts.map((p) => p.y).sort((a, b) => a - b);
expect(xs[0]).toBeCloseTo(1.9, 6);
expect(xs[3]).toBeCloseTo(2.1, 6);
expect(ys[0]).toBeCloseTo(2.8, 6);
expect(ys[3]).toBeCloseTo(3.2, 6);
});
it("blendet die Stütze aus, wenn Kategorie 50 unsichtbar ist", () => {
const plan = generatePlan(projectWithColumn(rectColumn), "eg", new Set(["20"]));
expect(plan.primitives.some((p) => p.kind === "polygon" && p.columnId)).toBe(false);
});
});
describe("Stütze — 3D (projectToModel3d / emitColumns)", () => {
it("erzeugt eine Rechteck-Box (8 Ecken) über die volle Höhe", () => {
const model = projectToModel3d(projectWithColumn(rectColumn));
const meshes = model.meshes.filter((m) => m.kind === "extrusion");
expect(meshes.length).toBe(1);
const mesh = meshes[0];
// 4 Footprint-Punkte × (unten + oben) = 8 Vertices.
expect(mesh.positions.length).toBe(8 * 3);
// z-Werte (jedes 3. Element) liegen bei 0 (UK) und 3 (OK).
const zs = mesh.positions.filter((_, i) => i % 3 === 2);
expect(Math.min(...zs)).toBeCloseTo(0, 6);
expect(Math.max(...zs)).toBeCloseTo(3, 6);
});
it("erzeugt ein tesselliertes Kreis-Prisma (64 Ecken)", () => {
const round: Column = {
...rectColumn,
profile: { kind: "round", radius: 0.15 },
};
const model = projectToModel3d(projectWithColumn(round));
const mesh = model.meshes.find((m) => m.kind === "extrusion");
if (!mesh) throw new Error("kein Prisma-Mesh");
// 32-Eck × (unten + oben) = 64 Vertices.
expect(mesh.positions.length).toBe(64 * 3);
});
});
describe("Stütze — vertikale Lage (columnVerticalExtent)", () => {
it("nutzt Geschoss-UK + Höhe als Default", () => {
const { zBottom, zTop } = columnVerticalExtent(
projectWithColumn(rectColumn),
rectColumn,
);
expect(zBottom).toBeCloseTo(0, 6);
expect(zTop).toBeCloseTo(3, 6);
});
it("bindet UK/OK an Anker (custom + floor)", () => {
const anchored: Column = {
...rectColumn,
bottom: { mode: "custom", z: 0.5 },
top: { mode: "floor", floorId: "og" }, // baseElevation OG = 3
};
const { zBottom, zTop } = columnVerticalExtent(projectWithColumn(anchored), anchored);
expect(zBottom).toBeCloseTo(0.5, 6);
expect(zTop).toBeCloseTo(3, 6);
});
});
describe("Stütze — Footprint-Helfer (columnFootprint)", () => {
it("tesselliert ein rundes Profil als 32-Eck um die Position", () => {
const round: Column = { ...rectColumn, profile: { kind: "round", radius: 0.15 } };
const pts = columnFootprint(round);
expect(pts.length).toBe(32);
// Alle Punkte liegen im Radius 0.15 um (2,3).
for (const p of pts) {
const d = Math.hypot(p.x - 2, p.y - 3);
expect(d).toBeCloseTo(0.15, 6);
}
});
});
+181
View File
@@ -0,0 +1,181 @@
// Integrations-Tests: der Grundriss-Pfad (generatePlan) wendet die grafischen
// Override-Regeln (Project.overrideRules) als OBERSTE Schicht über der
// By-Layer/By-Object-Auflösung an — als reines Rendering-Overlay (die
// Elementdaten bleiben unverändert).
import { describe, it, expect } from "vitest";
import { generatePlan } from "./generatePlan";
import type { OverrideRule, Project, Wall } from "../model/types";
/** Minimalprojekt: eine Wand (Ebene „Wände"/20) + eine 2D-Linie (Ebene 60). */
function project(overrideRules?: OverrideRule[]): Project {
const wall: Wall = {
id: "W1",
type: "wall",
floorId: "eg",
categoryCode: "20",
start: { x: 0, y: 0 },
end: { x: 4, y: 0 },
wallTypeId: "aw",
height: 2.6,
};
return {
id: "t",
name: "T",
lineStyles: [
{ id: "dashed", name: "Gestrichelt", weight: 0.13, color: "#111", dash: [2, 1] },
],
hatches: [{ id: "none", name: "Ohne", pattern: "none", scale: 1, angle: 0, color: "#111" }],
components: [{ id: "a", name: "A", color: "#d8d2c7", hatchId: "none", joinPriority: 10 }],
wallTypes: [{ id: "aw", name: "AW Beton", layers: [{ componentId: "a", thickness: 0.2 }] }],
drawingLevels: [
{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0, baseElevation: 0 },
],
layers: [
{ code: "20", name: "Wände", color: "#0a0a0a", lw: 0.5, visible: true, locked: false },
{ code: "60", name: "Zeichnung", color: "#0a0a0a", lw: 0.25, visible: true, locked: false },
],
walls: [wall],
doors: [],
openings: [],
ceilings: [],
stairs: [],
rooms: [],
drawings2d: [
{
id: "D1",
type: "drawing2d",
levelId: "eg",
categoryCode: "60",
geom: { shape: "line", a: { x: 0, y: 1 }, b: { x: 2, y: 1 } },
},
],
context: [],
overrideRules,
};
}
const visible = new Set(["20", "60"]);
/** Alle Polygone der Wand W1 (grob = eine Sammelfläche mit Umriss). */
function wallPolys(p: ReturnType<typeof generatePlan>) {
return p.primitives.filter(
(pr): pr is Extract<typeof pr, { kind: "polygon" }> =>
pr.kind === "polygon" && pr.wallId === "W1",
);
}
/** Die Linien-Primitive des 2D-Elements D1. */
function drawingLines(p: ReturnType<typeof generatePlan>) {
return p.primitives.filter(
(pr): pr is Extract<typeof pr, { kind: "line" }> =>
pr.kind === "line" && pr.drawingId === "D1",
);
}
describe("generatePlan — grafische Overrides (Regel-Engine)", () => {
it("layer_name-Regel färbt die Wand-Umrisslinie um (Farbe-Aktion)", () => {
const rules: OverrideRule[] = [
{
id: "r1",
name: "Wände rot",
enabled: true,
condition: { type: "layer_name", operator: "equals", value: "Wände" },
actions: { color: "#ff0000" },
},
];
const plan = generatePlan(project(rules), "eg", visible, undefined, "grob");
const polys = wallPolys(plan);
expect(polys.length).toBeGreaterThan(0);
expect(polys.every((p) => p.stroke === "#ff0000")).toBe(true);
});
it("Lineweight-Regel wirkt identisch zum expliziten Attribut-Override", () => {
const rules: OverrideRule[] = [
{
id: "r1",
name: "Wände dick",
enabled: true,
condition: { type: "layer_name", operator: "equals", value: "20" },
actions: { lineweight: 0.7 },
},
];
const withRule = generatePlan(project(rules), "eg", visible, undefined, "grob");
// Referenz: dieselbe Strichstärke direkt als Element-Attribut gesetzt.
const ref = project();
ref.walls[0].strokeWeight = 0.7;
const withAttr = generatePlan(ref, "eg", visible, undefined, "grob");
expect(wallPolys(withRule).map((p) => p.strokeWidthMm)).toEqual(
wallPolys(withAttr).map((p) => p.strokeWidthMm),
);
// Und unterscheidet sich vom Plan ohne Regel (Regel wirkt tatsächlich).
const base = generatePlan(project(), "eg", visible, undefined, "grob");
expect(wallPolys(withRule)[0].strokeWidthMm).not.toBe(
wallPolys(base)[0].strokeWidthMm,
);
});
it("deaktivierte Regel wirkt nicht (Standard-Strich bleibt)", () => {
const rules: OverrideRule[] = [
{
id: "r1",
name: "Wände rot (aus)",
enabled: false,
condition: { type: "layer_name", operator: "equals", value: "Wände" },
actions: { color: "#ff0000" },
},
];
const plan = generatePlan(project(rules), "eg", visible, undefined, "grob");
expect(wallPolys(plan).some((p) => p.stroke === "#ff0000")).toBe(false);
});
it("Regel-Override gewinnt über den Per-Instanz-Farbwert (oberste Schicht)", () => {
const rules: OverrideRule[] = [
{
id: "r1",
name: "Wände rot",
enabled: true,
condition: { type: "layer_name", operator: "equals", value: "Wände" },
actions: { color: "#ff0000" },
},
];
const p = project(rules);
p.walls[0].color = "#00ff00"; // Instanz-Farbe — die Regel liegt darüber.
const plan = generatePlan(p, "eg", visible, undefined, "grob");
expect(wallPolys(plan).every((pr) => pr.stroke === "#ff0000")).toBe(true);
});
it("object_name-Regel + Linienstil-Aktion: 2D-Linie erhält das Strichmuster", () => {
const rules: OverrideRule[] = [
{
id: "r1",
name: "Linien gestrichelt",
enabled: true,
condition: { type: "object_name", operator: "equals", value: "line" },
actions: { linetypeId: "dashed", color: "#ff00ff" },
},
];
const plan = generatePlan(project(rules), "eg", visible, undefined, "mittel");
const lines = drawingLines(plan);
expect(lines.length).toBeGreaterThan(0);
expect(lines.every((l) => l.color === "#ff00ff")).toBe(true);
expect(lines.every((l) => Array.isArray(l.dash) && l.dash.length === 2)).toBe(true);
});
it("reines Rendering-Overlay: die Projektdaten bleiben unverändert", () => {
const rules: OverrideRule[] = [
{
id: "r1",
name: "Alles rot",
enabled: true,
condition: { type: "layer_name", operator: "contains", value: "" },
actions: { color: "#ff0000", lineweight: 1.0 },
},
];
const p = project(rules);
generatePlan(p, "eg", visible, undefined, "grob");
expect(p.walls[0].color).toBeUndefined();
expect(p.walls[0].strokeWeight).toBeUndefined();
expect(p.drawings2d[0].lineStyleId).toBeUndefined();
});
});
+261 -29
View File
@@ -8,11 +8,14 @@
import type {
AttributeSource,
Ceiling,
Column,
Component,
Drawing2D,
ExtrudedSolid,
HatchPattern,
LayerCategory,
Opening,
OverrideActions,
Project,
Room,
Stair,
@@ -20,6 +23,8 @@ import type {
Wall,
} from "../model/types";
import {
columnLabel,
columnsOfFloor,
flattenCategories,
getCeilingType,
getComponent,
@@ -27,11 +32,15 @@ import {
getLayerCategory,
getLineStyle,
getWallType,
openingLabel,
openingsOfWall,
roomsOfFloor,
stairLabel,
stairsOfFloor,
wallTypeThickness,
} from "../model/types";
import { columnFootprint } from "../geometry/column";
import { effectiveOverrides, hasOverrides } from "../overrides/engine";
import { evaluateRoom, siaLabel } from "../geometry/roomArea";
import type { RichTextDoc } from "../text/richText";
import { docFromText } from "../text/richText";
@@ -42,6 +51,7 @@ import { stairGeometry, stairCut, stairOutline } from "../geometry/stair";
import type { SpiralOutlineSegments } from "../geometry/stair";
import {
doorSymbol,
openingGapQuad,
openingInterval,
openingJambs,
wallAxisFrame,
@@ -224,6 +234,17 @@ export type Primitive =
stairId?: string;
/** ID des Raums (Room), falls dieses Polygon eine Raum-Fläche ist (Auswahl). */
roomId?: string;
/**
* ID des extrudierten Körpers (truck-Integration), falls dieses Polygon der
* Grundriss-Footprint einer Extrusion ist (für eine spätere Links-Klick-
* Auswahl heute nur zur Nachvollziehbarkeit getaggt, noch nicht verdrahtet).
*/
extrudedSolidId?: string;
/**
* ID der Stütze (Column), falls dieses Polygon der Grundriss-Querschnitt
* (Poché) einer Stütze ist (für die Links-Klick-Auswahl der Fläche).
*/
columnId?: string;
}
| {
kind: "line";
@@ -312,6 +333,12 @@ export type Primitive =
/** Drehung in RADIANT (CCW im Modell). */
angle: number;
color: string;
/**
* Optionale Spaltenbreite in Modell-Metern (Textspalte): ist sie gesetzt,
* bricht der Renderer den Text wortweise auf diese Breite um. Fehlt sie,
* bleibt es einzeiliger Text.
*/
wrapWidth?: number;
/** ID des 2D-Zeichenelements (für Links-Klick-Auswahl). */
drawingId?: string;
greyed?: boolean;
@@ -597,15 +624,6 @@ export function generatePlan(
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(
(w) =>
w.floorId === floorId &&
visibleCodes.has(w.categoryCode) &&
categoryDisplay(w.categoryCode).render,
);
// Code → Kategorie-Strichstärke (mm), damit die Wand-Umrisslinie aus dem
// Modell (Ebene) stammt statt aus einer Magie-Konstante.
const lwByCode = categoryLwMap(project.layers);
@@ -614,26 +632,92 @@ export function generatePlan(
// "layer" von Vordergrund/Hintergrund/Strichstärke/Schraffur).
const catByCode = categoryMap(project.layers);
// ── Grafische Overrides (Regel-Engine, src/overrides/engine.ts) ───────────
// Effektive Aktionen je Element als OBERSTE Schicht ÜBER der regulären
// By-Layer/By-Object-Auflösung: die Elemente werden hier — rein zur
// Renderzeit — mit den Regel-Ergebnissen dekoriert (flache Kopien; das
// Projekt bleibt unverändert, jederzeit reversibel). `layer_name` matcht
// Ebenen-Name ODER -Code, `object_name` den Element-/Typ-Namen.
const overrideFor = (
categoryCode: string,
objectName?: string,
): OverrideActions =>
effectiveOverrides(project.overrideRules, {
layerName: catByCode.get(categoryCode)?.name,
layerCode: categoryCode,
objectName,
});
const wallTypeNameById = new Map(project.wallTypes.map((t) => [t.id, t.name]));
const ceilingTypeNameById = new Map(
(project.ceilingTypes ?? []).map((t) => [t.id, t.name]),
);
// Sichtbarkeit wie bisher; der Darstellungsmodus verfeinert sie zusätzlich:
// render=false blendet die Wand aus, greyed=true dimmt sie.
const walls = project.walls
.filter(
(w) =>
w.floorId === floorId &&
visibleCodes.has(w.categoryCode) &&
categoryDisplay(w.categoryCode).render,
)
.map((w) => {
// Regel-Override gewinnt über den Per-Instanz-Wert (oberste Schicht):
// Farbe → Umriss-/Fugenfarbe (Wall.color), Strichstärke → strokeWeight
// (der topmost-Wert der bestehenden resolveStrokeWeight-Kette).
const ov = overrideFor(w.categoryCode, wallTypeNameById.get(w.wallTypeId));
if (!hasOverrides(ov)) return w;
return {
...w,
color: ov.color ?? w.color,
strokeWeight: ov.lineweight ?? w.strokeWeight,
};
});
// Freie 2D-Zeichengeometrie dieses Geschosses (wie Wände nach sichtbaren
// Kategorien + Darstellungsmodus gefiltert). Wird über der Wand-Poché
// gezeichnet (am Ende angehängt).
const drawings = project.drawings2d.filter(
(d) =>
d.levelId === floorId &&
visibleCodes.has(d.categoryCode) &&
categoryDisplay(d.categoryCode).render,
);
const drawings = project.drawings2d
.filter(
(d) =>
d.levelId === floorId &&
visibleCodes.has(d.categoryCode) &&
categoryDisplay(d.categoryCode).render,
)
.map((d) => {
// `object_name` matcht bei freier 2D-Geometrie den Formnamen
// (line/polyline/rect/circle/arc/text). Linienstil-Override nur hier —
// Drawing2D ist das einzige Element mit LineStyle-Referenz.
const ov = overrideFor(d.categoryCode, d.geom.shape);
if (!hasOverrides(ov)) return d;
return {
...d,
color: ov.color ?? d.color,
weightMm: ov.lineweight ?? d.weightMm,
lineStyleId: ov.linetypeId ?? d.lineStyleId,
};
});
// 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,
);
const ceilings = (project.ceilings ?? [])
.filter(
(c) =>
c.floorId === floorId &&
visibleCodes.has(c.categoryCode) &&
categoryDisplay(c.categoryCode).render,
)
.map((c) => {
// Decken: nur der Farb-Override wirkt (der Umriss ist eine feste
// Überkopf-Haarlinie, siehe addCeilingPoche — keine Strichstärke).
const typeName =
(c.ceilingTypeId ? ceilingTypeNameById.get(c.ceilingTypeId) : undefined) ??
wallTypeNameById.get(c.wallTypeId);
const ov = overrideFor(c.categoryCode, typeName);
return ov.color !== undefined ? { ...c, color: ov.color } : c;
});
// Wand-Footprints für das Verdecken der (darunterliegenden) Decken-Umrisse:
// die Decke liegt als Slab über der Schnittebene, ihre Umrisslinie soll dort,
// wo eine Wand darüber steht, NICHT durch die Wand-Poché schlagen. Nur nötig,
@@ -650,10 +734,17 @@ export function generatePlan(
// 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,
);
const rooms = roomsOfFloor(project, floorId)
.filter(
(r) =>
visibleCodes.has(r.categoryCode) && categoryDisplay(r.categoryCode).render,
)
.map((r) => {
// Räume: `object_name` matcht den Raum-Namen; der Farb-Override färbt
// Fläche + Stempel (Room.color) um.
const ov = overrideFor(r.categoryCode, r.name);
return ov.color !== undefined ? { ...r, color: ov.color } : r;
});
for (const room of rooms) {
const greyed = categoryDisplay(room.categoryCode).greyed;
addRoomArea(primitives, room, greyed, detail);
@@ -701,8 +792,13 @@ export function generatePlan(
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);
// Override-Regeln: `object_name` matcht das Öffnungs-Label
// („Tür 90×210" / „Fenster 120×140").
const ov = overrideFor(o.categoryCode, openingLabel(o));
const oLwMm =
ov.lineweight ?? lwByCode.get(o.categoryCode) ?? LAYER_LINE_MM;
const effO = ov.color !== undefined ? { ...o, color: ov.color } : o;
addOpeningSymbol(primitives, project, wall, effO, d.greyed, detail, oLwMm);
}
}
}
@@ -717,8 +813,28 @@ export function generatePlan(
);
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);
// Override-Regeln: `object_name` matcht das Treppen-Label („Gerade 16 STG").
const ov = overrideFor(stair.categoryCode, stairLabel(stair));
const lwMm =
ov.lineweight ?? lwByCode.get(stair.categoryCode) ?? WALL_FALLBACK_MM;
const effStair = ov.color !== undefined ? { ...stair, color: ov.color } : stair;
addStairSymbol(primitives, project, effStair, greyed, lwMm, cutHeight);
}
// Stützen (Tragwerk) dieses Geschosses: Querschnitt-Poché (Rechteck/Kreis),
// um `rotation` gedreht, auf Kategorie „50". Über der Wand-Poché gezeichnet.
const columns = columnsOfFloor(project, floorId).filter(
(col) =>
visibleCodes.has(col.categoryCode) && categoryDisplay(col.categoryCode).render,
);
for (const column of columns) {
const greyed = categoryDisplay(column.categoryCode).greyed;
const category = catByCode.get(column.categoryCode);
const ov = overrideFor(column.categoryCode, columnLabel(column));
const lwMm =
ov.lineweight ?? resolveStrokeWeight(undefined, undefined, category, lwByCode.get(column.categoryCode) ?? WALL_FALLBACK_MM);
const effCol = ov.color !== undefined ? { ...column, color: ov.color } : column;
addColumnPoche(primitives, project, effCol, greyed, lwMm, category);
}
// 2D-Zeichengeometrie zuletzt (über der Poché).
@@ -727,6 +843,14 @@ export function generatePlan(
addDrawing2D(primitives, project, d, greyed, lwByCode, colorByCode, catByCode);
}
// Extrudierte Körper (truck-Integration): das Quell-Profil wurde beim
// Extrudieren aus `drawings2d` entfernt (s. `extrude.ts` appendExtrudedSolid)
// — im Grundriss erscheint stattdessen der Footprint-Umriss des Körpers
// selbst (Haarlinie, kein Poché/Schraffur; Farbe wie das 3D-Orange).
for (const solid of project.extrudedSolids ?? []) {
if (solid.levelId === floorId) addExtrudedSolidOutline(primitives, solid);
}
// Kontext-Konturen (Höhenlinien) als dezente Linien zeichnen — UNTER der
// Wand-Poché wäre ideal, aber da sie eine eigene gedämpfte Farbe tragen, ist
// die Reihenfolge unkritisch; wir hängen sie als Referenz an.
@@ -956,6 +1080,79 @@ function categoryMap(layers: LayerCategory[]): Map<string, LayerCategory> {
return map;
}
/** Footprint-Farbe der truck-Extrusionen im Grundriss (identisch zum 3D-Orange EXTRUSION_RGB in toWalls3d.ts). */
const EXTRUSION_STROKE = "#d98c40";
/**
* Footprint-Umriss eines extrudierten Körpers (truck-Integration) im Grundriss:
* reine Haarlinie ohne Füllung/Schraffur analog zur Ansichtslinie einer Wand
* unter der Schnitthöhe ({@link addWallPoche} `viewOnly`), da der Körper (noch)
* keine Schnitt-Poché/Kategorie-Zuordnung hat.
*/
function addExtrudedSolidOutline(out: Primitive[], solid: ExtrudedSolid): void {
out.push({
kind: "polygon",
pts: solid.points,
fill: "none",
stroke: EXTRUSION_STROKE,
strokeWidthMm: SYMBOL_HAIRLINE_MM,
hatch: NO_HATCH,
extrudedSolidId: solid.id,
});
}
/** Fallback-Poché-Füllung einer Stütze ohne (auflösbares) Bauteil: neutrale Tinte. */
const COLUMN_FALLBACK_FILL = "#3a3f47";
/**
* Grundriss-Querschnitt einer Stütze (Column) als Schnitt-Poché: gefülltes
* Polygon (Rechteck bzw. tesselliertes Kreisprofil), an `position` platziert und
* um `rotation` gedreht. Trägt sofern ein Bauteil (`componentId`) auflösbar ist
* dessen Füllung + Schraffur (dieselbe Auflösung wie Wand/Decke, {@link
* resolveWallSectionStyle}); sonst eine neutrale Vollton-Poché. `columnId` macht
* die Fläche links-klick-selektierbar (analog Extrusion/Raum).
*/
function addColumnPoche(
out: Primitive[],
project: Project,
column: Column,
greyed: boolean,
lwMm: number,
category: LayerCategory | undefined,
): void {
const pts = columnFootprint(column);
const stroke = column.color ?? category?.color ?? MONO_INK;
let fill = column.color ?? category?.color ?? COLUMN_FALLBACK_FILL;
let hatch: HatchRender = NO_HATCH;
if (column.componentId) {
try {
const comp = getComponent(project, column.componentId);
const hatchId =
resolveHatchId(undefined, undefined, category, comp.hatchId) ?? comp.hatchId;
const resolved = resolveHatch(
project,
hatchId,
undefined,
resolveForeground(comp, undefined, category, undefined),
);
hatch = resolved;
fill = resolved.pattern === "solid" ? HATCH_INK : HATCH_PAPER;
} catch {
// Bauteil nicht auflösbar → neutrale Poché (Fallback bleibt bestehen).
}
}
out.push({
kind: "polygon",
pts,
fill,
stroke,
strokeWidthMm: lwMm,
hatch,
greyed,
columnId: column.id,
});
}
/**
* Leitet ein 2D-Zeichenelement in Plan-Primitive ab (docs/design/drawing-tools.md
* §7.2). Farbe/Strichstärke kommen aus dem optionalen LineStyle, sonst aus der
@@ -1091,6 +1288,7 @@ function addDrawing2D(
heightM: g.height,
angle: g.angle,
color,
...(g.width && g.width > 0 ? { wrapWidth: g.width } : {}),
greyed,
drawingId: d.id,
});
@@ -1691,6 +1889,24 @@ function addOpeningSymbol(
if (o.kind === "door") {
const sym = doorSymbol(wall, o);
if (!sym) return;
// Unsichtbares Pick-Polygon über der Öffnungslücke (Laibung × Wanddicke) —
// damit die Tür wie ein Fenster per Punkt-in-Polygon über die ganze
// Öffnungsfläche selektierbar ist (Türsymbol besteht sonst nur aus dünnem
// Blatt + Bogen, die man exakt treffen müsste). fill/stroke "none" ⇒ zeichnet
// nichts; `pickOpening` (PlanView) testet nur die `pts`.
const gap = openingGapQuad(project, wall, o);
if (gap) {
out.push({
kind: "polygon",
pts: gap,
fill: "none",
stroke: "none",
strokeWidthMm: 0,
hatch: NO_HATCH,
greyed,
openingId: o.id,
});
}
// Tür-Typ „wandoeffnung": reiner Wanddurchbruch — KEIN Türblatt, KEIN
// Schwenkbogen. Nur die Öffnung/Laibung bleibt (die Wandlücke kommt aus der
// ausgesparten Poché; Sturz-/Anschlaglinien werden weiter unten gezeichnet).
@@ -1795,6 +2011,22 @@ function addOpeningSymbol(
const glassCount = detail === "fein" ? 2 : 1;
const sym = windowSymbol(project, wall, o, glassCount);
if (!sym) return;
// Unsichtbares Pick-Polygon über der Öffnungslücke — macht das Fenster über
// die ganze Fläche selektierbar, auch bei „grob" (nur eine Glaslinie, sonst
// schwer zu treffen). Siehe Tür-Zweig oben.
const wgap = openingGapQuad(project, wall, o);
if (wgap) {
out.push({
kind: "polygon",
pts: wgap,
fill: "none",
stroke: "none",
strokeWidthMm: 0,
hatch: NO_HATCH,
greyed,
openingId: o.id,
});
}
if (detail !== "grob") {
// Rahmen-Rechteck als reine Umrisslinie (Poché-Stroke, keine Füllung).
out.push({
+14 -1
View File
@@ -12,7 +12,7 @@
import type { Plan } from "./generatePlan";
import type { Project } from "../model/types";
import { planToRenderScene } from "./toRenderScene";
import { projectToModel3d } from "./toWalls3d";
import { projectToModel3d, TRUCK_MESH_READY_EVENT } from "./toWalls3d";
/** Sammel-Fenster: Modelländerungen werden gebündelt gepusht (nicht je Frame). */
const DEBOUNCE_MS = 200;
@@ -56,8 +56,21 @@ export function pushNativeScene(plan: Plan): void {
*/
export function pushNativeWalls(project: Project): void {
if (!isTauri()) return;
lastProject = project;
clearTimeout(wallsTimer);
wallsTimer = setTimeout(() => {
void invokeNative("push_native_walls", { walls: projectToModel3d(project) });
}, DEBOUNCE_MS);
}
/** Letztes an {@link pushNativeWalls} übergebenes Projekt (für den Re-Push unten). */
let lastProject: Project | null = null;
// truck-Extrusionen (s. toWalls3d.ts emitExtrudedSolids) laden WASM asynchron;
// ohne diesen Re-Push nach Abschluss bliebe eine fertige Extrusion im nativen
// Fenster unsichtbar, weil `pushNativeWalls` nur bei Projektänderungen läuft.
if (typeof window !== "undefined") {
window.addEventListener(TRUCK_MESH_READY_EVENT, () => {
if (lastProject) pushNativeWalls(lastProject);
});
}
+561 -72
View File
@@ -9,6 +9,7 @@ import { describe, it, expect } from "vitest";
import type { Project } from "../model/types";
import { sampleProject } from "../model/sampleProject";
import { selectionHighlightLines, projectToWalls3d, projectToModel3d } from "./toWalls3d";
import { resolveHatch } from "./generatePlan";
const RGB: [number, number, number] = [1, 0.5, 0.1];
const FLOATS_PER_VERTEX = 6; // [px,py,pz, r,g,b]
@@ -25,14 +26,19 @@ describe("selectionHighlightLines", () => {
expect(lines.length).toBe(0);
});
it("eine Wand ohne Öffnungen -> je Materiallage ein Quader (4·12 Kanten)", () => {
it("eine Wand ohne Öffnungen -> je Materiallage ein Quader, Kern-Band unter der Decke gespalten (5·12 Kanten)", () => {
// W2 (EG, Ost) trägt keine Tür/kein Fenster im Sample-Projekt (nur W1/W3
// haben Öffnungen, s. sampleProject.ts) -> ein Achsen-Teilquader. Wandtyp
// "aw" hat 4 Schichten (render-ext/insulation/brick/render-int), die
// TS-seitig je als eigene Box gestapelt werden (Option B) -> 4 Quader =
// 4·12 Kanten. (Früher: 1 Vollkörper = 12 Kanten.)
// TS-seitig je als eigene Box gestapelt werden (Option B).
// NEU (per-Schicht-Decken-Dominanz): W2 steht unter der Geschossdecke C1
// (dg-massiv: screed 30 / insulation 20 / concrete 100). Der Backstein (50)
// wird NUR von der Beton-Schicht (100) im z-Band [2.3,2.5] gekappt und läuft
// darüber (bis Wandkopf 2.6) durch -> ZWEI Backstein-Boxen [0,2.3] + [2.5,2.6].
// Die anderen Bänder bleiben je 1 Box (Aussenputz/Dämmung aussen: ungeschnitten;
// Innenputz 10 < alle Deckenschichten: bis 2.3). Also 5 Quader = 5·12 Kanten.
const lines = selectionHighlightLines(sampleProject, ["W2"], [], RGB);
expect(lines.length).toBe(4 * 12 * VERTICES_PER_EDGE * FLOATS_PER_VERTEX);
expect(lines.length).toBe(5 * 12 * VERTICES_PER_EDGE * FLOATS_PER_VERTEX);
});
it("eine Wand mit Farbwerten in jedem Vertex", () => {
@@ -43,11 +49,14 @@ describe("selectionHighlightLines", () => {
expect(lines[5]).toBeCloseTo(RGB[2], 6);
});
it("eine Decke (Slab) -> 3·n Kanten (n = Umriss-Eckenzahl)", () => {
// C1 hat einen 4-eckigen Umriss (5x4m Rechteck) -> 3*4 = 12 Kanten.
it("eine Decke (Slab) -> je Schicht 3·n Kanten (geschichteter Bodenaufbau)", () => {
// C1 hat einen 4-eckigen Umriss (5x4m Rechteck) -> 3*4 = 12 Kanten JE SCHICHT.
// Deckentyp "dg-massiv" hat 3 Schichten (Estrich/Dämmung/Beton) -> je ein
// eigener Slab, damit der 3D-Schnitt den Aufbau geschichtet zeigt (wie 2D).
const lines = selectionHighlightLines(sampleProject, [], ["C1"], RGB);
const n = 4;
expect(lines.length).toBe(3 * n * VERTICES_PER_EDGE * FLOATS_PER_VERTEX);
const layers = 3; // dg-massiv: screed + insulation + concrete
expect(lines.length).toBe(layers * 3 * n * VERTICES_PER_EDGE * FLOATS_PER_VERTEX);
});
it("Mehrfachauswahl (Wand + Decke) -> Summe der Einzelgeometrien", () => {
@@ -63,16 +72,21 @@ describe("selectionHighlightLines", () => {
expect(two.length).toBe(one.length * 2);
});
it("eine Wand MIT Öffnungen -> EIN Körper je Schicht-Band mit Löchern (kein Segment-Vielfaches mehr)", () => {
// NEUES Verhalten (3D-Pfad, layered=true): W1 (Tür D1 + Fenster O1) wird
// NICHT mehr in Pfeiler/Brüstung/Sturz zerlegt, sondern als EIN Körper pro
// Schicht-Band mit echten Löchern emittiert. Das Highlight umrandet daher
// genau die Schicht-Bänder (Wandtyp "aw" = 4 Schichten) — exakt so viele
// Kanten wie eine öffnungslose Wand desselben Typs (W2), NICHT ein Vielfaches
// durch Segmente. (Die Löcher selbst fügen im Highlight keine Kanten hinzu.)
const w1 = selectionHighlightLines(sampleProject, ["W1"], [], RGB);
const w2 = selectionHighlightLines(sampleProject, ["W2"], [], RGB);
expect(w1.length).toBe(4 * 12 * VERTICES_PER_EDGE * FLOATS_PER_VERTEX);
it("Öffnungen allein multiplizieren die Bänder NICHT (ein Körper je Schicht-Band mit Löchern)", () => {
// 3D-Pfad (layered=true): eine Wand mit Öffnungen wird NICHT in Pfeiler/
// Brüstung/Sturz zerlegt, sondern als EIN Körper pro Schicht-Band mit echten
// Löchern emittiert — die Löcher fügen im Highlight keine Kanten hinzu.
// Isoliert von Verschneidungen getestet: OHNE die Querwand W9 (deren T-Stoss
// sonst die innerste Putzschicht von W1 span-cutout-splittet, s. eigener
// Test unten) hat W1 (Tür D1 + Fenster O1) dieselbe Bänder-Zahl wie die
// öffnungslose Wand W2 desselben Typs "aw" — die Löcher fügen keine Körper
// hinzu. Beide stehen unter der Geschossdecke C1, deren Beton-Schicht (100)
// den Backstein (50) im z-Band [2.3,2.5] spaltet -> 5 Körper (per-Schicht-
// Decken-Dominanz, s. Backstein-Boxen [0,2.3] + [2.5,2.6]).
const noW9: Project = { ...sampleProject, walls: sampleProject.walls.filter((w) => w.id !== "W9") };
const w1 = selectionHighlightLines(noW9, ["W1"], [], RGB);
const w2 = selectionHighlightLines(noW9, ["W2"], [], RGB);
expect(w1.length).toBe(5 * 12 * VERTICES_PER_EDGE * FLOATS_PER_VERTEX);
expect(w1.length).toBe(w2.length);
});
});
@@ -114,23 +128,37 @@ describe("Öffnungen als echte Löcher in EINEM Körper (3D-Pfad, layeredWalls:t
],
};
it("layered=true: 1 RWall je Schicht-Band mit BEIDEN Fenstern als holes", () => {
it("layered=true: BEIDE Fenster als holes je Band, Loch-Positionen bleiben nach Join-Rebase korrekt", () => {
const w1 = projectToWalls3d(twoOffsetWindows, true).filter((w) => w.wallId === "W1");
// Genau ein Körper je Schicht-Band (aw = 4 Schichten), KEINE Segment-Boxen.
expect(w1.length).toBe(4);
for (const body of w1) {
// Jeder Band-Körper trägt BEIDE Löcher (in EINEM Körper).
// W1 ist Durchgangswand für die Querwand W9 (T-Stoss): die innerste Putz-
// schicht wird durch den Span-Cutout entlang der Achse in ZWEI Teilstücke
// gebrochen. NEU (per-Schicht-Decken-Dominanz): W1 steht unter der Decke C1,
// deren Beton (100) den Backstein (50) im z-Band [2.3,2.5] spaltet -> der
// Backstein kommt als ZWEI Boxen [0,2.3] + [2.5,2.6]. Die obere Backstein-Box
// liegt ÜBER beiden Fenstern (z ≤ 2.1) und trägt daher KEINE Löcher.
// Körper gesamt: render-ext(1) + insulation(1) + brick[0,2.3](1) +
// brick[2.5,2.6](1, ohne Loch) + render-int-Span(2) = 6.
expect(w1.length).toBe(6);
// Nur die Körper, die die Fenster-z-Bänder schneiden, tragen Löcher (5 Stück);
// die obere Backstein-Box (über den Fenstern) hat keine.
const withHoles = w1.filter((b) => b.holes && b.holes.length > 0);
expect(withHoles.length).toBe(5);
// W1-Achse: (0,0)->(5,0), u=(1,0) -> Box-Achsenstart = box.start[0]. Die Loch-
// Achsenkoordinaten sind je Körper auf DESSEN Box-Start rebased; die ABSOLUTE
// Position (Box-Start + from) muss wieder exakt auf den Öffnungs-Intervallen liegen.
for (const body of withHoles) {
expect(body.holes).toBeDefined();
expect(body.holes!.length).toBe(2);
const axis0 = body.start[0];
const byFrom = [...body.holes!].sort((a, b) => a.from - b.from);
// Fenster A: u=[1,2], z=[0.9,2.1].
expect(byFrom[0].from).toBeCloseTo(1.0, 6);
expect(byFrom[0].to).toBeCloseTo(2.0, 6);
// Fenster A: u=[1,2], z=[0.9,2.1] (absolut rekonstruiert).
expect(axis0 + byFrom[0].from).toBeCloseTo(1.0, 6);
expect(axis0 + byFrom[0].to).toBeCloseTo(2.0, 6);
expect(byFrom[0].zBottom).toBeCloseTo(0.9, 6);
expect(byFrom[0].zTop).toBeCloseTo(2.1, 6);
// Fenster B: u=[1.5,2.5], z=[0.3,0.7] — überlappt A in u, liegt tiefer.
expect(byFrom[1].from).toBeCloseTo(1.5, 6);
expect(byFrom[1].to).toBeCloseTo(2.5, 6);
expect(axis0 + byFrom[1].from).toBeCloseTo(1.5, 6);
expect(axis0 + byFrom[1].to).toBeCloseTo(2.5, 6);
expect(byFrom[1].zBottom).toBeCloseTo(0.3, 6);
expect(byFrom[1].zTop).toBeCloseTo(0.7, 6);
}
@@ -149,7 +177,12 @@ describe("Öffnungen als echte Löcher in EINEM Körper (3D-Pfad, layeredWalls:t
// zBottom des Lochs == Wand-UK (0), NICHT ab einer Brüstung.
const p: Project = { ...sampleProject, openings: [] };
const w1 = projectToWalls3d(p, true).filter((w) => w.wallId === "W1");
expect(w1.length).toBe(4);
// W1 ist Durchgangswand für W9 (T-Stoss) -> innerste Putzschicht span-cutout-
// gebrochen. Zusätzlich spaltet die Decke C1 (Beton 100) den Backstein (50) im
// z-Band [2.3,2.5] -> obere Backstein-Box [2.5,2.6] ohne Loch. Körper: render-ext
// + insulation + brick[0,2.3] + brick[2.5,2.6] + render-int-Span(2) = 6.
expect(w1.length).toBe(6);
// Der erste Körper (Aussenputz-Band, volle Achse) trägt die Tür als Loch bis UK.
const door = w1[0].holes?.find((h) => h.zBottom < 1e-6);
expect(door).toBeDefined();
expect(door!.zBottom).toBeCloseTo(0, 6); // Loch bis zur Wand-UK.
@@ -161,10 +194,13 @@ describe("Bauteil-/Materialfarbe statt Einheits-Grau (Wände + Decken)", () => {
// Wandtyp "aw" (s. sampleProject) hat 4 Schichten mit eigenen Farben:
// render-ext 0.02 #d8d2c7, insulation 0.16 #ffffff,
// brick 0.15 #8c5544, render-int 0.015 #efece6 (Σ = 0.345 m).
// W2 ist öffnungslos -> genau ein Achsen-Teilquader, also 4 Schicht-Boxen.
// W2 ist öffnungslos, steht aber unter der Geschossdecke C1: die Beton-Schicht
// (100) spaltet den Backstein (50) im z-Band [2.3,2.5] -> der Backstein kommt
// als ZWEI Boxen [0,2.3] + [2.5,2.6] (per-Schicht-Decken-Dominanz), die drei
// übrigen Bänder je 1 Box = 5 Boxen. Die Farbzuordnung je Schicht bleibt.
const walls = projectToWalls3d(sampleProject);
const w2 = walls.filter((w) => w.wallId === "W2");
expect(w2.length).toBe(4);
expect(w2.length).toBe(5);
// Alle Schicht-Boxen tragen die gemeinsame wallId (Klick selektiert ganze Wand)
// und sind Vollkörper (layers leer -> render3d zeichnet in `color`).
@@ -173,14 +209,18 @@ describe("Bauteil-/Materialfarbe statt Einheits-Grau (Wände + Decken)", () => {
expect(seg.layers.length).toBe(0);
}
// Die vier Materialfarben kommen genau einmal vor.
// Die vier Materialfarben kommen vor; der Backstein (#8c5544) zweimal (gespalten).
const hex = (c: [number, number, number]) =>
c.map((v) => Math.round(v * 255).toString(16).padStart(2, "0")).join("");
const colors = w2.map((s) => hex(s.color)).sort();
expect(colors).toEqual(["8c5544", "d8d2c7", "efece6", "ffffff"]);
expect(colors).toEqual(["8c5544", "8c5544", "d8d2c7", "efece6", "ffffff"]);
expect([...new Set(colors)].sort()).toEqual(["8c5544", "d8d2c7", "efece6", "ffffff"]);
// Die Schichtdicken summieren zur Gesamt-Wanddicke (keine Lücken/Overlaps).
const totalT = w2.reduce((s, seg) => s + seg.thickness, 0);
// Die Schichtdicken (je EINDEUTIGEM Querversatz, damit die gespaltene Backstein-
// Box nur EINMAL zählt) summieren zur Gesamt-Wanddicke (keine Lücken/Overlaps).
const byOffset = new Map<number, number>();
for (const seg of w2) byOffset.set(Math.round((seg.start[0] - 5) * 1e6) / 1e6, seg.thickness);
const totalT = [...byOffset.values()].reduce((s, t) => s + t, 0);
expect(totalT).toBeCloseTo(0.345, 6);
});
@@ -189,9 +229,14 @@ describe("Bauteil-/Materialfarbe statt Einheits-Grau (Wände + Decken)", () => {
// wirkt rein auf x (Box-Start.x = 5 + offset). Sortiert man die Boxen nach
// Versatz, müssen sich benachbarte Flächen exakt berühren und der Stapel
// symmetrisch um die Achse liegen: min = -T/2, max = +T/2.
const w2 = projectToWalls3d(sampleProject).filter((w) => w.wallId === "W2");
const bands = w2
.map((s) => ({ offset: s.start[0] - 5, t: s.thickness }))
// Nach EINDEUTIGEM Querversatz zusammenfassen: der unter der Decke gespaltene
// Backstein ([0,2.3] + [2.5,2.6]) liegt zweimal am SELBEN Versatz vor und darf
// die Dicken-Bilanz nicht doppelt zählen (per-Schicht-Decken-Dominanz).
const w2raw = projectToWalls3d(sampleProject).filter((w) => w.wallId === "W2");
const byOffset = new Map<number, number>();
for (const s of w2raw) byOffset.set(Math.round((s.start[0] - 5) * 1e6) / 1e6, s.thickness);
const bands = [...byOffset.entries()]
.map(([offset, t]) => ({ offset, t }))
.sort((a, b) => a.offset - b.offset);
const T = bands.reduce((s, b) => s + b.t, 0);
expect(bands[0].offset - bands[0].t / 2).toBeCloseTo(-T / 2, 6);
@@ -216,9 +261,14 @@ describe("Fenster-Glasscheiben (emitOpeningGlass)", () => {
context: [],
};
const { meshes } = projectToModel3d(p);
// `meshes` enthält jetzt auch die glatte Betontreppen-Laufplatte (ST1, Mesh);
// die Glasscheiben tragen die bläuliche Glasfarbe → danach filtern.
const glassMeshes = meshes.filter(
(m) => m.color[0] === 0.62 && m.color[1] === 0.76 && m.color[2] === 0.85,
);
// Genau EINE Scheibe: das Fenster O1; die Tür D1 bleibt offen (keine Scheibe).
expect(meshes.length).toBe(1);
const glass = meshes[0];
expect(glassMeshes.length).toBe(1);
const glass = glassMeshes[0];
// Quader: 8 Ecken (24 Floats) + 12 Dreiecke (36 Indizes), leicht bläuliches Glas.
expect(glass.positions.length).toBe(24);
expect(glass.indices.length).toBe(36);
@@ -234,32 +284,73 @@ describe("Fenster-Glasscheiben (emitOpeningGlass)", () => {
it("Türen bekommen keine Scheibe; je Fenster genau eine", () => {
// Volles Sample-Projekt: 2 Fenster (O1, O2) + 1 Tür (D1), Kontext leer.
// Neben den Glasscheiben liegt jetzt auch das Betontreppen-Mesh in `meshes`
// → nach Glasfarbe filtern: genau 2 Scheiben (O1, O2), Tür ohne Scheibe.
const { meshes } = projectToModel3d(sampleProject);
expect(meshes.length).toBe(2);
const glassMeshes = meshes.filter(
(m) => m.color[0] === 0.62 && m.color[1] === 0.76 && m.color[2] === 0.85,
);
expect(glassMeshes.length).toBe(2);
});
});
describe("Prioritäts-Trim Wand/Decke (Z-Fighting-Fix, s. trimWallTopForCeilings)", () => {
// Sample-Projekt: W1-W4 (EG, Wandtyp "aw", dominante Schicht "brick"
// joinPriority 50) tragen exakt Wandhöhe 2.6 m = Geschosshöhe -> zTop 2.6.
// Decke C1 (EG, Deckentyp "dg-massiv", dominante Schicht "concrete"
// joinPriority 100) hat OK bündig mit dem Wandkopf (zTop 2.6) und
// Gesamtdicke 0.3 m (screed .06 + insulation .04 + concrete .2) -> zBottom
// 2.3. Vor dem Trim überlappten sich Wand- und Deckenvolumen exakt über
// [2.3..2.6] im ganzen Wand-Footprint (Z-Fighting). Nach dem Trim endet die
// Wand an der Deckenunterkante, weil die Decke die höhere joinPriority hat.
it("Decke mit höherer joinPriority kappt die Wand an ihrer Unterkante", () => {
describe("Per-Schicht-Decken-Dominanz Wand/Decke (3D-Entsprechung der Schnitt-Boolean-Dominanz)", () => {
// Sample-Projekt: W1-W4 (EG, Wandtyp "aw": render-ext 10, insulation 20,
// brick 50, render-int 10) tragen Wandhöhe 2.6 m = Geschosshöhe. Decke C1
// (EG, Deckentyp "dg-massiv", dominante Schicht "concrete" joinPriority 100)
// hat OK bündig mit dem Wandkopf (zTop 2.6), Gesamtdicke 0.3 m -> zBottom 2.3.
//
// ENTSCHEIDEND (Nutzer-Bug „Kranz durch die Dämmung"): C1.outline ist der
// Raumgrundriss auf den WAND-ACHSEN ((0,0)-(5,0)-(5,4)-(0,4)) — die Decke liegt
// also nur auf dem INNEREN Halbquerschnitt jeder Aussenwand. Für W2 (Achse x=5)
// liegen die Bandmittellinien bei x = 5 offset:
// render-ext x=5.1625 (AUSSEN, x>5) -> NICHT überdeckt -> läuft durch
// insulation x=5.0725 (AUSSEN) -> NICHT überdeckt -> läuft durch
// brick x=4.9175 (INNEN, x<5) -> überdeckt -> Decken-z-Schnitt
// render-int x=4.8350 (INNEN) -> überdeckt -> Decken-z-Schnitt
// Die Decke schneidet ein Band also nur dort, wo ihre Outline die Footprint-
// Mittellinie DIESES Bandes überdeckt (geometrischer Überlapp wie
// toSection.ts::subtractDominantBands) — NICHT mehr per wandweitem BBox-Test.
// (Frühere Tests erwarteten hier „ALLE Schichten geschnitten"; das kodierte das
// falsche BBox-Verhalten und ist die Ursache des 3D-Kranzes. Korrigiert.)
it("Decke schneidet nur die von ihrer Outline überdeckten (inneren) Bänder; Dämmung/Aussenputz laufen durch; Beton spaltet den Backstein PER SCHICHT", () => {
const w2 = projectToWalls3d(sampleProject).filter((w) => w.wallId === "W2");
expect(w2.length).toBeGreaterThan(0);
for (const seg of w2) {
expect(seg.baseElevation + seg.height).toBeCloseTo(2.3, 6);
}
// PER-SCHICHT-Decken-Dominanz (identisch zu toSection.ts::subtractDominantBands):
// jede Deckenschicht (screed 30 / insulation 20 / concrete 100) schneidet nur
// Wandbänder mit STRIKT niedrigerer Priorität in IHREM z-Band. Der Backstein (50)
// wird also NUR von der Beton-Schicht (100, z-Band [2.3,2.5]) gekappt und läuft
// darüber bis zum Wandkopf (2.6) durch -> ZWEI Boxen. So schneidet er im Schnitt
// die schwimmenden Deckenschichten Estrich/Dämmung über sich weg (Nutzer-Bug).
const zBoxes = (t: number) =>
w2.filter((s) => Math.abs(s.thickness - t) < 1e-6)
.map((s) => [s.baseElevation, s.baseElevation + s.height] as const)
.sort((a, b) => a[0] - b[0]);
// brick: [0,2.3] (unter Beton) + [2.5,2.6] (über Beton, bis Wandkopf).
const brick = zBoxes(0.15);
expect(brick.length).toBe(2);
expect(brick[0][1]).toBeCloseTo(2.3, 6); // endet an Beton-UK
expect(brick[1][0]).toBeCloseTo(2.5, 6); // beginnt an Beton-OK
expect(brick[1][1]).toBeCloseTo(2.6, 6); // läuft bis Wandkopf durch
// render-int (10 < allen Deckenschichten): über [2.3,2.6] komplett gekappt -> [0,2.3].
const renderInt = zBoxes(0.015);
expect(renderInt.length).toBe(1);
expect(renderInt[0][1]).toBeCloseTo(2.3, 6);
// AUSSEN liegende Schichten (Mittellinie ausserhalb der Decken-Outline): KEIN
// Schnitt -> volle Höhe (behobener „Kranz"-Bug, wie im 2D-Schnitt).
expect(zBoxes(0.16)).toEqual([[0, 2.6]]); // insulation läuft durch
expect(zBoxes(0.02)).toEqual([[0, 2.6]]); // render-ext (Aussenputz) läuft durch
expect(w2.length).toBe(5); // render-ext + insulation + brick(2) + render-int
});
it("kein Trim, wenn die Wand-Priorität >= Decken-Priorität ist", () => {
// Deckentyp-Priorität (concrete) auf 40 senken -> unter der Wand-Priorität
// (brick 50) -> die Decke gewinnt nicht mehr, die Wand bleibt unverändert
// auf voller Höhe (2.6 m, wie ohne Trim-Logik).
it("PER-SCHICHT: nur überdeckte Bänder mit < Decken-Priorität werden geschnitten (Kern >= Prio läuft durch)", () => {
// Deckentyp-Priorität (concrete) auf 40 senken. Von den ÜBERDECKTEN (inneren)
// Bändern läuft brick (50 > 40) jetzt bis zum Wandkopf (2.6) durch; render-int
// (10 < 40) wird im Decken-z-Band [2.3,2.6] geschnitten -> endet bei 2.3. Die
// AUSSEN liegenden Bänder (insulation, render-ext) sind ohnehin nicht überdeckt
// und laufen unabhängig von der Priorität durch (2.6). Priorität ALLEIN genügt
// also nicht — es braucht zusätzlich den geometrischen Überlapp (wie die
// „gestaffelte Kette" in toSection.boolean.test.ts, wo Beton die Dämmung trotz
// höherer Prio NICHT schneidet, weil sie sich nicht überlappen).
const p: Project = {
...sampleProject,
components: sampleProject.components.map((c) =>
@@ -267,13 +358,155 @@ describe("Prioritäts-Trim Wand/Decke (Z-Fighting-Fix, s. trimWallTopForCeilings
),
};
const w2 = projectToWalls3d(p).filter((w) => w.wallId === "W2");
expect(w2.length).toBeGreaterThan(0);
for (const seg of w2) {
expect(seg.baseElevation + seg.height).toBeCloseTo(2.6, 6);
}
expect(w2.length).toBe(4); // je 1 Box pro Band (Wand ragt nicht über die Decke)
const top = (t: number) => {
const b = w2.find((s) => Math.abs(s.thickness - t) < 1e-6)!;
return b.baseElevation + b.height;
};
expect(top(0.15)).toBeCloseTo(2.6, 6); // brick (überdeckt, 50 > 40) läuft durch
expect(top(0.015)).toBeCloseTo(2.3, 6); // render-int (überdeckt, 10 < 40) geschnitten
expect(top(0.16)).toBeCloseTo(2.6, 6); // insulation (nicht überdeckt) läuft durch
expect(top(0.02)).toBeCloseTo(2.6, 6); // render-ext (nicht überdeckt) läuft durch
});
it("kein Trim, wenn keine Decke im selben Geschoss überlappt (z.B. OG-Wände ohne Dach-Decke)", () => {
it("Wand ragt über die Decke: überdecktes dominiertes Band spaltet vertikal, Aussenschichten bleiben ungeteilt", () => {
// W2 auf 3.0 m erhöhen (über Decken-OK 2.6) und concrete-Priorität auf 25
// senken. Von den ÜBERDECKTEN Bändern spaltet render-int (10 < 25) vertikal in
// ZWEI Boxen [0,2.3] + [2.6,3.0] (Decken-z-Band [2.3,2.6] ausgespart); brick
// (50 > 25) läuft als EINE Box [0,3.0] durch. Die NICHT überdeckten Aussen-
// schichten (insulation, render-ext) bleiben je EINE volle Box [0,3.0] — sie
// werden NICHT vertikal aufgespalten (kein Kranz).
const p: Project = {
...sampleProject,
walls: sampleProject.walls.map((w) => (w.id === "W2" ? { ...w, height: 3.0 } : w)),
components: sampleProject.components.map((c) =>
c.id === "concrete" ? { ...c, joinPriority: 25 } : c,
),
};
const w2 = projectToWalls3d(p).filter((w) => w.wallId === "W2");
// render-int 2 Boxen + brick/insulation/render-ext je 1 Box = 5 Boxen.
expect(w2.length).toBe(5);
const zRanges = (t: number) =>
w2
.filter((s) => Math.abs(s.thickness - t) < 1e-6)
.map((s) => [s.baseElevation, s.baseElevation + s.height] as const)
.sort((a, b) => a[0] - b[0]);
// brick (überdeckt, 50 > 25): EINE durchgehende Box.
const brick = zRanges(0.15);
expect(brick.length).toBe(1);
expect(brick[0][0]).toBeCloseTo(0, 6);
expect(brick[0][1]).toBeCloseTo(3.0, 6);
// render-int (überdeckt, 10 < 25): ZWEI Boxen unter/über der Decke.
const renderInt = zRanges(0.015);
expect(renderInt.length).toBe(2);
expect(renderInt[0][0]).toBeCloseTo(0, 6);
expect(renderInt[0][1]).toBeCloseTo(2.3, 6);
expect(renderInt[1][0]).toBeCloseTo(2.6, 6);
expect(renderInt[1][1]).toBeCloseTo(3.0, 6);
// insulation (nicht überdeckt): EINE volle Box, KEIN vertikaler Split.
const insul = zRanges(0.16);
expect(insul.length).toBe(1);
expect(insul[0][0]).toBeCloseTo(0, 6);
expect(insul[0][1]).toBeCloseTo(3.0, 6);
// render-ext (nicht überdeckt): ebenfalls EINE volle Box.
const renderExt = zRanges(0.02);
expect(renderExt.length).toBe(1);
expect(renderExt[0][0]).toBeCloseTo(0, 6);
expect(renderExt[0][1]).toBeCloseTo(3.0, 6);
});
it("Decke überdeckt die VOLLE Wanddicke -> jede Schicht PER SCHICHT gegen die Deckenschichten verschnitten", () => {
// Gegenprobe: verbreitert man C1 so, dass die Outline die ganze W2 überdeckt
// (Aussenkante x=5.1725 < 5.5), liegen ALLE Bandmittellinien innerhalb der Decke.
// PER-SCHICHT (statt pauschal): Deckenschichten screed(30, z[2.54,2.6]),
// insulation(20, z[2.5,2.54]), concrete(100, z[2.3,2.5]).
// • render-ext/render-int (Prio 10 < allen): über [2.3,2.6] komplett gekappt -> [0,2.3].
// • brick (50): nur von concrete(100) gekappt -> [0,2.3] + [2.5,2.6].
// • insulation-Wand (20): von concrete(100) und screed(30) gekappt, aber NICHT
// von der gleichrangigen Decken-insulation(20) -> [0,2.3] + [2.5,2.54] (Sliver,
// wo gleichrangiges Material koexistiert — exakt wie „gleiche Prio koexistiert").
const p: Project = {
...sampleProject,
ceilings: (sampleProject.ceilings ?? []).map((c) =>
c.id === "C1"
? {
...c,
outline: [
{ x: 0, y: -0.5 },
{ x: 5.5, y: -0.5 },
{ x: 5.5, y: 4.5 },
{ x: 0, y: 4.5 },
],
}
: c,
),
};
const w2 = projectToWalls3d(p).filter((w) => w.wallId === "W2");
expect(w2.length).toBe(6);
const zBoxes = (t: number) =>
w2.filter((s) => Math.abs(s.thickness - t) < 1e-6)
.map((s) => [s.baseElevation, s.baseElevation + s.height] as const)
.sort((a, b) => a[0] - b[0]);
expect(zBoxes(0.02)).toEqual([[0, 2.3]]); // render-ext (10) voll gekappt
expect(zBoxes(0.015)).toEqual([[0, 2.3]]); // render-int (10) voll gekappt
const brick = zBoxes(0.15); // brick (50): nur Beton kappt
expect(brick.length).toBe(2);
expect(brick[0][1]).toBeCloseTo(2.3, 6);
expect(brick[1][0]).toBeCloseTo(2.5, 6);
expect(brick[1][1]).toBeCloseTo(2.6, 6);
const insul = zBoxes(0.16); // insulation-Wand (20): Sliver [2.5,2.54] bleibt
expect(insul.length).toBe(2);
expect(insul[0][1]).toBeCloseTo(2.3, 6);
expect(insul[1][0]).toBeCloseTo(2.5, 6);
expect(insul[1][1]).toBeCloseTo(2.54, 6);
});
it("Decke überdeckt nur EINEN TEIL der Wandlänge -> überdecktes Kern-Band spaltet ENTLANG DER ACHSE", () => {
// C1.outline auf die halbe Länge kürzen (y in [0,2] statt [0,4]): die Decke
// überdeckt die inneren W2-Bänder nur auf dem Achsenstück y in [0,2]. Das
// überdeckte Kern-Band (brick) wird dort z-geschnitten und läuft im Rest der
// Wandlänge (y > 2) über die volle Höhe durch — die Überdeckungsgrenze (y=2)
// wird aus der Bandmittellinien-Abtastung per Bisektion scharf ermittelt. Die
// Aussenschichten (nicht überdeckt) bleiben ganz. So wird auch eine Decke
// korrekt behandelt, die nur einen Teil der Wandlänge trägt.
const p: Project = {
...sampleProject,
ceilings: (sampleProject.ceilings ?? []).map((c) =>
c.id === "C1"
? { ...c, outline: [{ x: 0, y: 0 }, { x: 5, y: 0 }, { x: 5, y: 2 }, { x: 0, y: 2 }] }
: c,
),
};
const w2 = projectToWalls3d(p).filter((w) => w.wallId === "W2");
// Per-Schicht-Decken-Dominanz + Achsen-Überdeckung: der Backstein (50) wird nur
// von der Beton-Schicht (100) UND nur auf dem überdeckten Achsenstück y∈[~0.08,2]
// gekappt. Dort entstehen ZWEI z-Boxen [0,2.3] + [2.5,2.6]; das unbedeckte Stück
// y∈[2,~3.92] läuft als EINE volle Box [0,2.6] durch -> 3 Backstein-Boxen.
// render-int (10 < Beton) spaltet in [0,2.3] (überdeckt) + [0,2.6] (unbedeckt) = 2.
// Aussenschichten (insulation, render-ext) nicht überdeckt -> je 1 volle Box.
expect(w2.length).toBe(7);
const brick = w2
.filter((s) => Math.abs(s.thickness - 0.15) < 1e-6)
.map((s) => ({ sy: s.start[1], zb: s.baseElevation, zt: s.baseElevation + s.height }))
.sort((a, b) => a.zb - b.zb || a.sy - b.sy);
expect(brick.length).toBe(3);
// Überdecktes Stück y∈[~0.08,2]: untere Box [0,2.3] + obere Box [2.5,2.6].
const covered = brick.filter((b) => b.sy < 1.0);
expect(covered.length).toBe(2);
expect(covered[0].zt).toBeCloseTo(2.3, 6); // unter Beton
expect(covered[1].zb).toBeCloseTo(2.5, 6); // über Beton
expect(covered[1].zt).toBeCloseTo(2.6, 6);
// Unbedecktes Stück y∈[2,~3.92]: EINE volle Box [0,2.6].
const uncovered = brick.filter((b) => b.sy > 1.5);
expect(uncovered.length).toBe(1);
expect(uncovered[0].zt).toBeCloseTo(2.6, 6);
// insulation (aussen) bleibt EINE ungeteilte volle Box.
const insul = w2.filter((s) => Math.abs(s.thickness - 0.16) < 1e-6);
expect(insul.length).toBe(1);
expect(insul[0].baseElevation + insul[0].height).toBeCloseTo(2.6, 6);
});
it("keine Subtraktion, wenn keine Decke im selben Geschoss überlappt (z.B. OG-Wände ohne Dach-Decke)", () => {
// W6 (OG) hat keine überlappende Decke im Sample-Projekt (C1 ist EG) ->
// unverändertes Verhalten (volle wall.height).
const w6 = projectToWalls3d(sampleProject).filter((w) => w.wallId === "W6");
@@ -284,15 +517,271 @@ describe("Prioritäts-Trim Wand/Decke (Z-Fighting-Fix, s. trimWallTopForCeilings
});
});
describe("Bauteil-/Materialfarbe Decke", () => {
it("Decke -> Farbe der dicksten Schicht (dg-massiv: concrete 0.2 m, #9aa0a6)", () => {
// Deckentyp "dg-massiv": screed 0.06, insulation 0.04, concrete 0.2 ->
// dickste Schicht = concrete (#9aa0a6).
describe("Geschichteter Bodenaufbau Decke", () => {
it("Decke -> je Schicht ein Slab, oben->unten gestapelt mit Bauteilfarbe/-schraffur", () => {
// Deckentyp "dg-massiv": screed 0.06 (#c9c2b3), insulation 0.04 (#ffffff),
// concrete 0.2 (#9aa0a6) — Reihenfolge OK->UK. C1: zTop 2.6, Gesamt 0.3 ->
// zBottom 2.3. Proportional: Estrich 2.54..2.6, Dämmung 2.50..2.54,
// Beton 2.30..2.50. Der 3D-Schnitt zeigt so den Aufbau geschichtet (wie 2D).
const { slabs } = projectToModel3d(sampleProject);
const c1 = slabs.find((s) => s.ceilingId === "C1");
expect(c1).toBeDefined();
expect(c1!.color[0]).toBeCloseTo(0x9a / 255, 6);
expect(c1!.color[1]).toBeCloseTo(0xa0 / 255, 6);
expect(c1!.color[2]).toBeCloseTo(0xa6 / 255, 6);
const c1 = slabs
.filter((s) => s.ceilingId === "C1")
.sort((a, b) => b.zTop - a.zTop); // oben zuerst
expect(c1.length).toBe(3);
// Oberste Schicht = Estrich (#c9c2b3), bündig mit OK der Decke (2.6).
expect(c1[0].zTop).toBeCloseTo(2.6, 6);
expect(c1[0].color[0]).toBeCloseTo(0xc9 / 255, 6);
expect(c1[0].color[1]).toBeCloseTo(0xc2 / 255, 6);
expect(c1[0].color[2]).toBeCloseTo(0xb3 / 255, 6);
// Mittlere Schicht = Dämmung (#ffffff).
expect(c1[1].color[0]).toBeCloseTo(1, 6);
expect(c1[1].color[1]).toBeCloseTo(1, 6);
expect(c1[1].color[2]).toBeCloseTo(1, 6);
// Unterste Schicht = Betondecke (#9aa0a6), bündig mit UK der Decke (2.3).
expect(c1[2].zBottom).toBeCloseTo(2.3, 6);
expect(c1[2].color[0]).toBeCloseTo(0x9a / 255, 6);
expect(c1[2].color[1]).toBeCloseTo(0xa0 / 255, 6);
expect(c1[2].color[2]).toBeCloseTo(0xa6 / 255, 6);
// Schichten schließen lückenlos aneinander (UK Schicht k == OK Schicht k+1).
expect(c1[0].zBottom).toBeCloseTo(c1[1].zTop, 6);
expect(c1[1].zBottom).toBeCloseTo(c1[2].zTop, 6);
});
});
describe("sliceTermination: Wand endet an der Decke (Zuschnitt, nicht Priorität)", () => {
// W2 (EG, aw) steht unter der Decke C1 (dg-massiv). Ohne Terminierung ("both")
// spaltet der Beton (100) den Backstein (50) in [0,2.3] + [2.5,2.6] (heutiges,
// prioritäts-korrektes Verhalten). "below" clippt die Wand an der Decken-UK →
// nur die untere Backstein-Box [0,2.3] bleibt; "above" spiegelbildlich [2.5,2.6].
const withTermination = (mode: "both" | "below" | "above") => ({
...sampleProject,
walls: sampleProject.walls.map((w) =>
w.id === "W2" ? { ...w, sliceTermination: mode === "both" ? undefined : mode } : w,
),
});
const brickBoxes = (mode: "both" | "below" | "above") =>
projectToWalls3d(withTermination(mode))
.filter((w) => w.wallId === "W2" && Math.abs(w.thickness - 0.15) < 1e-6)
.map((w) => [w.baseElevation, w.baseElevation + w.height] as const)
.sort((a, b) => a[0] - b[0]);
it('Default/"both": Backstein bleibt als ZWEI Boxen [0,2.3] + [2.5,2.6] (heutiges Verhalten)', () => {
const brick = brickBoxes("both");
expect(brick.length).toBe(2);
expect(brick[0][0]).toBeCloseTo(0, 6);
expect(brick[0][1]).toBeCloseTo(2.3, 6);
expect(brick[1][0]).toBeCloseTo(2.5, 6);
expect(brick[1][1]).toBeCloseTo(2.6, 6);
});
it('"below": NUR die untere Backstein-Box [0,2.3] (Wand endet an der Decke, kein Rest oben)', () => {
const brick = brickBoxes("below");
expect(brick.length).toBe(1);
expect(brick[0][0]).toBeCloseTo(0, 6);
expect(brick[0][1]).toBeCloseTo(2.3, 6);
// Auch der Innenputz (render-int, überdeckt) endet einheitlich an der Decken-UK 2.3.
const renderInt = projectToWalls3d(withTermination("below"))
.filter((w) => w.wallId === "W2" && Math.abs(w.thickness - 0.015) < 1e-6);
expect(renderInt.length).toBe(1);
expect(renderInt[0].baseElevation + renderInt[0].height).toBeCloseTo(2.3, 6);
// Aussenschichten (nicht überdeckt) laufen weiter voll durch (2.6).
const insul = projectToWalls3d(withTermination("below"))
.filter((w) => w.wallId === "W2" && Math.abs(w.thickness - 0.16) < 1e-6);
expect(insul[0].baseElevation + insul[0].height).toBeCloseTo(2.6, 6);
});
it('"above": NUR die obere Backstein-Box [2.5,2.6] (Brüstung/Attika von oben)', () => {
const brick = brickBoxes("above");
expect(brick.length).toBe(1);
expect(brick[0][0]).toBeCloseTo(2.5, 6);
expect(brick[0][1]).toBeCloseTo(2.6, 6);
});
});
describe("Schnitt-Schraffur-Orientierung: relativeToWall dreht bei Wand, nicht bei Decke (2D-Parität)", () => {
// 2D-Referenz (verbindlich): splitWallLayers ruft resolveHatch(…, 90, …) (Wand
// steht vertikal, relativeToWall-Muster drehen mit), splitSlabLayers ruft
// resolveHatch(…, undefined, …) (Decke liegt, absoluter Winkel). Der 3D-Pfad muss
// EXAKT diesen Endwinkel je Kontext liefern (statt des rohen Hatch-Winkels) —
// sonst läuft z. B. die Boden-Dämmung im 3D-Schnitt horizontal statt vertikal.
const wallInsul = () =>
projectToModel3d(sampleProject).walls.find(
(w) => w.cut?.componentId === "insulation" && Math.abs(w.thickness - 0.16) < 1e-6,
)!;
const slabInsul = () =>
projectToModel3d(sampleProject).slabs.find((s) => s.cut?.componentId === "insulation")!;
it("WAND-Dämmung: 3D-Hatch-Winkel == resolveHatch(sia-insulation, 90)", () => {
const expected = resolveHatch(sampleProject, "sia-insulation", 90).angle;
expect(wallInsul().hatch?.angle).toBeCloseTo(expected, 6);
});
it("DECKEN-Dämmung: 3D-Hatch-Winkel == resolveHatch(sia-insulation, undefined) (absolut)", () => {
const expected = resolveHatch(sampleProject, "sia-insulation", undefined).angle;
expect(slabInsul().hatch?.angle).toBeCloseTo(expected, 6);
});
it("Wand- und Decken-Dämmung unterscheiden sich um den Wandachsenwinkel (relativeToWall dreht 90°)", () => {
// sia-insulation ist relativeToWall -> Wand-Winkel = roh 90, Decke = roh.
// Die Differenz ist exakt 90° (der Boden-Aufbau läuft dadurch quer zur Wand).
const diff = (slabInsul().hatch!.angle - wallInsul().hatch!.angle + 360) % 360;
expect(diff).toBeCloseTo(90, 6);
});
it("WAND-Backstein (relativeToWall): 3D-Hatch-Winkel == resolveHatch(sia-brick, 90)", () => {
const brick = projectToModel3d(sampleProject).walls.find(
(w) => w.cut?.componentId === "brick" && Math.abs(w.thickness - 0.15) < 1e-6,
)!;
expect(brick.hatch?.angle).toBeCloseTo(resolveHatch(sampleProject, "sia-brick", 90).angle, 6);
});
it("Musterlinien-Stärke kommt aus dem LineStyle des Hatch durch (2D-Parität)", () => {
// sia-insulation nutzt lineStyleId "hatch-hair" (0.02 mm). Der 3D-Hatch muss die
// resolveHatch-lineWeight (aus dem LineStyle) tragen — für Wand UND Decke gleich.
const expected = resolveHatch(sampleProject, "sia-insulation", 90).lineWeight;
expect(wallInsul().hatch?.lineWeight).toBeCloseTo(expected, 6);
expect(slabInsul().hatch?.lineWeight).toBeCloseTo(expected, 6);
expect(expected).toBeCloseTo(0.02, 6); // hatch-hair
});
});
describe("Wand-Joins im 3D (L-/T-Stösse, identisch zum 2D-Grundriss/Schnitt)", () => {
// Die per-Schicht-Cut-Distanzen aus computeJoins (model/joins.ts) werden im
// layered-3D-Pfad auf die Band-Achsenlängen angewandt — dieselben Zahlen, die
// generatePlan.ts über clippedBand auf die 2D-Polygone anwendet (Band-Mittellinie
// an der Gehrungs-/Anschluss-Linie geschnitten). Alle Erwartungswerte unten sind
// aus der Join-Geometrie des Sample-Projekts von Hand hergeleitet.
it("(a) L-Ecke gleicher Wandtyp: jede Schicht individuell gehrt (aussen länger, Kern kürzer)", () => {
// W2 (5,0)->(5,4), Typ "aw", KEINE Öffnung/Querwand -> genau 4 Schicht-Bänder.
// Beide Enden sind rechtwinklige L-Ecken (mit W1 bei (5,0), W3 bei (5,4)):
// 45°-Gehrung x+y=5 (Start) bzw. y=x-1 (Ende). Die Mittellinie einer Schicht
// bei Querversatz `offset` trifft die Gehrung bei Achsenmeter `offset` (Start)
// bzw. `4-offset` (Ende). Aussenschichten (negativer offset) laufen ÜBER die
// Ecke hinaus (wrappen), Innenschichten (positiver offset) enden kürzer.
// Achse u=(0,1) -> Box-Achsenanfang/-ende = start[1]/end[1].
// W2 steht unter der Decke C1: die Beton-Schicht (100) spaltet den Backstein (50)
// z-mässig in [0,2.3] + [2.5,2.6] (per-Schicht-Decken-Dominanz) -> 5 Boxen. Die
// Gehrung ist z-unabhängig, beide Backstein-Boxen tragen denselben Achsenschnitt.
const w2 = projectToWalls3d(sampleProject).filter((w) => w.wallId === "W2");
expect(w2.length).toBe(5);
const band = (t: number) => w2.find((s) => Math.abs(s.thickness - t) < 1e-9)!;
// offset je Schicht (aw, center): -0.1625 / -0.0725 / 0.0825 / 0.165.
const cases: Array<[number, number]> = [
[0.02, -0.1625], // render-ext (aussen) -> from=-0.1625, to=4.1625
[0.16, -0.0725], // insulation
[0.15, 0.0825], // brick (Kern)
[0.015, 0.165], // render-int (innen) -> from=0.165, to=3.835 (am kürzesten)
];
for (const [t, offset] of cases) {
const b = band(t);
expect(b.start[1]).toBeCloseTo(offset, 6); // Achsenanfang = offset
expect(b.end[1]).toBeCloseTo(4 - offset, 6); // Achsenende = 4-offset
}
// Der Kern (brick) ist kürzer als die Aussenschicht (render-ext): die
// per-Schicht-Gehrung ist also wirklich individuell (keine einheitliche Länge).
const len = (t: number) => band(t).end[1] - band(t).start[1];
expect(len(0.15)).toBeLessThan(len(0.02));
expect(len(0.15)).toBeCloseTo(3.835, 6); // 3.9175 - 0.0825
expect(len(0.02)).toBeCloseTo(4.325, 6); // 4.1625 - (-0.1625)
});
it("(b) T-Stoss: Abzweig-Kern läuft bis zur Rückgrat-Nahfläche durch, Putz-Band stoppt an der Wandfläche", () => {
// Querwand W9 (2.4,0)->(2.4,4), Typ "iw" (render-int 0.015 / brick 0.12 /
// render-int 0.015), stösst mittig auf W1 (unten) und W3 (oben) -> zwei
// Mittelspannen-T-Stösse. W9-brick == W1/W3-Rückgrat (Komponente "brick",
// Prio 50) -> MERGE: der brick-Kern sticht durch den 0.015-Nah-Putz der
// Durchgangswand bis zu deren Rückgrat-Nahfläche, die render-int-Putzschichten
// stoppen an der Wandoberfläche. Achse u=(0,1) -> Achsenmeter = start[1]/end[1].
// W9 (iw) steht ebenfalls unter der Decke C1: die Beton-Schicht (100) spaltet den
// Kern-Backstein (50) z-mässig in [0,2.3] + [2.5,2.6] -> 2 Kern-Boxen + 2 Putz = 4.
// Die Gehrung/T-Stoss-Achse ist z-unabhängig, beide Kern-Boxen identisch.
const w9 = projectToWalls3d(sampleProject).filter((w) => w.wallId === "W9");
expect(w9.length).toBe(4);
const brick = w9.find((s) => Math.abs(s.thickness - 0.12) < 1e-9)!;
const renders = w9.filter((s) => Math.abs(s.thickness - 0.015) < 1e-9);
expect(renders.length).toBe(2);
// Putz stoppt an W1-Oberfläche (y=0.1725) bzw. W3-Oberfläche (y=3.8275).
for (const r of renders) {
expect(r.start[1]).toBeCloseTo(0.1725, 6);
expect(r.end[1]).toBeCloseTo(3.8275, 6);
}
// Kern läuft 0.015 tiefer bis zur Rückgrat-Nahfläche (y=0.1575 bzw. 3.8425).
expect(brick.start[1]).toBeCloseTo(0.1575, 6);
expect(brick.end[1]).toBeCloseTo(3.8425, 6);
expect(brick.start[1]).toBeLessThan(renders[0].start[1]); // Kern durchgesteckt
});
it("(b) T-Stoss Durchgangswand: Nah-Putz wird über die Kernbreite aufgebrochen (Span-Cutout splittet das Band)", () => {
// W1 (Durchgangswand) verliert den Nah-Putz (innerstes render-int-Band) über
// die Achsen-Breite des durchstechenden W9-Kerns: das Band zerfällt entlang
// der Achse in zwei Teilstücke [0.165..2.34] und [2.46..4.835] (Lücke = die
// 0.12 m Kernbreite um x=2.4). W1-Achse u=(1,0) -> Achsenmeter = start[0]/end[0].
const w1 = projectToWalls3d(sampleProject).filter((w) => w.wallId === "W1");
const innerPlaster = w1
.filter((s) => Math.abs(s.thickness - 0.015) < 1e-9)
.map((s) => [s.start[0], s.end[0]] as const)
.sort((a, b) => a[0] - b[0]);
expect(innerPlaster.length).toBe(2); // in zwei Teilstücke gebrochen
// Lücke = Kernbreite-Projektion [2.34, 2.46] um die Querwand-Achse x=2.4.
expect(innerPlaster[0][1]).toBeCloseTo(2.34, 6); // Ende Teilstück 1
expect(innerPlaster[1][0]).toBeCloseTo(2.46, 6); // Anfang Teilstück 2
});
it("(c) Join-verschobenes Band: Öffnungs-Löcher bleiben absolut korrekt positioniert (rebased)", () => {
// W1 trägt Fenster O1 (Achsenintervall [3.0,4.0]). Das Aussenputz-Band ist an
// den L-Ecken über die Achse hinaus verlängert (Achsenanfang < 0) — die Loch-
// `from`/`to` sind auf DIESEN Box-Start umgerechnet. Rekonstruiert man die
// absolute Achsenposition (Box-Achsenanfang + `from`), liegt das Fenster wieder
// exakt auf [3.0,4.0], unabhängig von der Join-Verschiebung. W1-Achse u=(1,0).
const w1 = projectToWalls3d(sampleProject).filter((w) => w.wallId === "W1");
const outer = w1.find((s) => Math.abs(s.thickness - 0.02) < 1e-9)!;
expect(outer.start[0]).toBeLessThan(0); // Band über die Ecke hinaus verlängert
const window = (outer.holes ?? [])
.map((h) => [outer.start[0] + h.from, outer.start[0] + h.to] as const)
.sort((a, b) => a[0] - b[0])
.find(([a]) => a > 2); // das Fenster O1 (das andere Loch ist die Tür D1)
expect(window).toBeDefined();
expect(window![0]).toBeCloseTo(3.0, 6);
expect(window![1]).toBeCloseTo(4.0, 6);
});
it("Joins wirken NUR im layered-3D-Pfad, nicht im Schnitt-Pfad (layered=false unverändert)", () => {
// Der Schnitt-Pfad (computeSection, layeredWalls:false) darf KEINE Join-Cuts
// bekommen: W2 bleibt eine Voll-Box über die ganze Dicke, ungehrt (Achse
// 0..4), damit toSection.ts::subtractDominantBands seine eigene Dominanz auf
// Rohgeometrie anwendet.
const w2 = projectToWalls3d(sampleProject, false).filter((w) => w.wallId === "W2");
expect(w2.length).toBe(1); // Voll-Box, keine Schicht-Bänder
expect(w2[0].start[1]).toBeCloseTo(0, 6); // ungehrt: Achse 0..4
expect(w2[0].end[1]).toBeCloseTo(4, 6);
});
});
describe("emitStairMeshes (Betontreppe: glatte Laufplatte als Mesh)", () => {
it("gerade Betontreppe -> Mesh mit 8 Ecken je Tritt, glatte schräge Untersicht", () => {
// Sample-Treppe ST1: Typ "stair-standard" (Tragart "beton"), gerade,
// stepCount 15 -> ein konvexes Prisma je Tritt = 8 Ecken. Das Mesh landet in
// model.meshes (kind "extrusion").
const model = projectToModel3d(sampleProject);
const stairMesh = model.meshes.find((m) => m.positions.length / 3 === 8 * 15);
expect(stairMesh).toBeTruthy();
const m = stairMesh!;
// Indizes bilden Dreiecke (6 Quads * 2 Tris * 3 = 36 Indizes je Tritt).
expect(m.indices.length).toBe(15 * 36);
expect(m.indices.length % 3).toBe(0);
// Keine NaN/Inf in der Geometrie.
expect(m.positions.every((v) => Number.isFinite(v))).toBe(true);
// Höhen (jede 3. Komponente) liegen zwischen Treppen-UK (~0) und -OK (~2.6).
const heights = m.positions.filter((_, i) => i % 3 === 2);
expect(Math.min(...heights)).toBeGreaterThanOrEqual(-1e-6);
expect(Math.max(...heights)).toBeLessThanOrEqual(2.6 + 1e-6);
// Alle Index-Werte referenzieren existierende Vertices.
const vcount = m.positions.length / 3;
expect(m.indices.every((idx) => idx >= 0 && idx < vcount)).toBe(true);
});
});
+1243 -124
View File
File diff suppressed because it is too large Load Diff
+169
View File
@@ -0,0 +1,169 @@
// Unit-Tests für den gemeinsamen Wand-Loch-Ausschnitt-Generator (wallMeshCut.ts,
// TS-Port von render3d/src/mesh.rs::extrude_layer_segment_with_holes). Prüft die
// geometrische Zerlegung (gleiche Rechteck-Anzahl wie die Rust-Referenz), nicht
// nur „läuft".
import { describe, it, expect } from "vitest";
import { isWatertight, solidSubrects, wallCutMesh } from "./wallMeshCut";
import type { CutMesh } from "./wallMeshCut";
import type { RWall } from "./toWalls3d";
/**
* Geschlossene-Fläche-Kennzahlen: Vektorfläche n dA (Σ (v1v0)×(v2v0)) und
* 6× signiertes Volumen (Σ v0·(v1×v2)). Dienen den Watertight-Tests direkt (statt
* eines naiven Kanten-Manifold-Zählers, der an den legitimen T-Stössen der vollen
* Deckel/Boden-Streifen scheitern würde siehe isWatertight-Doc in wallMeshCut.ts).
*/
function surfaceIntegrals(m: CutMesh): { areaLen: number; vol6: number } {
const p = m.positions;
let ax = 0;
let ay = 0;
let az = 0;
let vol6 = 0;
for (let t = 0; t < m.indices.length; t += 3) {
const i0 = m.indices[t] * 3;
const i1 = m.indices[t + 1] * 3;
const i2 = m.indices[t + 2] * 3;
const e1 = [p[i1] - p[i0], p[i1 + 1] - p[i0 + 1], p[i1 + 2] - p[i0 + 2]];
const e2 = [p[i2] - p[i0], p[i2 + 1] - p[i0 + 1], p[i2 + 2] - p[i0 + 2]];
const cx = e1[1] * e2[2] - e1[2] * e2[1];
const cy = e1[2] * e2[0] - e1[0] * e2[2];
const cz = e1[0] * e2[1] - e1[1] * e2[0];
ax += cx;
ay += cy;
az += cz;
vol6 += p[i0] * cx + p[i0 + 1] * cy + p[i0 + 2] * cz;
}
return { areaLen: Math.hypot(ax, ay, az), vol6 };
}
/** Ein RWall-Band mit optionalen Löchern (frei stehend, Achse entlang +x). */
function band(holes?: RWall["holes"]): RWall {
return {
start: [0, 0],
end: [5, 0],
thickness: 0.4,
height: 2.6,
baseElevation: 0,
color: [0.8, 0.8, 0.8],
layers: [],
wallId: "W1",
...(holes ? { holes } : {}),
};
}
describe("solidSubrects — Koordinaten-Kompression (Rust-Parität)", () => {
it("ohne Loch: genau ein Vollrechteck", () => {
expect(solidSubrects(0, 5, 0, 2.6, [])).toEqual([[0, 5, 0, 2.6]]);
});
it("ein voll im Inneren liegendes Loch → 3×3-Gitter minus 1 = 8 solide Teilrechtecke", () => {
const rects = solidSubrects(0, 5, 0, 2.6, [[2, 3, 0.9, 2.4]]);
expect(rects).toHaveLength(8);
// Keiner der Teilrechteck-Mittelpunkte liegt im Loch.
for (const [ua, ub, za, zb] of rects) {
const cu = (ua + ub) / 2;
const cz = (za + zb) / 2;
expect(cu > 2 && cu < 3 && cz > 0.9 && cz < 2.4).toBe(false);
}
});
it("randständiges Loch (Tür am Boden) → 3×2-Gitter minus 1 = 5 Teilrechtecke", () => {
expect(solidSubrects(0, 5, 0, 2.6, [[2, 3, 0, 2.1]])).toHaveLength(5);
});
it("zwei versetzt übereinanderliegende Löcher: keine überlappenden/entarteten Teilrechtecke", () => {
const rects = solidSubrects(0, 5, 0, 2.8, [
[1, 2, 0.9, 1.4],
[3, 4, 1.6, 2.2],
]);
// Alle Teilrechtecke haben echte Ausdehnung und liegen ausserhalb beider Löcher.
for (const [ua, ub, za, zb] of rects) {
expect(ub - ua).toBeGreaterThan(0);
expect(zb - za).toBeGreaterThan(0);
}
expect(rects.length).toBeGreaterThan(0);
});
});
describe("wallCutMesh — Loch-Ausschnitt-Mesh", () => {
it("ohne Loch: 12 Dreiecke (volle Box-Äquivalenz)", () => {
const m = wallCutMesh(band());
expect(m.indices.length / 3).toBe(12);
});
it("Fenster im Inneren: 48 Dreiecke, 4 Laibungen, Loch-Rand-Vertices vorhanden", () => {
const m = wallCutMesh(band([{ from: 2, to: 3, zBottom: 0.9, zTop: 2.4 }]));
expect(m.indices.length / 3).toBe(48);
// Vertices an allen vier Loch-Rändern (Brüstung/Sturz/links/rechts).
const has = (axis: number, val: number) => {
for (let i = 0; i < m.positions.length; i += 3) {
if (Math.abs(m.positions[i + axis] - val) < 1e-6) return true;
}
return false;
};
expect(has(1, 0.9)).toBe(true); // y = Brüstungs-OK
expect(has(1, 2.4)).toBe(true); // y = Sturz-UK
expect(has(0, 2)).toBe(true); // x = linke Kante
expect(has(0, 3)).toBe(true); // x = rechte Kante
// KEIN Vertex liegt strikt im offenen Loch-Rechteck (die Fläche ist wirklich weg).
for (let i = 0; i < m.positions.length; i += 3) {
const x = m.positions[i];
const y = m.positions[i + 1];
const strictlyInside = x > 2 + 1e-6 && x < 3 - 1e-6 && y > 0.9 + 1e-6 && y < 2.4 - 1e-6;
expect(strictlyInside).toBe(false);
}
});
it("Tür am Boden: 36 Dreiecke (untere Laibung entfällt, Boden bekommt die Lücke)", () => {
const m = wallCutMesh(band([{ from: 2, to: 3, zBottom: 0, zTop: 2.1 }]));
expect(m.indices.length / 3).toBe(36);
});
it("alle Dreiecks-Indizes referenzieren existierende Vertices", () => {
const m = wallCutMesh(band([{ from: 2, to: 3, zBottom: 0.9, zTop: 2.4 }]));
const vcount = m.positions.length / 3;
for (const idx of m.indices) {
expect(idx).toBeGreaterThanOrEqual(0);
expect(idx).toBeLessThan(vcount);
}
});
});
describe("wallCutMesh — Wasserdichtigkeit + Aussen-Orientierung (der hohl/offen-Bug)", () => {
it("volle Wand OHNE Loch: geschlossen (∮ n dA = 0), Normalen aussen (Volumen = L·T·H)", () => {
const m = wallCutMesh(band());
const { areaLen, vol6 } = surfaceIntegrals(m);
expect(areaLen).toBeLessThan(1e-6); // ∮ n dA = 0 ⇒ kein Loch, kein invertiertes Dreieck
expect(vol6 / 6).toBeCloseTo(5 * 0.4 * 2.6, 6); // > 0 ⇒ aussen orientiert
expect(isWatertight(m)).toBe(true);
});
it("Wand mit Fenster: trotz T-Stösse geschlossen + aussen orientiert (Volumen = Wand Fensterquader)", () => {
const m = wallCutMesh(band([{ from: 2, to: 3, zBottom: 0.9, zTop: 2.4 }]));
const { areaLen, vol6 } = surfaceIntegrals(m);
expect(areaLen).toBeLessThan(1e-6);
expect(vol6 / 6).toBeCloseTo(5 * 0.4 * 2.6 - 1 * 0.4 * (2.4 - 0.9), 6);
expect(isWatertight(m)).toBe(true);
});
it("eine EINZELNE invertierte Kappe würde erkannt (Regressionswächter gegen falsche Wicklung)", () => {
// Bau die volle Wand, kehre die Wicklung EINES Dreiecks um → nicht mehr dicht.
const m = wallCutMesh(band());
const broken: CutMesh = { positions: m.positions.slice(), indices: m.indices.slice() };
[broken.indices[1], broken.indices[2]] = [broken.indices[2], broken.indices[1]];
expect(isWatertight(broken)).toBe(false);
});
it("Türwand ist ein GESCHLOSSENER Prisma-Körper (Querschnitt = П-Polygon, Türkerbe umlaufend)", () => {
// Die Türkerbe ist eine Einbuchtung der UNTEREN Umriss-Kante (kein Loch): der
// Querschnitt bleibt EIN einfaches, geschlossenes Polygon (П-Form), das über
// die Dicke extrudiert wieder einen geschlossenen Körper ergibt. Die fehlende
// untere Laibung und die Boden-Lücke heben sich in ∮ n dA exakt auf.
const m = wallCutMesh(band([{ from: 2, to: 3, zBottom: 0, zTop: 2.1 }]));
const { areaLen, vol6 } = surfaceIntegrals(m);
expect(areaLen).toBeLessThan(1e-6);
expect(vol6 / 6).toBeCloseTo(5 * 0.4 * 2.6 - 1 * 0.4 * 2.1, 6);
expect(isWatertight(m)).toBe(true);
});
});
+370
View File
@@ -0,0 +1,370 @@
// Gemeinsamer Wand-Mesh-Generator MIT echtem Loch-Ausschnitt (Fenster/Türen).
//
// Portierung der Rust-Referenz `render3d/src/mesh.rs::extrude_layer_segment_
// with_holes` nach TS: aus EINEM RWall-Band (Achse `start→end`, `thickness`,
// `baseElevation`, `height` + rechteckige `holes`) wird das Dreiecks-Mesh
// erzeugt, das die Öffnungen ECHT ausschneidet (statt der vollen Box). Damit
// sehen die Datei-Exporte (STL/OBJ/IFC) genauso aus wie die 3D-Ansicht: die
// Löcher sind sichtbar ausgestanzt, inklusive der Laibungs-/Jamb-Flächen.
//
// WICHTIG — WARUM KEINE GEHRUNG HIER: Die RWall-Bänder aus
// `toWalls3d.ts::pickGeometry` sind bereits an ihren Achsenden auf die
// Gehrungs-/Anschluss-Schnittlinie GEKÜRZT (die Box endet aber achsparallel,
// keine gekippte Stirnfläche — siehe `bandCutDistance` in toWalls3d.ts). Die
// Ecken-Verschneidung (Joins) steckt also schon in `start`/`end` jedes Bandes.
// Deshalb entfällt hier die `start_cut`/`end_cut`-Gehrungslogik der Rust-Funktion
// (im Rust-Pfad wird sie nur gebraucht, weil dort EIN ungekürztes Band mit
// Gehrungslinien extrudiert wird). Das Ergebnis: dieselbe Rechteck-Zerlegung der
// Langseiten + dieselben 4 Laibungsquads je Loch wie in Rust, nur mit rechten
// (ungekippten) Stirnkappen — exakt die Geometrie, die der Viewer schon zeigt.
//
// ACHSEN: Modell (x, y) → Welt (x, Höhe, y), Y-UP — identisch zu exportMesh.ts /
// selectionHighlightLines / dem 3D-Viewer. Ein Weltvertex (vx,vy,vz) ist also
// (Modell-x, Meter-Höhe, Modell-y).
import type { RWall } from "./toWalls3d";
/** Rundungs-/Entartungsschwelle (1e-4 m), 1:1 wie `openings::MIN_SPAN` in Rust. */
const MIN_SPAN = 1e-4;
/** Ein Weltpunkt (x, Höhe, y). */
type V3 = [number, number, number];
/**
* Ein Dreiecks-Mesh in LOKALEN (0-basierten) Indizes: `positions` flach
* (x,y,z, ) in Weltmetern (Y-up), `indices` je 3 = ein Dreieck.
*/
export interface CutMesh {
positions: number[];
indices: number[];
}
/**
* Aussen-Normale eines planaren, CCW-von-aussen georderten Quads über die ersten
* drei Ecken: `normalize(cross(b-a, d-a))` 1:1 wie `mesh.rs::quad_normal`.
* Nulllange Flächen liefern den Nullvektor (Aufrufer nutzt sie dann nicht).
*/
function quadNormal(a: V3, b: V3, d: V3): V3 {
const ab: V3 = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
const ad: V3 = [d[0] - a[0], d[1] - a[1], d[2] - a[2]];
const c: V3 = [
ab[1] * ad[2] - ab[2] * ad[1],
ab[2] * ad[0] - ab[0] * ad[2],
ab[0] * ad[1] - ab[1] * ad[0],
];
const len = Math.max(Math.hypot(c[0], c[1], c[2]), 1e-9);
return [c[0] / len, c[1] / len, c[2] / len];
}
/**
* Hängt ein Dreieck an `mesh` an, dessen Winding an `wantNormal` ausgerichtet
* wird (robust gegen die Eck-Reihenfolge) 1:1 wie `mesh.rs::push_tri_oriented`.
*/
function pushTriOriented(mesh: CutMesh, a: V3, b: V3, c: V3, wantNormal: V3): void {
const ab: V3 = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
const ac: V3 = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
const gn: V3 = [
ab[1] * ac[2] - ab[2] * ac[1],
ab[2] * ac[0] - ab[0] * ac[2],
ab[0] * ac[1] - ab[1] * ac[0],
];
const dot = gn[0] * wantNormal[0] + gn[1] * wantNormal[1] + gn[2] * wantNormal[2];
const [v0, v1, v2] = dot < 0 ? [a, c, b] : [a, b, c];
const base = mesh.positions.length / 3;
mesh.positions.push(v0[0], v0[1], v0[2], v1[0], v1[1], v1[2], v2[0], v2[1], v2[2]);
mesh.indices.push(base, base + 1, base + 2);
}
/** Hängt ein Quad (vier Ecken als Ring) als zwei orientierte Dreiecke an. */
function pushQuadOriented(mesh: CutMesh, a: V3, b: V3, c: V3, d: V3, wantNormal: V3): void {
pushTriOriented(mesh, a, b, c, wantNormal);
pushTriOriented(mesh, a, c, d, wantNormal);
}
/**
* Zerlegt das Rechteck `[uLo,uHi]×[zLo,zHi]` MINUS `holes` (`[u0,u1,z0,z1]`) in
* achsparallele, disjunkte SOLIDE Teilrechtecke Koordinaten-Kompression, 1:1
* wie `mesh.rs::solid_subrects` (jede Gitterzelle, deren Mittelpunkt in keinem
* Loch liegt, ist ein solides Teilrechteck). Exportiert für die Zerlegungs-Tests
* (gleiche Rechteck-Anzahl wie die Rust-Zerlegung).
*/
export function solidSubrects(
uLo: number,
uHi: number,
zLo: number,
zHi: number,
holes: Array<[number, number, number, number]>,
): Array<[number, number, number, number]> {
const sortDedup = (v: number[]): number[] => {
const s = [...v].sort((a, b) => a - b);
const out: number[] = [];
for (const x of s) {
if (out.length === 0 || Math.abs(x - out[out.length - 1]) > MIN_SPAN) out.push(x);
}
return out;
};
const usRaw = [uLo, uHi];
const zsRaw = [zLo, zHi];
for (const [a0, a1, hb, ht] of holes) {
usRaw.push(a0, a1);
zsRaw.push(hb, ht);
}
const us = sortDedup(usRaw);
const zs = sortDedup(zsRaw);
const out: Array<[number, number, number, number]> = [];
for (let i = 0; i < us.length - 1; i++) {
const ua = us[i];
const ub = us[i + 1];
if (ub - ua <= MIN_SPAN) continue;
for (let j = 0; j < zs.length - 1; j++) {
const za = zs[j];
const zb = zs[j + 1];
if (zb - za <= MIN_SPAN) continue;
const cu = (ua + ub) * 0.5;
const cz = (za + zb) * 0.5;
const inHole = holes.some(([a0, a1, hb, ht]) => cu > a0 && cu < a1 && cz > hb && cz < ht);
if (!inHole) out.push([ua, ub, za, zb]);
}
}
return out;
}
/**
* Zieht die (ggf. überlappenden) `gaps` vom Intervall `[lo,hi]` ab und liefert
* die verbleibenden, sortierten, disjunkten Teilintervalle 1:1 wie
* `mesh.rs::subtract_intervals` (für Deckel-/Boden-/Kappen-Lücken randständiger
* Löcher).
*/
function subtractIntervals(lo: number, hi: number, gaps: Array<[number, number]>): Array<[number, number]> {
const gs = gaps
.map(([a, b]): [number, number] => [Math.max(a, lo), Math.min(b, hi)])
.filter(([a, b]) => b - a > MIN_SPAN)
.sort((p, q) => p[0] - q[0]);
const out: Array<[number, number]> = [];
let cursor = lo;
for (const [a, b] of gs) {
if (a > cursor + MIN_SPAN) out.push([cursor, a]);
cursor = Math.max(cursor, b);
}
if (hi > cursor + MIN_SPAN) out.push([cursor, hi]);
return out;
}
/**
* Prüft, ob ein Mesh eine GESCHLOSSENE, KONSISTENT NACH AUSSEN gewundene Fläche
* ist (bounds ein Volumen ohne Löcher) über das Gauss-/Divergenz-Kriterium
* statt eines Kanten-Manifold-Zählers:
* n dA = 0 Σ (v1v0)×(v2v0) = 0 über alle Dreiecke (jede offene
* Lücke ODER jedes falsch gewundene/fehlende Dreieck verletzt die Summe).
* signiertes Volumen Σ v0·(v1×v2)/6 > 0 Normalen zeigen NACH AUSSEN.
*
* BEWUSST NICHT der naive jede ungerichtete Kante genau 2×"-Manifold-Check:
* dieses Mesh nutzt (wie die Rust-Referenz) VOLLE Deckel/Boden-Streifen über die
* ganze Breite, während die Langseiten an den Loch-Spalten unterteilt sind das
* erzeugt legitime T-Stösse (eine lange Kante trifft mehrere kurze), die KEINE
* echten Lücken sind. Der naive Kantenzähler würde diese korrekte, wasserdichte
* Geometrie fälschlich als offen" melden; das Gauss-Kriterium ist T-Stoss-robust
* und misst genau die Eigenschaft, auf die es ankommt (dichtes Volumen + Aussen-
* Orientierung die Ursache des hohl/offen"-Bugs war eine invertierte Wicklung
* im IFC-Export, nicht eine fehlende Fläche).
*/
export function isWatertight(mesh: CutMesh, eps = 1e-6): boolean {
const p = mesh.positions;
let ax = 0;
let ay = 0;
let az = 0;
let vol6 = 0;
for (let t = 0; t < mesh.indices.length; t += 3) {
const i0 = mesh.indices[t] * 3;
const i1 = mesh.indices[t + 1] * 3;
const i2 = mesh.indices[t + 2] * 3;
const e1x = p[i1] - p[i0];
const e1y = p[i1 + 1] - p[i0 + 1];
const e1z = p[i1 + 2] - p[i0 + 2];
const e2x = p[i2] - p[i0];
const e2y = p[i2 + 1] - p[i0 + 1];
const e2z = p[i2 + 2] - p[i0 + 2];
const cx = e1y * e2z - e1z * e2y;
const cy = e1z * e2x - e1x * e2z;
const cz = e1x * e2y - e1y * e2x;
ax += cx;
ay += cy;
az += cz;
// v0 · (v1 × v2) = v0 · (e1 × e2) (die reinen v0-Terme heben sich auf).
vol6 += p[i0] * cx + p[i0 + 1] * cy + p[i0 + 2] * cz;
}
return Math.hypot(ax, ay, az) < eps && vol6 > eps;
}
/**
* Erzeugt das Wand-Mesh EINES RWall-Bandes mit ECHTEN Loch-Ausschnitten der
* TS-Port von `mesh.rs::extrude_layer_segment_with_holes` (ohne Gehrung, siehe
* Moduldoc). Aufbau:
* Langseiten (+n / n): Flächen-Rechteck MINUS der Löcher, per {@link
* solidSubrects} in Teilrechtecke zerlegt (je 2 Dreiecke).
* Deckel (y1) / Boden (y0): voller Streifen minus der u-Intervalle rand-
* ständiger Löcher (z. B. eine Tür am Boden).
* Stirnkappen (a=0 / a=length): volle Höhe minus z-Intervalle randständiger
* Löcher.
* Laibungen je Loch: bis zu vier Quads (links/rechts/unten/oben), nur für die
* im Wand-Inneren liegenden Seiten.
*
* Ohne `holes` (undefined/leer) liefert das die volle Box (Regressionsfall);
* Aufrufer, die für lochlose Bänder die klassische 8-Ecken-Box wollen, prüfen
* das selbst (siehe exportMesh.ts).
*/
export function wallCutMesh(w: RWall): CutMesh {
const mesh: CutMesh = { positions: [], indices: [] };
const [sx, sy] = w.start;
const [ex, ey] = w.end;
const dx = ex - sx;
const dy = ey - sy;
const length = Math.hypot(dx, dy);
if (length < 1e-9 || w.height <= 1e-9 || w.thickness <= 1e-9) return mesh;
const ux = dx / length;
const uy = dy / length;
// Links-Normale n = (uy, ux) — dieselbe Konvention wie mesh.rs::left_normal.
const n: [number, number] = [-uy, ux];
const y0 = w.baseElevation;
const y1 = w.baseElevation + w.height;
const offA = w.thickness / 2;
const offB = -w.thickness / 2;
const off = (p: [number, number], s: number): [number, number] => [p[0] + n[0] * s, p[1] + n[1] * s];
const axisPoint = (a: number): [number, number] => [sx + ux * a, sy + uy * a];
// Plan-Punkt bei Achsen-Position `a`, Dicken-Versatz `offv` (keine Gehrung).
const gAt = (a: number, offv: number): [number, number] => off(axisPoint(a), offv);
const wpt = (g: [number, number], y: number): V3 => [g[0], y, g[1]];
// Löcher auf [0,length]×[y0,y1] klemmen, entartete verwerfen.
const hs: Array<[number, number, number, number]> = [];
for (const h of w.holes ?? []) {
const a0 = Math.max(h.from, 0);
const a1 = Math.min(h.to, length);
const zb = Math.max(h.zBottom, y0);
const zt = Math.min(h.zTop, y1);
if (a1 - a0 > MIN_SPAN && zt - zb > MIN_SPAN) hs.push([a0, a1, zb, zt]);
}
// ── Langseiten (+n / n): Rechteck-Gitter minus Löcher. ────────────────────
const np: V3 = [n[0], 0, n[1]];
const nn: V3 = [-n[0], 0, -n[1]];
for (const [ua, ub, za, zb] of solidSubrects(0, length, y0, y1, hs)) {
pushQuadOriented(
mesh,
wpt(gAt(ua, offA), za),
wpt(gAt(ub, offA), za),
wpt(gAt(ub, offA), zb),
wpt(gAt(ua, offA), zb),
np,
);
pushQuadOriented(
mesh,
wpt(gAt(ua, offB), za),
wpt(gAt(ub, offB), za),
wpt(gAt(ub, offB), zb),
wpt(gAt(ua, offB), zb),
nn,
);
}
// ── Deckel (y1) / Boden (y0): voller Streifen minus randständiger Löcher. ──
const topGaps = hs.filter(([, , , zt]) => zt >= y1 - MIN_SPAN).map(([a0, a1]): [number, number] => [a0, a1]);
for (const [ua, ub] of subtractIntervals(0, length, topGaps)) {
pushQuadOriented(
mesh,
wpt(gAt(ua, offA), y1),
wpt(gAt(ub, offA), y1),
wpt(gAt(ub, offB), y1),
wpt(gAt(ua, offB), y1),
[0, 1, 0],
);
}
const botGaps = hs.filter(([, , zb]) => zb <= y0 + MIN_SPAN).map(([a0, a1]): [number, number] => [a0, a1]);
for (const [ua, ub] of subtractIntervals(0, length, botGaps)) {
pushQuadOriented(
mesh,
wpt(gAt(ua, offA), y0),
wpt(gAt(ub, offA), y0),
wpt(gAt(ub, offB), y0),
wpt(gAt(ua, offB), y0),
[0, -1, 0],
);
}
// ── Stirnkappen (a=0 / a=length): volle Höhe minus randständiger Löcher. ───
const startNormal = quadNormal(wpt(gAt(0, offB), y0), wpt(gAt(0, offA), y0), wpt(gAt(0, offB), y1));
const startGaps = hs.filter(([a0]) => a0 <= MIN_SPAN).map(([, , zb, zt]): [number, number] => [zb, zt]);
for (const [za, zb] of subtractIntervals(y0, y1, startGaps)) {
pushQuadOriented(
mesh,
wpt(gAt(0, offB), za),
wpt(gAt(0, offA), za),
wpt(gAt(0, offA), zb),
wpt(gAt(0, offB), zb),
startNormal,
);
}
const endNormal = quadNormal(wpt(gAt(length, offA), y0), wpt(gAt(length, offB), y0), wpt(gAt(length, offA), y1));
const endGaps = hs.filter(([, a1]) => a1 >= length - MIN_SPAN).map(([, , zb, zt]): [number, number] => [zb, zt]);
for (const [za, zb] of subtractIntervals(y0, y1, endGaps)) {
pushQuadOriented(
mesh,
wpt(gAt(length, offA), za),
wpt(gAt(length, offB), za),
wpt(gAt(length, offB), zb),
wpt(gAt(length, offA), zb),
endNormal,
);
}
// ── Laibungen je Loch: nur die im Wand-Inneren liegenden Seiten. ──────────
for (const [a0, a1, zb, zt] of hs) {
// Linke Laibung (bei a0), Normale +u (in die Öffnung).
if (a0 > MIN_SPAN) {
pushQuadOriented(
mesh,
wpt(gAt(a0, offA), zb),
wpt(gAt(a0, offB), zb),
wpt(gAt(a0, offB), zt),
wpt(gAt(a0, offA), zt),
[ux, 0, uy],
);
}
// Rechte Laibung (bei a1), Normale u.
if (a1 < length - MIN_SPAN) {
pushQuadOriented(
mesh,
wpt(gAt(a1, offA), zb),
wpt(gAt(a1, offB), zb),
wpt(gAt(a1, offB), zt),
wpt(gAt(a1, offA), zt),
[-ux, 0, -uy],
);
}
// Untere Laibung (bei zb = Brüstungsoberkante), Normale +Y.
if (zb > y0 + MIN_SPAN) {
pushQuadOriented(
mesh,
wpt(gAt(a0, offA), zb),
wpt(gAt(a1, offA), zb),
wpt(gAt(a1, offB), zb),
wpt(gAt(a0, offB), zb),
[0, 1, 0],
);
}
// Obere Laibung (bei zt = Sturzunterkante), Normale Y.
if (zt < y1 - MIN_SPAN) {
pushQuadOriented(
mesh,
wpt(gAt(a0, offA), zt),
wpt(gAt(a1, offA), zt),
wpt(gAt(a1, offB), zt),
wpt(gAt(a0, offB), zt),
[0, -1, 0],
);
}
}
return mesh;
}
+11 -3
View File
@@ -19,6 +19,8 @@ import { createLayoutSlice } from "./layoutSlice";
import type { LayoutSlice } from "./layoutSlice";
import { createSiteSlice } from "./siteSlice";
import type { SiteSlice } from "./siteSlice";
import { createNotifySlice } from "./notifySlice";
import type { NotifySlice } from "./notifySlice";
/** Gesamtzustand: Vereinigung aller Slice-Felder + Actions. */
export type RootState = ProjectSlice &
@@ -26,17 +28,23 @@ export type RootState = ProjectSlice &
SelectionSlice &
ViewSlice &
LayoutSlice &
SiteSlice;
SiteSlice &
NotifySlice;
const store = createStore<RootState>((api) => ({
// Jede Slice-Factory bekommt die volle RootState-API. Die Slices tippen nur
// ihren eigenen Ausschnitt; die Cast auf die enge Slice-API ist sicher, weil
// `set/get` immer über den Gesamt-RootState laufen. ProjectSlice bringt die
// History (undo/redo) gleich mit (setProject ist der einzige Durchlauf-
// punkt jeder Projekt-Mutation, siehe projectSlice.ts).
// punkt jeder Projekt-Mutation, siehe projectSlice.ts) und braucht `notify`
// (Cross-Slice) für die Lösch-Schutz-Warnungen (statt window.alert, siehe
// notifySlice.ts).
...createProjectSlice(
api as unknown as StoreApi<ProjectSlice & HistorySlice & { activeLevelId: string }>,
api as unknown as StoreApi<
ProjectSlice & HistorySlice & { activeLevelId: string } & Pick<NotifySlice, "notify">
>,
),
...createNotifySlice(api as unknown as StoreApi<NotifySlice>),
...createSelectionSlice(api as unknown as StoreApi<SelectionSlice>),
...createViewSlice(api as unknown as StoreApi<ViewSlice>),
...createLayoutSlice(api as unknown as StoreApi<LayoutSlice>),
+61
View File
@@ -0,0 +1,61 @@
/**
* Notify-Slice (`src/state/notifySlice.ts`) nicht-blockierende
* Kurzmeldungen (Ersatz für `window.alert`, das im Tauri-WKWebView deaktiviert
* ist). Getestet wird nur der reine State (Hinzufügen/Entfernen); der
* Auto-Dismiss-Timer lebt bewusst in der Toast-Komponente (src/ui/Toast.tsx),
* nicht in der Slice, und ist hier daher kein Thema.
*/
import { describe, it, expect, beforeEach } from "vitest";
import { getState, setState } from "./appStore";
beforeEach(() => {
setState({ notifications: [] });
});
describe("notifySlice", () => {
it("notify fügt eine Meldung hinzu (Default-Art: info)", () => {
getState().notify("Hallo");
const notes = getState().notifications;
expect(notes.length).toBe(1);
expect(notes[0].message).toBe("Hallo");
expect(notes[0].kind).toBe("info");
expect(notes[0].id).toBeTruthy();
});
it("notify übernimmt die übergebene Art", () => {
getState().notify("Achtung", "warning");
expect(getState().notifications[0].kind).toBe("warning");
});
it("mehrere notify()-Aufrufe erzeugen unterschiedliche ids, in Reihenfolge angehängt", () => {
getState().notify("A");
getState().notify("B");
const notes = getState().notifications;
expect(notes.map((n) => n.message)).toEqual(["A", "B"]);
expect(new Set(notes.map((n) => n.id)).size).toBe(2);
});
it("dismissNotify entfernt genau die Meldung mit der gegebenen id", () => {
getState().notify("A");
getState().notify("B");
const [first, second] = getState().notifications;
getState().dismissNotify(first.id);
const remaining = getState().notifications;
expect(remaining.length).toBe(1);
expect(remaining[0].id).toBe(second.id);
});
it("dismissNotify bei unbekannter id ist ein No-Op", () => {
getState().notify("A");
const before = getState().notifications;
getState().dismissNotify("unbekannte-id");
expect(getState().notifications).toEqual(before);
});
});
+58
View File
@@ -0,0 +1,58 @@
// Notify-Slice: nicht-blockierende Kurzmeldungen (Toasts). Ersetzt
// `window.alert`, das im Tauri-WKWebView deaktiviert ist (tut nichts) und
// Nutzer-Warnungen (z. B. „X wird noch verwendet") lautlos verschluckte.
//
// Reiner Zustand + zwei Actions — das Rendern (gestapelter Container) UND der
// Auto-Dismiss-Timer leben bewusst in der Toast-Komponente
// (src/ui/Toast.tsx), nicht hier: ein Timer pro GEMOUNTETER Meldung ist
// robuster als einer pro `notify()`-Aufruf (kein doppeltes Aufräumen bei
// Store-Reload/Tests, kein Timer, der eine Komponente überlebt, die es nie zu
// sehen bekam). Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import type { StoreApi } from "./store";
/** Art der Meldung — steuert nur den Farbakzent im Toast. */
export type NotifyKind = "info" | "warning" | "error";
/** Eine einzelne Toast-Meldung. */
export interface Notification {
id: string;
message: string;
kind: NotifyKind;
}
/** Felder/Actions, die diese Slice in den RootState beisteuert. */
export interface NotifySlice {
notifications: Notification[];
/** Fügt eine Meldung mit eindeutiger id hinzu (Default-Art: "info"). */
notify: (message: string, kind?: NotifyKind) => void;
/** Entfernt eine Meldung per id (No-op bei unbekannter id). */
dismissNotify: (id: string) => void;
}
// Monoton wachsender Zähler zusätzlich zum Zeitstempel: verhindert doppelte
// ids, falls mehrere `notify()`-Aufrufe innerhalb derselben Millisekunde
// passieren (z. B. mehrere Löschversuche in einer Schleife).
let counter = 0;
function nextId(): string {
counter += 1;
return `notify-${Date.now()}-${counter}`;
}
export function createNotifySlice(api: StoreApi<NotifySlice>): NotifySlice {
const { set } = api;
return {
notifications: [],
notify: (message, kind = "info") =>
set((s) => ({
notifications: [...s.notifications, { id: nextId(), message, kind }],
})),
dismissNotify: (id) =>
set((s) => ({
notifications: s.notifications.filter((n) => n.id !== id),
})),
};
}
+12 -4
View File
@@ -30,6 +30,7 @@ import { t } from "../i18n";
import type { StoreApi } from "./store";
import { createHistorySlice, pushHistoryPatch } from "./historySlice";
import type { HistorySlice } from "./historySlice";
import type { NotifyKind } from "./notifySlice";
/** Felder, die diese Slice in den RootState beisteuert. */
export interface ProjectSlice {
@@ -286,6 +287,9 @@ export interface ProjectSlice {
*/
type ProjectSliceDeps = {
activeLevelId: string;
/** Nicht-blockierende Kurzmeldung (Toast) Ersatz für `window.alert`, das
* im Tauri-WKWebView deaktiviert ist (siehe notifySlice.ts). */
notify: (message: string, kind?: NotifyKind) => void;
} & HistorySlice;
export function createProjectSlice(
@@ -579,7 +583,7 @@ export function createProjectSlice(
codes.has(o.categoryCode),
);
if (usedByWall || usedByDoor || usedByOpening) {
window.alert(t("alert.layerInUse", { code: src.code, name: src.name }));
get().notify(t("alert.layerInUse", { code: src.code, name: src.name }), "warning");
return p;
}
return { ...p, layers: removeByCode(p.layers, code) };
@@ -664,7 +668,7 @@ export function createProjectSlice(
);
if (used) {
const name = p.components.find((c) => c.id === id)?.name ?? id;
window.alert(t("alert.componentInUse", { name }));
get().notify(t("alert.componentInUse", { name }), "warning");
return p;
}
return { ...p, components: p.components.filter((c) => c.id !== id) };
@@ -695,7 +699,7 @@ export function createProjectSlice(
const used = p.components.some((c) => c.hatchId === id);
if (used) {
const name = p.hatches.find((h) => h.id === id)?.name ?? id;
window.alert(t("alert.hatchInUse", { name }));
get().notify(t("alert.hatchInUse", { name }), "warning");
return p;
}
return { ...p, hatches: p.hatches.filter((h) => h.id !== id) };
@@ -726,7 +730,7 @@ export function createProjectSlice(
const used = p.hatches.some((h) => h.lineStyleId === id);
if (used) {
const name = p.lineStyles.find((l) => l.id === id)?.name ?? id;
window.alert(t("alert.lineStyleInUse", { name }));
get().notify(t("alert.lineStyleInUse", { name }), "warning");
return p;
}
return { ...p, lineStyles: p.lineStyles.filter((l) => l.id !== id) };
@@ -1373,6 +1377,8 @@ export function drawingVertices(d: import("../model/types").Drawing2D): Vec2[] {
{ x: g.min.x, y: g.max.y },
];
}
// Text: EIN Griff am Ankerpunkt (zum Verschieben; siehe moveGrip).
if (g.shape === "text") return [g.at];
return [];
}
@@ -1412,6 +1418,8 @@ function moveGrip(
const max = { x: Math.max(pt.x, opp.x), y: Math.max(pt.y, opp.y) };
return { ...d, geom: { ...g, min, max } };
}
// Text: der einzige Griff (Index 0) sitzt am Ankerpunkt → verschiebt ihn.
if (g.shape === "text") return { ...d, geom: { ...g, at: pt } };
return d;
}),
};
+59
View File
@@ -0,0 +1,59 @@
// Unit-Tests für projectStore.ts — Guard-Verhalten ohne IndexedDB.
//
// vitest läuft mit environment: "node" (siehe vitest.config.ts); dort
// existiert `indexedDB` nicht, und `fake-indexeddb` ist keine devDependency
// dieses Repos. Getestet wird daher primär, dass die API auch ohne
// IndexedDB sauber degradiert (siehe CONVENTIONS/Auftrag): `loadProject`
// liefert `null`, `listProjects` liefert `[]`, `saveProject`/`deleteProject`
// werfen nicht.
import { describe, it, expect } from "vitest";
import { saveProject, loadProject, listProjects, deleteProject } from "./projectStore";
import type { Project } from "../model/types";
/** Minimalprojekt (analog model/wall.test.ts) — genügt der Project-Form. */
function makeProject(overrides?: Partial<Project>): Project {
return {
id: "t",
name: "T",
lineStyles: [],
hatches: [],
components: [],
wallTypes: [],
drawingLevels: [],
layers: [],
walls: [],
doors: [],
...overrides,
} as Project;
}
describe("projectStore — Guard ohne IndexedDB", () => {
it("loadProject liefert null", async () => {
await expect(loadProject("irgendein-projekt")).resolves.toBeNull();
});
it("listProjects liefert leere Liste", async () => {
await expect(listProjects()).resolves.toEqual([]);
});
it("saveProject wirft nicht (no-op)", async () => {
await expect(saveProject("p1", makeProject())).resolves.toBeUndefined();
});
it("deleteProject wirft nicht (no-op)", async () => {
await expect(deleteProject("p1")).resolves.toBeUndefined();
});
it("loadProject mit leerem Namen liefert null, ohne indexedDB zu berühren", async () => {
await expect(loadProject("")).resolves.toBeNull();
});
it("saveProject mit leerem Namen ist ein No-op", async () => {
await expect(saveProject("", makeProject())).resolves.toBeUndefined();
});
it("deleteProject mit leerem Namen ist ein No-op", async () => {
await expect(deleteProject("")).resolves.toBeUndefined();
});
});
+184
View File
@@ -0,0 +1,184 @@
// Projekt-Persistenz — IndexedDB (Browser-nativer Speicher-Kern).
//
// Speichert vollständige Projekte (inkl. Meshes/grosser JSON-Nutzlast) lokal
// im Browser. Analog zu visibilitySets.ts: reine Speicher-Helfer OHNE React-
// Abhängigkeit, sämtlicher Zugriff defensiv (SSR/Tests/Privatmodus) — hier
// mit `indexedDB` statt `localStorage`, da localStorage-Kontingente für
// Projekt-JSON (Wände, Meshes, …) zu klein sind.
//
// Hinweis: Dies ist nur der Speicher-KERN (CRUD-API). UI-Verdrahtung
// (Speichern/Öffnen-Menü, zuletzt-geöffnet-Liste, Auto-Save) ist NICHT Teil
// dieses Moduls — folgt als separates Item.
import type { Project } from "../model/types";
/** Name der IndexedDB-Datenbank. */
const DB_NAME = "dossier";
/** Name des ObjectStores (keyPath „name"). */
const STORE_NAME = "projects";
/** DB-Schemaversion. */
const DB_VERSION = 1;
/** Ein gespeicherter Datensatz je Projekt. */
interface ProjectRecord {
name: string;
savedAt: number;
project: Project;
}
/** `true`, sobald die Fehlmeldung „IndexedDB nicht verfügbar" geloggt wurde (nur einmal). */
let warnedUnavailable = false;
/** Warnt einmalig auf der Konsole, dass IndexedDB fehlt (Tests/SSR/Privatmodus). */
function warnUnavailableOnce(): void {
if (warnedUnavailable) return;
warnedUnavailable = true;
try {
// eslint-disable-next-line no-console
console.warn(
"[projectStore] IndexedDB nicht verfügbar — Projekt-Persistenz deaktiviert.",
);
} catch {
// Konsole kann in exotischen Umgebungen fehlen — ignorieren.
}
}
/** Liefert `true`, wenn `indexedDB` in dieser Umgebung nutzbar ist. */
function isAvailable(): boolean {
try {
return typeof indexedDB !== "undefined";
} catch {
// Zugriff kann werfen (z. B. strikte SSR-Sandboxen).
return false;
}
}
/** Gecachtes Öffnen-Promise, damit `indexedDB.open` nur einmal läuft. */
let dbPromise: Promise<IDBDatabase> | null = null;
/** Öffnet (bzw. erstellt) die Datenbank und liefert die geöffnete Verbindung. */
function openDb(): Promise<IDBDatabase> {
if (dbPromise) return dbPromise;
dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
let request: IDBOpenDBRequest;
try {
request = indexedDB.open(DB_NAME, DB_VERSION);
} catch (err) {
reject(err instanceof Error ? err : new Error(String(err)));
return;
}
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: "name" });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error ?? new Error("indexedDB.open fehlgeschlagen"));
});
// Bei Fehlschlag den Cache zurücksetzen, damit ein späterer Aufruf erneut
// versuchen kann (statt dauerhaft am fehlgeschlagenen Promise zu hängen).
dbPromise.catch(() => {
dbPromise = null;
});
return dbPromise;
}
/**
* Speichert ein Projekt unter `name` (überschreibt einen vorhandenen
* Datensatz gleichen Namens). Ohne IndexedDB: no-op (wirft nicht).
*/
export async function saveProject(name: string, project: Project): Promise<void> {
if (!name) return;
if (!isAvailable()) {
warnUnavailableOnce();
return;
}
try {
const db = await openDb();
const record: ProjectRecord = { name, savedAt: Date.now(), project };
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readwrite");
tx.objectStore(STORE_NAME).put(record);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error ?? new Error("Transaktion fehlgeschlagen"));
tx.onabort = () => reject(tx.error ?? new Error("Transaktion abgebrochen"));
});
} catch {
// Kontingent überschritten, Zugriff verweigert o. Ä. — still ignorieren
// (analog writeJson in visibilitySets.ts).
}
}
/**
* Lädt ein Projekt nach Namen. Liefert `null`, wenn es nicht existiert, die
* Umgebung IndexedDB nicht unterstützt oder der Zugriff fehlschlägt.
*/
export async function loadProject(name: string): Promise<Project | null> {
if (!name) return null;
if (!isAvailable()) {
warnUnavailableOnce();
return null;
}
try {
const db = await openDb();
const record = await new Promise<ProjectRecord | undefined>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readonly");
const request = tx.objectStore(STORE_NAME).get(name);
request.onsuccess = () => resolve(request.result as ProjectRecord | undefined);
request.onerror = () => reject(request.error ?? new Error("Lesen fehlgeschlagen"));
});
return record?.project ?? null;
} catch {
return null;
}
}
/**
* Listet alle gespeicherten Projekte (Name + Speicherzeitpunkt), alphabetisch
* nach Name sortiert. Ohne IndexedDB oder bei Fehler: leere Liste.
*/
export async function listProjects(): Promise<{ name: string; savedAt: number }[]> {
if (!isAvailable()) {
warnUnavailableOnce();
return [];
}
try {
const db = await openDb();
const records = await new Promise<ProjectRecord[]>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readonly");
const request = tx.objectStore(STORE_NAME).getAll();
request.onsuccess = () => resolve((request.result as ProjectRecord[]) ?? []);
request.onerror = () => reject(request.error ?? new Error("Lesen fehlgeschlagen"));
});
return records
.map((r) => ({ name: r.name, savedAt: r.savedAt }))
.sort((a, b) => a.name.localeCompare(b.name));
} catch {
return [];
}
}
/**
* Löscht ein gespeichertes Projekt. Ohne IndexedDB oder bei Fehler: no-op
* (wirft nicht).
*/
export async function deleteProject(name: string): Promise<void> {
if (!name) return;
if (!isAvailable()) {
warnUnavailableOnce();
return;
}
try {
const db = await openDb();
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readwrite");
tx.objectStore(STORE_NAME).delete(name);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error ?? new Error("Transaktion fehlgeschlagen"));
tx.onabort = () => reject(tx.error ?? new Error("Transaktion abgebrochen"));
});
} catch {
// still ignorieren — analog removeKey in visibilitySets.ts.
}
}
+180 -2
View File
@@ -21,20 +21,26 @@ import {
import type {
AttributeSource,
Ceiling,
Column,
ColumnProfile,
Drawing2D,
Drawing2DGeom,
ExtrudedSolid,
LayerCategory,
Opening,
Project,
Room,
SiaCategory,
SliceTermination,
Stair,
StairShape,
VerticalAnchor,
Wall,
WallReferenceLine,
} from "../model/types";
import { evaluateRoom } from "../geometry/roomArea";
import { columnVerticalExtent } from "../model/wall";
import { columnFootprint } from "../geometry/column";
import { evaluateRoom, polygonArea } from "../geometry/roomArea";
import {
ceilingVerticalExtent,
nextFloorAbove,
@@ -70,6 +76,8 @@ export interface FloorChoice {
*/
export interface WallInfo {
referenceLine: WallReferenceLine;
/** Terminierungs-Regel am Deckenanschluss (Default "both" = heutiges Verhalten). */
sliceTermination: SliceTermination;
wallTypeId: string;
/** Aktuelle Gesamtdicke (Meter). */
thickness: number;
@@ -91,6 +99,14 @@ export interface WallInfo {
/** Aufgelöste absolute UK/OK (Meter) + abgeleitete Höhe. */
zBottom: number;
zTop: number;
/** Achslänge der Wand (Meter). */
length: number;
/** Bruttovolumen = Länge × Dicke × Höhe (m³, ohne Öffnungsabzug). */
grossVolume: number;
/** Summe der Öffnungsvolumina (Fenster/Türen) dieser Wand (m³). */
openingVolume: number;
/** Nettovolumen = Brutto Öffnungen (m³, ≥ 0). */
netVolume: number;
}
/**
@@ -106,6 +122,8 @@ export interface CeilingInfo {
singleLayer: boolean;
/** Grundfläche der Decke (m²). */
area: number;
/** Volumen = Fläche × Dicke (m³). */
volume: number;
/** Verfügbare Deckentyp-Presets (dediziert, `project.ceilingTypes`). */
ceilingTypes: WallTypeChoice[];
/** Geschoss, dem die Decke zugeordnet ist. */
@@ -115,6 +133,8 @@ export interface CeilingInfo {
floors: FloorChoice[];
/** OK-Bindung (undefined = Geschoss-Oberkante). */
top?: VerticalAnchor;
/** UK-Bindung (undefined = OK Dicke). */
bottom?: VerticalAnchor;
/** Aufgelöste absolute UK/OK (Meter). */
zBottom: number;
zTop: number;
@@ -146,6 +166,8 @@ export interface OpeningInfo {
doorType?: "normal" | "wandoeffnung";
/** Nur Tür: Sturzlinien (keine/innen/aussen/beide). Fehlt → "beide". */
lintelLines?: "keine" | "innen" | "aussen" | "beide";
/** Zugewiesener Tür-/Fenstertyp (Bibliothek, `Opening.typeId`) oder undefined. */
typeId?: string;
/** Aufgelöste absolute UK/OK (Meter). */
zBottom: number;
zTop: number;
@@ -171,6 +193,8 @@ export interface StairInfo {
up: boolean;
/** Referenzpunkt der Laufbreite (links/mitte/rechts). Fehlt → "mitte". */
referenz?: "links" | "mitte" | "rechts";
/** Zugewiesener Treppentyp (Bibliothek, `Stair.typeId`) oder undefined. */
typeId?: string;
/** Geschoss, dem die Treppe zugeordnet ist. */
floorId: string;
floorName: string;
@@ -198,6 +222,41 @@ export interface RoomInfo {
floorName: string;
}
/**
* Extrusions-spezifische Attribute für das Object-Info-Panel (nur bei Auswahl
* genau eines extrudierten Körpers, truck-Integration, gesetzt).
*/
export interface ExtrudedSolidInfo {
/** Extrusionshöhe in Metern (editierbar). */
height: number;
/** Verjüngung 0 (Prisma) … 1 (Spitze), editierbar. */
taper: number;
/** Grundfläche des Profils (m²). */
area: number;
/** Geschoss, dessen `baseElevation` die UK des Körpers bestimmt. */
floorId: string;
floorName: string;
}
/**
* Stützen-spezifische Attribute für das Object-Info-Panel (nur bei Auswahl genau
* einer Stütze gesetzt). Anzeigefertige Werte für die Panel-Felder.
*/
export interface ColumnInfo {
/** Profil (Rechteck/Kreis) — bestimmt, welche Masse editierbar sind. */
profile: ColumnProfile;
/** Höhe (Meter, editierbar). */
height: number;
/** Drehung (Grad, editierbar) — intern in Radiant gespeichert. */
rotationDeg: number;
/** Geschoss, dem die Stütze zugeordnet ist. */
floorId: string;
floorName: string;
/** Aufgelöste absolute UK/OK (Meter). */
zBottom: number;
zTop: number;
}
/** Achsparallele Bounding-Box eines Elements in Modell-Metern. */
export interface SelectionBBox {
minX: number;
@@ -212,7 +271,16 @@ export interface SelectionBBox {
* gezeichneten Wert zeigt. `fillHatchId`/`closed` sind nur für Drawing2D sinnvoll.
*/
export interface Selection {
kind: "wall" | "ceiling" | "opening" | "stair" | "room" | "drawing2d" | "door";
kind:
| "wall"
| "ceiling"
| "opening"
| "stair"
| "room"
| "drawing2d"
| "door"
| "extrudedSolid"
| "column";
id: string;
/** Grafik-Kategorie (Ebene) des Elements. */
categoryCode: string;
@@ -276,6 +344,10 @@ export interface Selection {
stair?: StairInfo;
/** Raum-Attribute (nur bei `kind === "room"`). */
room?: RoomInfo;
/** Extrusions-Attribute (nur bei `kind === "extrudedSolid"`). */
extrudedSolid?: ExtrudedSolidInfo;
/** Stützen-Attribute (nur bei `kind === "column"`). */
column?: ColumnInfo;
}
const WALL_FALLBACK_MM = 0.18;
@@ -354,8 +426,18 @@ function wallSelection(project: Project, wall: Wall): Selection {
const floor = project.drawingLevels.find((z) => z.id === wall.floorId);
const { zBottom, zTop } = wallVerticalExtent(project, wall);
const above = nextFloorAbove(project, wall);
// Volumen aus Achslänge × Gesamtdicke × Höhe, abzüglich der Öffnungen (jede
// Öffnung durchbricht die volle Wanddicke: Breite × Höhe × Dicke).
const length = Math.hypot(wall.end.x - wall.start.x, wall.end.y - wall.start.y);
const wallHeight = Math.max(0, zTop - zBottom);
const grossVolume = length * thickness * wallHeight;
const openingVolume = (project.openings ?? [])
.filter((o) => o.hostWallId === wall.id)
.reduce((sum, o) => sum + o.width * o.height * thickness, 0);
const netVolume = Math.max(0, grossVolume - openingVolume);
const wallInfo: WallInfo = {
referenceLine: wall.referenceLine ?? "center",
sliceTermination: wall.sliceTermination ?? "both",
wallTypeId: wall.wallTypeId,
thickness,
singleLayer: wt.layers.length <= 1,
@@ -376,6 +458,10 @@ function wallSelection(project: Project, wall: Wall): Selection {
top: wall.top,
zBottom,
zTop,
length,
grossVolume,
openingVolume,
netVolume,
};
// bbox aus der Wandachse (start/end); die Schichtdicke bleibt unberücksichtigt,
// damit die Box der Achs-Geometrie folgt (genügt für die Paletten).
@@ -415,6 +501,7 @@ function ceilingSelection(project: Project, ceiling: Ceiling): Selection {
thickness,
singleLayer: wt.layers.length <= 1,
area: ceilingArea(ceiling.outline),
volume: ceilingArea(ceiling.outline) * thickness,
ceilingTypes: (project.ceilingTypes ?? []).map((t) => ({
id: t.id,
name: t.name,
@@ -428,6 +515,7 @@ function ceilingSelection(project: Project, ceiling: Ceiling): Selection {
.filter((z) => z.kind === "floor")
.map((z) => ({ id: z.id, name: z.name })),
top: ceiling.top,
bottom: ceiling.bottom,
zBottom,
zTop,
};
@@ -494,6 +582,7 @@ function openingSelection(project: Project, o: Opening): Selection {
wingCount: o.wingCount,
doorType: o.doorType,
lintelLines: o.lintelLines,
typeId: o.typeId,
zBottom: ext.zBottom,
zTop: ext.zTop,
};
@@ -530,6 +619,7 @@ function stairSelection(project: Project, s: Stair): Selection {
treadDepth: geo.treadDepth,
up: s.up !== false,
referenz: s.referenz,
typeId: s.typeId,
floorId: s.floorId,
floorName: floor?.name ?? s.floorId,
zBottom,
@@ -589,6 +679,84 @@ function roomSelection(project: Project, r: Room): Selection {
};
}
/** Auswahl-Farbe der Extrusions-Footprints (identisch zum Grundriss-Umriss/3D-Orange). */
const EXTRUSION_COLOR = "#d98c40";
/** Selektion für einen extrudierten Körper ableiten (truck-Integration). */
function extrudedSolidSelection(project: Project, s: ExtrudedSolid): Selection {
const floor = project.drawingLevels.find((z) => z.id === s.levelId);
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity;
for (const p of s.points) {
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 info: ExtrudedSolidInfo = {
height: s.height,
taper: s.taper ?? 0,
area: polygonArea(s.points),
floorId: s.levelId,
floorName: floor?.name ?? s.levelId,
};
return {
kind: "extrudedSolid",
id: s.id,
categoryCode: "",
color: EXTRUSION_COLOR,
weightMm: WALL_FALLBACK_MM,
fillHatchId: null,
fillColor: null,
closed: true,
bbox: { minX, minY, maxX, maxY },
extrudedSolid: info,
};
}
/** Umriss-/Auswahlfarbe der Stützen-Poché (neutrales Tragwerks-Grau). */
const COLUMN_COLOR = "#3a3f47";
/** Selektion für eine Stütze (Column) ableiten. */
function columnSelection(project: Project, col: Column): Selection {
const floor = project.drawingLevels.find((z) => z.id === col.floorId);
const category = findCategory(project.layers, col.categoryCode);
const pts = columnFootprint(col);
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)) minX = minY = maxX = maxY = 0;
const { zBottom, zTop } = columnVerticalExtent(project, col);
const info: ColumnInfo = {
profile: col.profile,
height: col.height,
rotationDeg: ((col.rotation ?? 0) * 180) / Math.PI,
floorId: col.floorId,
floorName: floor?.name ?? col.floorId,
zBottom,
zTop,
};
return {
kind: "column",
id: col.id,
categoryCode: col.categoryCode,
color: col.color ?? category?.color ?? COLUMN_COLOR,
weightMm: WALL_FALLBACK_MM,
bbox: { minX, minY, maxX, maxY },
column: info,
};
}
/** Selektion für ein 2D-Zeichenelement ableiten (gleiche Auflösung wie generatePlan). */
function drawingSelection(project: Project, d: Drawing2D): Selection {
const ls = d.lineStyleId
@@ -640,11 +808,21 @@ export function deriveSelection(
selectedOpeningId: string | null = null,
selectedStairId: string | null = null,
selectedRoomId: string | null = null,
selectedExtrudedSolidId: string | null = null,
selectedColumnId: string | null = null,
): Selection | null {
if (selectedDrawingId) {
const d = project.drawings2d.find((x) => x.id === selectedDrawingId);
return d ? drawingSelection(project, d) : null;
}
if (selectedExtrudedSolidId) {
const s = (project.extrudedSolids ?? []).find((x) => x.id === selectedExtrudedSolidId);
return s ? extrudedSolidSelection(project, s) : null;
}
if (selectedColumnId) {
const c = (project.columns ?? []).find((x) => x.id === selectedColumnId);
return c ? columnSelection(project, c) : null;
}
if (selectedOpeningId) {
const o = (project.openings ?? []).find((x) => x.id === selectedOpeningId);
if (o) return openingSelection(project, o);
+12
View File
@@ -19,6 +19,10 @@ export interface SelectionSlice {
selectedStairIds: string[];
// Gewählte Räume als MENGE von IDs — getrennt von den übrigen Kanälen.
selectedRoomIds: string[];
// Gewählte extrudierte Körper (truck-Integration) als MENGE von IDs — getrennt.
selectedExtrudedSolidIds: string[];
// Gewählte Stützen (Tragwerk) als MENGE von IDs — getrennt von den übrigen Kanälen.
selectedColumnIds: string[];
setSelectedWallIds: (ids: string[]) => void;
setSelectedDrawingIds: (ids: string[]) => void;
@@ -26,6 +30,8 @@ export interface SelectionSlice {
setSelectedOpeningIds: (ids: string[]) => void;
setSelectedStairIds: (ids: string[]) => void;
setSelectedRoomIds: (ids: string[]) => void;
setSelectedExtrudedSolidIds: (ids: string[]) => void;
setSelectedColumnIds: (ids: string[]) => void;
/** Auswahl (Wände + Decken + Öffnungen + Treppen + 2D-Elemente) leeren. */
clearSelection: () => void;
}
@@ -41,12 +47,16 @@ export function createSelectionSlice(
selectedOpeningIds: [],
selectedStairIds: [],
selectedRoomIds: [],
selectedExtrudedSolidIds: [],
selectedColumnIds: [],
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 }),
setSelectedExtrudedSolidIds: (ids) => set({ selectedExtrudedSolidIds: ids }),
setSelectedColumnIds: (ids) => set({ selectedColumnIds: ids }),
clearSelection: () =>
set({
selectedWallIds: [],
@@ -55,6 +65,8 @@ export function createSelectionSlice(
selectedOpeningIds: [],
selectedStairIds: [],
selectedRoomIds: [],
selectedExtrudedSolidIds: [],
selectedColumnIds: [],
}),
};
}
+158
View File
@@ -0,0 +1,158 @@
// Pure Tests für den Ausschnitte-Baum (state/viewSnapshotFolders.ts):
// Aufbau, Reparent, Zyklus-Schutz, verwaiste Referenzen, Löschen ohne
// Datenverlust. Kein React/DOM.
import { describe, it, expect } from "vitest";
import type { ViewSnapshot, ViewSnapshotFolder } from "../model/types";
import {
buildViewSnapshotTree,
createViewSnapshotFolder,
deleteFolder,
isDescendant,
moveFolder,
moveSnapshotToFolder,
} from "./viewSnapshotFolders";
/** Minimaler ViewSnapshot-Stub (nur id/name/folderId sind hier relevant). */
function snap(id: string, folderId?: string): ViewSnapshot {
return {
id,
name: id,
...(folderId ? { folderId } : {}),
viewType: "grundriss",
view3d: "top",
scaleDenominator: 100,
detail: "mittel",
activeLevelId: "L0",
layerVisibility: {},
drawingVisibility: {},
} as ViewSnapshot;
}
function folder(id: string, parentId?: string): ViewSnapshotFolder {
return createViewSnapshotFolder(id, id, parentId);
}
describe("createViewSnapshotFolder", () => {
it("lässt parentId weg, wenn nicht gesetzt (Wurzelebene)", () => {
const f = createViewSnapshotFolder("f1", "Ordner");
expect(f).toEqual({ id: "f1", name: "Ordner" });
expect("parentId" in f).toBe(false);
});
it("setzt parentId, wenn gegeben", () => {
expect(createViewSnapshotFolder("f2", "Sub", "f1").parentId).toBe("f1");
});
});
describe("buildViewSnapshotTree", () => {
it("verschachtelt Ordner + Ausschnitte", () => {
const folders = [folder("f1"), folder("f2", "f1")];
const snaps = [snap("s0"), snap("s1", "f1"), snap("s2", "f2")];
const tree = buildViewSnapshotTree(folders, snaps);
expect(tree.rootSnapshots.map((s) => s.id)).toEqual(["s0"]);
expect(tree.rootFolders).toHaveLength(1);
const f1 = tree.rootFolders[0];
expect(f1.folder.id).toBe("f1");
expect(f1.snapshots.map((s) => s.id)).toEqual(["s1"]);
expect(f1.subfolders).toHaveLength(1);
expect(f1.subfolders[0].folder.id).toBe("f2");
expect(f1.subfolders[0].snapshots.map((s) => s.id)).toEqual(["s2"]);
});
it("hebt Ausschnitt mit unbekanntem folderId auf die Wurzelebene (verwaist)", () => {
const tree = buildViewSnapshotTree([folder("f1")], [snap("s1", "ghost")]);
expect(tree.rootSnapshots.map((s) => s.id)).toEqual(["s1"]);
expect(tree.rootFolders[0].snapshots).toHaveLength(0);
});
it("hebt Ordner mit unbekanntem parentId auf die Wurzelebene (verwaist)", () => {
const tree = buildViewSnapshotTree([folder("f2", "ghost")], []);
expect(tree.rootFolders.map((n) => n.folder.id)).toEqual(["f2"]);
});
it("bewahrt die Eingabe-Reihenfolge je Ebene", () => {
const folders = [folder("b"), folder("a")];
const snaps = [snap("s2"), snap("s1")];
const tree = buildViewSnapshotTree(folders, snaps);
expect(tree.rootFolders.map((n) => n.folder.id)).toEqual(["b", "a"]);
expect(tree.rootSnapshots.map((s) => s.id)).toEqual(["s2", "s1"]);
});
});
describe("isDescendant", () => {
const folders = [folder("f1"), folder("f2", "f1"), folder("f3", "f2")];
it("erkennt sich selbst", () => {
expect(isDescendant(folders, "f1", "f1")).toBe(true);
});
it("erkennt echten Nachfahren (transitiv)", () => {
expect(isDescendant(folders, "f3", "f1")).toBe(true);
});
it("verneint Nicht-Nachfahren", () => {
expect(isDescendant(folders, "f1", "f3")).toBe(false);
});
it("bricht bei kaputter parentId-Kette ab (kein Absturz)", () => {
expect(isDescendant([folder("x", "ghost")], "x", "f1")).toBe(false);
});
});
describe("moveSnapshotToFolder", () => {
it("setzt folderId", () => {
const out = moveSnapshotToFolder([snap("s1")], "s1", "f1");
expect(out[0].folderId).toBe("f1");
});
it("entfernt folderId bei null (Wurzelebene)", () => {
const out = moveSnapshotToFolder([snap("s1", "f1")], "s1", null);
expect("folderId" in out[0]).toBe(false);
});
it("no-op bei unbekannter Id", () => {
const input = [snap("s1", "f1")];
expect(moveSnapshotToFolder(input, "ghost", null)).toEqual(input);
});
});
describe("moveFolder", () => {
const folders = [folder("f1"), folder("f2", "f1"), folder("f3")];
it("hängt Ordner unter neuen Elternordner", () => {
const out = moveFolder(folders, "f3", "f1");
expect(out.find((f) => f.id === "f3")!.parentId).toBe("f1");
});
it("verschiebt auf Wurzelebene bei null", () => {
const out = moveFolder(folders, "f2", null);
expect("parentId" in out.find((f) => f.id === "f2")!).toBe(false);
});
it("verhindert Zyklus: Ordner in eigenen Nachfahren (No-op)", () => {
expect(moveFolder(folders, "f1", "f2")).toEqual(folders);
});
it("verhindert Ordner in sich selbst (No-op)", () => {
expect(moveFolder(folders, "f1", "f1")).toEqual(folders);
});
});
describe("deleteFolder", () => {
it("zieht Unterordner + Ausschnitte auf die Elternebene (kein Datenverlust)", () => {
// f1 > f2 > f3; s1 in f2. f2 löschen → f3 & s1 hängen an f1.
const folders = [folder("f1"), folder("f2", "f1"), folder("f3", "f2")];
const snaps = [snap("s1", "f2")];
const out = deleteFolder("f2", folders, snaps);
expect(out.folders.map((f) => f.id).sort()).toEqual(["f1", "f3"]);
expect(out.folders.find((f) => f.id === "f3")!.parentId).toBe("f1");
expect(out.snapshots[0].folderId).toBe("f1");
});
it("zieht auf die Wurzelebene, wenn der gelöschte Ordner an der Wurzel lag", () => {
const folders = [folder("f1"), folder("f2", "f1")];
const snaps = [snap("s1", "f1")];
const out = deleteFolder("f1", folders, snaps);
expect(out.folders).toHaveLength(1);
expect("parentId" in out.folders[0]).toBe(false); // f2 an Wurzel
expect("folderId" in out.snapshots[0]).toBe(false); // s1 an Wurzel
});
it("no-op bei unbekannter Ordner-Id", () => {
const folders = [folder("f1")];
const snaps = [snap("s1", "f1")];
const out = deleteFolder("ghost", folders, snaps);
expect(out.folders).toEqual(folders);
expect(out.snapshots).toEqual(snaps);
});
});
+183
View File
@@ -0,0 +1,183 @@
// Ausschnitte-Baum (Ordner + Ausschnitte) — reine, testbare Logik (DOSSIER A2).
//
// Enthält KEINE React-/DOM-Abhängigkeit: nur immutable CRUD auf ViewSnapshot-
// Ordnern und die Auflösung der flachen Listen zum verschachtelten Baum. Das
// UI-Wiring (App.tsx setProject, ViewSnapshotsPanel) baut hierauf auf.
//
// Spiegelbildlich zu `panels/layoutModel.ts` (Layouts-Baum), aber bewusst OHNE
// Import aus dort — Ausschnitte-spezifisch und entkoppelt gehalten.
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import type { ViewSnapshot, ViewSnapshotFolder } from "../model/types";
// ── Fabrik ───────────────────────────────────────────────────────────────────
/** Neuer, leerer Ausschnitte-Ordner (optional in `parentId`). */
export function createViewSnapshotFolder(
id: string,
name: string,
parentId?: string,
): ViewSnapshotFolder {
return { id, name, ...(parentId ? { parentId } : {}) };
}
// ── Baum-Aufbau ──────────────────────────────────────────────────────────────
/** Ein Ordner-Knoten: der Ordner, seine Unterordner und direkten Ausschnitte. */
export interface ViewSnapshotFolderNode {
folder: ViewSnapshotFolder;
subfolders: ViewSnapshotFolderNode[];
snapshots: ViewSnapshot[];
}
/** Der aufgebaute Baum: Wurzel-Ausschnitte (ohne Ordner) + Wurzel-Ordner. */
export interface ViewSnapshotTree {
/** Ausschnitte ohne (gültigen) `folderId` — auf Wurzelebene. */
rootSnapshots: ViewSnapshot[];
/** Ordner ohne (gültigen) `parentId` — auf Wurzelebene, je mit Unterbaum. */
rootFolders: ViewSnapshotFolderNode[];
}
/**
* Baut aus der flachen Ordner-/Ausschnitt-Liste den verschachtelten Baum. Robust
* gegen verwaiste Referenzen: ein Ausschnitt mit unbekanntem `folderId` bzw. ein
* Ordner mit unbekanntem `parentId` landet auf der Wurzelebene (so bleiben
* Alt-/Teilprojekte sichtbar). Die Eingabe-Reihenfolge bleibt je Ebene erhalten.
*/
export function buildViewSnapshotTree(
folders: ViewSnapshotFolder[],
snapshots: ViewSnapshot[],
): ViewSnapshotTree {
const folderIds = new Set(folders.map((f) => f.id));
const nodes = new Map<string, ViewSnapshotFolderNode>();
for (const f of folders) {
nodes.set(f.id, { folder: f, subfolders: [], snapshots: [] });
}
const rootFolders: ViewSnapshotFolderNode[] = [];
for (const f of folders) {
const node = nodes.get(f.id)!;
const parent =
f.parentId && folderIds.has(f.parentId) ? nodes.get(f.parentId) : undefined;
if (parent) parent.subfolders.push(node);
else rootFolders.push(node);
}
const rootSnapshots: ViewSnapshot[] = [];
for (const s of snapshots) {
const node =
s.folderId && folderIds.has(s.folderId) ? nodes.get(s.folderId) : undefined;
if (node) node.snapshots.push(s);
else rootSnapshots.push(s);
}
return { rootSnapshots, rootFolders };
}
// ── Baum-Umsortieren (Drag & Drop) + Löschen ────────────────────────────────
/**
* Ist `candidateId` gleich `rootId` oder ein Nachfahre von `rootId`? Für den
* Zyklus-Schutz beim Ordner-in-Ordner-Verschieben: ein Ordner darf NICHT in sich
* selbst oder einen seiner Nachfahren wandern (sonst löst sich der Ast vom Baum).
* Robust gegen kaputte `parentId`-Ketten (bricht bei unbekannter Referenz ab).
*/
export function isDescendant(
folders: ViewSnapshotFolder[],
candidateId: string,
rootId: string,
): boolean {
if (candidateId === rootId) return true;
const byId = new Map(folders.map((f) => [f.id, f]));
const seen = new Set<string>();
let cur = byId.get(candidateId);
while (cur && cur.parentId && !seen.has(cur.id)) {
if (cur.parentId === rootId) return true;
seen.add(cur.id);
cur = byId.get(cur.parentId);
}
return false;
}
/**
* Verschiebt einen Ausschnitt in einen anderen Ordner (immutabel). `folderId =
* null` → zurück auf die Wurzelebene. No-op, wenn die Ausschnitt-Id fehlt.
*/
export function moveSnapshotToFolder(
snapshots: ViewSnapshot[],
snapshotId: string,
folderId: string | null,
): ViewSnapshot[] {
return snapshots.map((s) => {
if (s.id !== snapshotId) return s;
if (folderId) return { ...s, folderId };
const next = { ...s };
delete next.folderId;
return next;
});
}
/**
* Verschiebt einen Ordner unter einen neuen Elternordner (immutabel).
* `parentId = null` Wurzelebene. Verhindert Zyklen: ein Ordner kann nicht in
* sich selbst oder einen seiner Nachfahren wandern (dann No-op Original zurück).
*/
export function moveFolder(
folders: ViewSnapshotFolder[],
folderId: string,
parentId: string | null,
): ViewSnapshotFolder[] {
if (parentId && isDescendant(folders, parentId, folderId)) return folders;
return folders.map((f) => {
if (f.id !== folderId) return f;
if (parentId) return { ...f, parentId };
return stripParentId(f);
});
}
/**
* Löscht einen Ordner (immutabel). Unterordner und Ausschnitte des Ordners
* werden auf die Ebene des gelöschten Ordners (dessen `parentId`) hochgezogen,
* statt mitgelöscht zu werden es gehen keine Ausschnitte verloren. Liefert die
* neuen `folders`/`snapshots`-Listen.
*/
export function deleteFolder(
folderId: string,
folders: ViewSnapshotFolder[],
snapshots: ViewSnapshot[],
): { folders: ViewSnapshotFolder[]; snapshots: ViewSnapshot[] } {
const target = folders.find((f) => f.id === folderId);
const grandParentId = target?.parentId;
const nextFolders = folders
.filter((f) => f.id !== folderId)
.map((f) =>
f.parentId === folderId
? grandParentId
? { ...f, parentId: grandParentId }
: stripParentId(f)
: f,
);
const nextSnapshots = snapshots.map((s) =>
s.folderId === folderId
? grandParentId
? { ...s, folderId: grandParentId }
: stripFolderId(s)
: s,
);
return { folders: nextFolders, snapshots: nextSnapshots };
}
/** Ordner auf die Wurzelebene setzen (parentId entfernen). */
function stripParentId(folder: ViewSnapshotFolder): ViewSnapshotFolder {
const next = { ...folder };
delete next.parentId;
return next;
}
/** Ausschnitt auf die Wurzelebene setzen (folderId entfernen). */
function stripFolderId(snap: ViewSnapshot): ViewSnapshot {
const next = { ...snap };
delete next.folderId;
return next;
}
+196
View File
@@ -0,0 +1,196 @@
// Unit-Tests der reinen Ausschnitt-Logik (src/state/viewSnapshots.ts):
// Capture aus einem Zustand → ViewSnapshot mit allen Feldern; Read →
// Kernzustand; Roundtrip capture→read; Override-Aktiv-Menge wiederherstellen;
// Robustheit gegen fehlende optionale Felder (Alt-/Fremd-Snapshots).
import { describe, it, expect } from "vitest";
import type { OverrideRule, ViewSnapshot } from "../model/types";
import {
captureViewSnapshot,
readViewSnapshot,
applyOverrideRuleEnabled,
snapshotMatchesLiveState,
type ViewSnapshotState,
} from "./viewSnapshots";
/** Ein voll besetzter Beispiel-Kernzustand. */
function sampleState(): ViewSnapshotState {
return {
viewType: "perspektive",
view3d: "iso",
fov: 42,
scaleDenominator: 50,
detail: "fein",
activeLevelId: "floor-2",
layerVisibility: { "20": true, "30": false },
drawingVisibility: { "floor-1": true, "floor-2": false },
enabledOverrideRuleIds: ["r1", "r3"],
};
}
describe("viewSnapshots — captureViewSnapshot", () => {
it("erfasst alle Felder + Meta (id/name)", () => {
const snap = captureViewSnapshot("vs-1", "Nord EG", sampleState());
expect(snap).toMatchObject({
id: "vs-1",
name: "Nord EG",
viewType: "perspektive",
view3d: "iso",
fov: 42,
scaleDenominator: 50,
detail: "fein",
activeLevelId: "floor-2",
layerVisibility: { "20": true, "30": false },
drawingVisibility: { "floor-1": true, "floor-2": false },
enabledOverrideRuleIds: ["r1", "r3"],
});
expect(snap.folder).toBeUndefined();
});
it("übernimmt einen (getrimmten) Ordner, ignoriert Leerstrings", () => {
expect(captureViewSnapshot("a", "n", sampleState(), " Pläne ").folder).toBe(
"Pläne",
);
expect(captureViewSnapshot("a", "n", sampleState(), " ").folder).toBeUndefined();
});
it("koppelt die Maps/Arrays vom Live-Zustand ab (Kopie, keine Referenz)", () => {
const st = sampleState();
const snap = captureViewSnapshot("a", "n", st);
st.layerVisibility["20"] = false;
st.drawingVisibility["floor-1"] = false;
st.enabledOverrideRuleIds.push("r9");
expect(snap.layerVisibility["20"]).toBe(true);
expect(snap.drawingVisibility["floor-1"]).toBe(true);
expect(snap.enabledOverrideRuleIds).toEqual(["r1", "r3"]);
});
});
describe("viewSnapshots — readViewSnapshot", () => {
it("Roundtrip: capture → read ergibt denselben Kernzustand", () => {
const st = sampleState();
const snap = captureViewSnapshot("vs-1", "Nord EG", st);
expect(readViewSnapshot(snap)).toEqual(st);
});
it("füllt fehlende optionale Felder defensiv auf (Alt-Snapshot ohne fov/overrides)", () => {
const legacy: ViewSnapshot = {
id: "old",
name: "Alt",
viewType: "grundriss",
view3d: "perspective",
scaleDenominator: 100,
detail: "mittel",
activeLevelId: "floor-1",
layerVisibility: {},
drawingVisibility: {},
// fov + enabledOverrideRuleIds absichtlich weggelassen
};
const st = readViewSnapshot(legacy);
expect(st.fov).toBe(50);
expect(st.enabledOverrideRuleIds).toEqual([]);
});
});
describe("viewSnapshots — applyOverrideRuleEnabled", () => {
const mk = (id: string, enabled: boolean): OverrideRule => ({
id,
name: id,
enabled,
condition: { type: "layer_name", operator: "equals", value: id },
actions: {},
});
it("setzt enabled genau auf die gespeicherte Aktiv-Menge", () => {
const rules = [mk("r1", false), mk("r2", true), mk("r3", false)];
const next = applyOverrideRuleEnabled(rules, ["r1", "r3"]);
expect(next.map((r) => [r.id, r.enabled])).toEqual([
["r1", true],
["r2", false],
["r3", true],
]);
});
it("ignoriert unbekannte (inzwischen gelöschte) Ids", () => {
const rules = [mk("r1", false)];
const next = applyOverrideRuleEnabled(rules, ["r1", "ghost"]);
expect(next[0].enabled).toBe(true);
expect(next).toHaveLength(1);
});
it("liefert dieselbe Referenz zurück, wenn nichts zu ändern ist (No-Op)", () => {
const rules = [mk("r1", true), mk("r2", false)];
expect(applyOverrideRuleEnabled(rules, ["r1"])).toBe(rules);
});
});
describe("viewSnapshots — snapshotMatchesLiveState", () => {
it("identischer Zustand → true", () => {
expect(snapshotMatchesLiveState(sampleState(), sampleState())).toBe(true);
});
it("Override-Ids in anderer Reihenfolge → weiterhin true (Menge zählt)", () => {
const live = { ...sampleState(), enabledOverrideRuleIds: ["r3", "r1"] };
expect(snapshotMatchesLiveState(sampleState(), live)).toBe(true);
});
// Jedes einzelne abweichende Feld muss zu false führen.
it("viewType weicht ab → false", () => {
expect(
snapshotMatchesLiveState(sampleState(), { ...sampleState(), viewType: "grundriss" }),
).toBe(false);
});
it("view3d weicht ab → false", () => {
expect(
snapshotMatchesLiveState(sampleState(), { ...sampleState(), view3d: "perspective" }),
).toBe(false);
});
it("fov weicht ab → false", () => {
expect(
snapshotMatchesLiveState(sampleState(), { ...sampleState(), fov: 43 }),
).toBe(false);
});
it("scaleDenominator weicht ab → false", () => {
expect(
snapshotMatchesLiveState(sampleState(), { ...sampleState(), scaleDenominator: 100 }),
).toBe(false);
});
it("detail weicht ab → false", () => {
expect(
snapshotMatchesLiveState(sampleState(), { ...sampleState(), detail: "grob" }),
).toBe(false);
});
it("activeLevelId weicht ab → false", () => {
expect(
snapshotMatchesLiveState(sampleState(), { ...sampleState(), activeLevelId: "floor-9" }),
).toBe(false);
});
it("layerVisibility-Wert weicht ab → false", () => {
const live = { ...sampleState(), layerVisibility: { "20": true, "30": true } };
expect(snapshotMatchesLiveState(sampleState(), live)).toBe(false);
});
it("layerVisibility-Key fehlt/kommt hinzu → false", () => {
const live = { ...sampleState(), layerVisibility: { "20": true } };
expect(snapshotMatchesLiveState(sampleState(), live)).toBe(false);
const live2 = {
...sampleState(),
layerVisibility: { "20": true, "30": false, "40": true },
};
expect(snapshotMatchesLiveState(sampleState(), live2)).toBe(false);
});
it("drawingVisibility weicht ab → false", () => {
const live = {
...sampleState(),
drawingVisibility: { "floor-1": true, "floor-2": true },
};
expect(snapshotMatchesLiveState(sampleState(), live)).toBe(false);
});
it("enabledOverrideRuleIds weicht ab (eine Regel mehr) → false", () => {
const live = { ...sampleState(), enabledOverrideRuleIds: ["r1", "r3", "r4"] };
expect(snapshotMatchesLiveState(sampleState(), live)).toBe(false);
});
it("enabledOverrideRuleIds weicht ab (andere Regel) → false", () => {
const live = { ...sampleState(), enabledOverrideRuleIds: ["r1", "r2"] };
expect(snapshotMatchesLiveState(sampleState(), live)).toBe(false);
});
});
+160
View File
@@ -0,0 +1,160 @@
// Ausschnitte / View-Snapshots — reine Erfassungs-/Wiederherstellungslogik.
//
// Ein „Ausschnitt" (View-Snapshot, DOSSIER A2) ist eine benannte, gespeicherte
// Ansicht, die den kompletten Darstellungszustand einfängt und wiederherstellt.
// KERN-PRINZIP: nichts neu erfinden — die Bausteine existieren schon (View-
// State, Sichtbarkeits-Snapshots wie `LayerCombo`/`DrawingCombo`, Overrides,
// aktives Geschoss) und werden hier nur KOMPONIERT + benannt gebündelt.
//
// Dieses Modul hält die eigentliche Logik als PURE Funktionen (testbar, ohne
// React/Store): Capture aus einem Zustandsobjekt → ViewSnapshot, und Read eines
// ViewSnapshots → wiederherstellbarer Kernzustand (den das Wiring in App.tsx auf
// die konkreten Setter abbildet). Das Setter-/rAF-Wiring bleibt in App.tsx.
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import type { OverrideRule, ViewSnapshot } from "../model/types";
import type { DetailLevel, View3d, ViewType } from "../ui/TopBar";
/**
* Der wiederherstellbare KERNZUSTAND eines Ausschnitts alle Felder, die ein
* Snapshot einfängt, OHNE Meta (`id`/`name`/`folder`). Sowohl `captureViewSnapshot`
* (Eingang) als auch `readViewSnapshot` (Ausgang) sprechen diese Form; damit ist
* ein sauberer Roundtrip (capture read) trivial prüfbar.
*/
export interface ViewSnapshotState {
viewType: ViewType;
view3d: View3d;
fov: number;
scaleDenominator: number;
detail: DetailLevel;
activeLevelId: string;
layerVisibility: Record<string, boolean>;
drawingVisibility: Record<string, boolean>;
/** Ids der aktiven (`enabled`) Override-Regeln zum Erfassungszeitpunkt. */
enabledOverrideRuleIds: string[];
}
/**
* Baut aus einem Kernzustand einen benannten Ausschnitt. Pure `id`/`name`
* kommen vom Aufrufer (App erzeugt die Id, der Nutzer den Namen); die
* Sichtbarkeits-/Overrides-Maps werden defensiv kopiert, damit der Snapshot vom
* Live-Zustand entkoppelt ist. `folder` optional (Phase 1: flach).
*/
export function captureViewSnapshot(
id: string,
name: string,
state: ViewSnapshotState,
folder?: string,
): ViewSnapshot {
const snap: ViewSnapshot = {
id,
name,
viewType: state.viewType,
view3d: state.view3d,
fov: state.fov,
scaleDenominator: state.scaleDenominator,
detail: state.detail,
activeLevelId: state.activeLevelId,
layerVisibility: { ...state.layerVisibility },
drawingVisibility: { ...state.drawingVisibility },
enabledOverrideRuleIds: [...state.enabledOverrideRuleIds],
};
if (folder && folder.trim()) snap.folder = folder.trim();
return snap;
}
/**
* Liest den wiederherstellbaren Kernzustand aus einem Ausschnitt. Fehlende
* optionale Felder (aus hand-/altangelegten Snapshots) werden defensiv
* aufgefüllt (`fov` 50, `enabledOverrideRuleIds` []). Die Sichtbarkeits-Maps
* werden kopiert, damit der Aufrufer sie gefahrlos weiterreichen kann.
*/
export function readViewSnapshot(snap: ViewSnapshot): ViewSnapshotState {
return {
viewType: snap.viewType,
view3d: snap.view3d,
fov: snap.fov ?? 50,
scaleDenominator: snap.scaleDenominator,
detail: snap.detail,
activeLevelId: snap.activeLevelId,
layerVisibility: { ...snap.layerVisibility },
drawingVisibility: { ...snap.drawingVisibility },
enabledOverrideRuleIds: snap.enabledOverrideRuleIds
? [...snap.enabledOverrideRuleIds]
: [],
};
}
/** Deep-Equal zweier `Record<string, boolean>`-Maps: gleiche Keys UND Werte. */
function boolMapEqual(
a: Record<string, boolean>,
b: Record<string, boolean>,
): boolean {
const ka = Object.keys(a);
const kb = Object.keys(b);
if (ka.length !== kb.length) return false;
for (const k of ka) {
if (a[k] !== b[k]) return false;
}
return true;
}
/** Mengengleichheit zweier Id-Listen (Reihenfolge egal, Duplikate ignoriert). */
function idSetEqual(a: string[], b: string[]): boolean {
if (a.length !== b.length) return false;
const sa = new Set(a);
for (const id of b) {
if (!sa.has(id)) return false;
}
return sa.size === new Set(b).size;
}
/**
* Prüft, ob ein Ausschnitt-Kernzustand EXAKT dem aktuellen Live-Zustand
* entspricht Grundlage der angewählt bleibt bis zur Abweichung"-Logik im
* Ausschnitte-Panel. Vergleicht ALLE erfassten Felder feldweise (Enums/Zahlen
* strikt, Sichtbarkeits-Maps deep-equal, Override-Ids als Menge). Weicht auch
* nur EIN Feld ab `false`. Pure/testbar; der Aufrufer (App.tsx) normalisiert
* den gespeicherten Snapshot vorher über `readViewSnapshot`, damit fehlende
* optionale Felder (fov/overrides) fair verglichen werden.
*/
export function snapshotMatchesLiveState(
snap: ViewSnapshotState,
live: ViewSnapshotState,
): boolean {
return (
snap.viewType === live.viewType &&
snap.view3d === live.view3d &&
snap.fov === live.fov &&
snap.scaleDenominator === live.scaleDenominator &&
snap.detail === live.detail &&
snap.activeLevelId === live.activeLevelId &&
boolMapEqual(snap.layerVisibility, live.layerVisibility) &&
boolMapEqual(snap.drawingVisibility, live.drawingVisibility) &&
idSetEqual(snap.enabledOverrideRuleIds, live.enabledOverrideRuleIds)
);
}
/**
* Setzt bei jeder Override-Regel `enabled = enabledIds.includes(rule.id)`
* (immutabel). Reproduziert die Aktiv-Menge, die ein Ausschnitt gespeichert hat.
* Regeln, die es beim Erfassen noch nicht gab, bleiben unberührt deaktiviert;
* inzwischen gelöschte Regel-Ids werden schlicht ignoriert. Liefert eine NEUE
* Liste nur, wenn sich etwas ändert (sonst dieselbe Referenz No-Op-freundlich
* für den setProject-History-Schutz).
*/
export function applyOverrideRuleEnabled(
rules: OverrideRule[],
enabledIds: string[],
): OverrideRule[] {
const active = new Set(enabledIds);
let changed = false;
const next = rules.map((r) => {
const enabled = active.has(r.id);
if (enabled === r.enabled) return r;
changed = true;
return { ...r, enabled };
});
return changed ? next : rules;
}
+539 -3
View File
@@ -561,6 +561,14 @@ body {
line-height: 1;
}
.brand-word {
/* Als <button> gerendert (öffnet den Über-Dialog) Button-Chrome zurücksetzen,
Optik bleibt exakt wie die frühere <span>. */
appearance: none;
background: none;
border: none;
padding: 0;
margin: 0;
cursor: pointer;
font-weight: 700;
font-size: 12px;
letter-spacing: -0.01em;
@@ -568,8 +576,101 @@ body {
color: var(--ink);
white-space: nowrap;
}
.brand-word:hover .brand-dot {
filter: brightness(1.25);
}
.brand-dot {
color: var(--accent);
/* Petrolgrün wie im DOSSIER-Rhino-Plugin (fix, unabhängig vom Akzent). */
color: #0f766e;
}
/* Export-Sammelmenü: Liste im Popover (fixed) über der Oberleiste. */
.tb-popover.tb-menu {
padding: 4px;
min-width: 200px;
display: flex;
flex-direction: column;
gap: 1px;
}
/* „Über"-Dialog (In-App, dunkel). */
.about-overlay {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.45);
}
.about-dialog {
min-width: 320px;
max-width: 420px;
padding: 22px 24px;
border-radius: 10px;
background: var(--panel, #23262b);
border: 1px solid var(--border, #3a3f47);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
color: var(--ink, #e6e8ea);
/* Der Dialog ist ein DOM-Kind der `.topbar` (white-space: nowrap) ohne
dieses Reset liefe der mehrzeilige Beschreibungstext einzeilig aus dem
Dialog heraus. Hier wieder normalen Zeilenumbruch erzwingen. */
white-space: normal;
}
.about-brand {
font-weight: 700;
font-size: 20px;
text-transform: lowercase;
letter-spacing: -0.01em;
}
.about-version {
margin-top: 2px;
font-size: 11px;
color: var(--muted, #9aa0a6);
}
.about-desc {
margin: 14px 0 0;
font-size: 12px;
line-height: 1.5;
color: var(--label, #c4c8cd);
}
.about-meta {
margin-top: 12px;
display: flex;
flex-direction: column;
gap: 2px;
font-size: 11px;
line-height: 1.5;
color: var(--muted, #9aa0a6);
}
.about-sec-label {
margin-top: 16px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted, #9aa0a6);
}
.about-licenses {
margin: 6px 0 0;
padding-left: 16px;
font-size: 11px;
line-height: 1.6;
color: var(--label, #c4c8cd);
}
.about-close {
appearance: none;
margin-top: 18px;
padding: 6px 14px;
border-radius: 6px;
border: 1px solid var(--border, #3a3f47);
background: var(--input, #2b2f36);
color: var(--ink, #e6e8ea);
cursor: pointer;
font-size: 12px;
}
.about-close:hover {
background: var(--accent-dim, #33383f);
}
.tag {
@@ -730,6 +831,49 @@ body {
display: inline-flex;
}
/* Iso-Split-Button (IsoMenu): Haupt-Icon + schmaler Pfeil-Trigger, zusammen
exakt so breit wie ein normales .view-icon (31px) behält die Grid-Spalten-
breite des Ansichts-Rasters bei. */
.view-icon-split {
display: inline-flex;
}
.view-icon-split-main {
width: 22px;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
border-right: none;
}
.view-icon-caret {
width: 9px;
height: 22px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid #4a4a4a;
background: #2c2c2c;
color: #b0b0b0;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
border-radius: 0 6px 6px 0;
cursor: pointer;
transition: background 0.14s, color 0.14s, border-color 0.14s;
}
.view-icon-caret:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.1);
color: #f0f0f0;
border-color: var(--accent-border);
}
.view-icon-caret.active {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.view-icon-caret:disabled {
opacity: 0.4;
cursor: default;
}
/* Material-Symbols-Icon in Oberleisten-Buttons (Referenzlinien, Linien-Modus,
Ressourcen) auf die kompakte Icon-Button-Höhe skaliert, currentColor erbend,
sodass aktiv/hover die Farbe wie bei den View-Icons mitführt. */
@@ -1318,6 +1462,18 @@ body {
opacity: 0.45;
}
/* Drag&Drop-Ziel: überfahrene Ordnerzeile (Layouts-Baum) hervorheben. */
.nav-row.drag-over {
background: var(--active-dim);
box-shadow: inset 0 0 0 1.5px var(--active-light);
border-bottom-color: transparent;
}
/* Wurzel-Ablagefläche (leerer Bereich der Baumliste) beim Ziehen markieren. */
.nav-list.drag-over-root {
box-shadow: inset 0 0 0 1.5px var(--active-light);
border-radius: 6px;
}
.nav-label {
flex: 1;
min-width: 0;
@@ -1335,6 +1491,53 @@ body {
color: var(--muted);
}
/* Footer-Bar des Ausschnitte-Panels: fixiert am Panel-Boden, zeigt die
Kenndaten des angewählten Ausschnitts (Massstab/Kombis/Overrides + Rename). */
.viewsnap-footer {
margin-top: auto;
flex: 0 0 auto;
position: sticky;
bottom: 0;
padding: 6px 10px 8px;
border-top: 1px solid var(--line);
background: var(--panel-2);
}
.viewsnap-footer-name {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 5px;
}
.viewsnap-footer-title {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 12px;
font-weight: 600;
color: var(--active-light);
}
.viewsnap-footer-grid {
display: grid;
grid-template-columns: auto 1fr;
gap: 2px 8px;
margin: 0;
font-size: 11px;
}
.viewsnap-footer-grid dt {
color: var(--muted);
white-space: nowrap;
}
.viewsnap-footer-grid dd {
margin: 0;
color: var(--label);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Augen-Schalter (Sichtbarkeit) — erbt Zeilenfarbe via currentColor. */
.eye {
flex: 0 0 auto;
@@ -1814,7 +2017,7 @@ body {
.plan-svg .snap-glyph {
fill: none;
stroke: #ffcc44;
stroke-width: 1.4;
stroke-width: 2.2;
}
.plan-svg .snap-glyph.snap-fill {
fill: #ffcc44;
@@ -3841,19 +4044,35 @@ body {
letter-spacing: 0.04em;
margin: 2px 2px 4px;
}
/* Bezugspunkt-Zeile: Würfel (fix quadratisch) links, X/Y/Z-Spalte rechts. */
.objinfo-refrow {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 6px;
}
.objinfo-cube {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(3, 1fr);
gap: 3px;
/* Immer quadratisch, wächst NICHT mit der Panelbreite (Nutzer-Wunsch). */
flex: 0 0 auto;
width: 64px;
height: 64px;
margin: 0 auto 6px;
aspect-ratio: 1 / 1;
padding: 3px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--input);
}
.objinfo-xyzcol {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1 1 auto;
min-width: 0;
}
.objinfo-anchor {
display: flex;
align-items: center;
@@ -4325,6 +4544,52 @@ body {
.imp-radio input[disabled] + span {
color: var(--muted);
}
/* Segment-Auswahl (A4/A3/Frei · Hoch/Quer) in den Layout-Erstell-Dialogen. */
.imp-seg {
display: inline-flex;
gap: 0;
border: 1px solid var(--border);
border-radius: 7px;
overflow: hidden;
}
.imp-seg-btn {
font-family: inherit;
font-size: 12px;
padding: 6px 14px;
border: none;
border-right: 1px solid var(--border);
background: var(--input);
color: var(--ink);
cursor: pointer;
}
.imp-seg-btn:last-child {
border-right: none;
}
.imp-seg-btn:hover {
background: var(--panel-2);
}
.imp-seg-btn.active {
background: var(--accent);
color: #fff;
}
/* Freie-Grösse-Felder (Breite × Höhe). */
.imp-size-row {
display: flex;
align-items: flex-end;
gap: 10px;
}
.imp-size-field {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 11px;
color: var(--muted);
}
.imp-size-x {
padding-bottom: 6px;
color: var(--muted);
}
.imp-input,
.imp-select {
width: 100%;
@@ -4393,6 +4658,17 @@ body {
opacity: 0.45;
cursor: not-allowed;
}
/* Riskante Aktion (z. B. Lock erzwingen): dezente rote Kontur statt Fläche
warnt, ohne wie der primäre Bestätigen-Button auszusehen. */
.imp-btn.danger {
color: var(--ctx-danger);
border-color: var(--ctx-danger);
}
.imp-btn.danger:hover {
background: rgba(216, 96, 96, 0.14);
color: var(--ctx-danger);
border-color: var(--ctx-danger);
}
/* Einstellungs-Fenster (SettingsDialog): Farbfelder + Akzent-Presets
Nutzt sonst dieselben imp-*-Klassen wie die anderen Dialoge. */
@@ -5490,6 +5766,15 @@ body {
margin: 0 3px;
flex: 0 0 auto;
}
/* Kamera-Gruppe der Quick-Access-Leiste (zusätzlich zum Ansichten"-Ribbon-Tab):
dieselben `.view-icon`/`.view-icon-split`-Bausteine, nur ohne eigenen Rand um
die Gruppe die 22px-Höhe passt bereits in die 26px hohe Chrome-Zeile. */
.tb-qa-camera {
display: inline-flex;
align-items: center;
gap: 2px;
flex: 0 0 auto;
}
.ribbon-tab:hover {
color: var(--label);
}
@@ -5611,3 +5896,254 @@ body {
height: 100%;
overflow: hidden;
}
/* Layout-Blatt im Haupt-Viewport (DOSSIER A3, Phase 2b)
Das Blatt liegt auf einem dunklen Tisch"; oben eine schlanke Werkzeugleiste,
darunter die pan-/zoombare Blattfläche mit dem weissen Papier. Griffe/HUD sind
Overlays. */
.layout-sheet-pane {
display: flex;
flex-direction: column;
}
.layout-sheet-toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 10px;
border-bottom: 1px solid var(--line, #2b2b2b);
background: var(--panel, #1d1d1d);
flex: 0 0 auto;
z-index: 3;
}
.layout-sheet-name {
font-size: 11px;
font-weight: 600;
color: var(--fg, #ddd);
margin-left: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 40%;
}
.layout-sheet-stage {
position: relative;
flex: 1;
min-height: 0;
min-width: 0;
overflow: hidden;
/* Dunkler „Tisch" mit dezentem Verlauf, damit das weisse Blatt abhebt. */
background:
radial-gradient(circle at 50% 40%, #2a2a2a 0%, #161616 70%, #101010 100%);
touch-action: none;
user-select: none;
}
.layout-sheet-paper {
position: absolute;
background: #ffffff;
box-shadow: 0 4px 22px rgba(0, 0, 0, 0.55);
}
.layout-sheet-vp {
position: absolute;
overflow: hidden;
background: #ffffff;
outline: 1px solid rgba(0, 0, 0, 0.35);
}
.layout-sheet-vp.selected {
outline: 1.5px solid #3b82f6;
z-index: 2;
}
.layout-sheet-vp-missing {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
color: #b00;
text-align: center;
padding: 4px;
}
/* Resize-Griffe (8 Stück) — kleine blaue Quadrate am Rand des gewählten VP. */
.layout-sheet-grip {
position: absolute;
width: 9px;
height: 9px;
margin: -5px 0 0 -5px; /* auf den Ankerpunkt zentrieren */
background: #ffffff;
border: 1.5px solid #3b82f6;
border-radius: 2px;
z-index: 4;
}
/* Ghost-Rechteck beim Aufziehen einer neuen Platzierung. */
.layout-sheet-ghost {
position: absolute;
border: 1.5px dashed #3b82f6;
background: rgba(59, 130, 246, 0.12);
z-index: 4;
pointer-events: none;
}
/* HUD (Eigenschaften des gewählten VP) unten links. */
.layout-sheet-hud {
position: absolute;
left: 12px;
bottom: 12px;
z-index: 5;
display: flex;
flex-direction: column;
gap: 5px;
padding: 8px 10px;
min-width: 150px;
background: color-mix(in srgb, var(--panel-2, #262626) 92%, transparent);
border: 1px solid var(--line, #2b2b2b);
border-radius: 6px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
}
/* Ausschnitt-Bindung nach dem Aufziehen — mittig oben. */
.layout-sheet-bind {
position: absolute;
left: 50%;
top: 16px;
transform: translateX(-50%);
z-index: 6;
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px 10px;
min-width: 220px;
background: color-mix(in srgb, var(--panel-2, #262626) 96%, transparent);
border: 1px solid var(--line, #2b2b2b);
border-radius: 6px;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.5);
}
.layout-sheet-hud-title {
font-size: 11px;
font-weight: 600;
color: var(--fg, #ddd);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 220px;
}
.layout-sheet-hud-row {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--muted, #6e6e6e);
}
.layout-sheet-hud-row > span:first-child {
flex: 0 0 54px;
}
.layout-sheet-hud-scale {
display: flex;
align-items: center;
gap: 2px;
flex: 1;
}
.layout-sheet-hud-scale input {
width: 64px;
}
.layout-sheet-hud-size {
font-size: 10px;
color: var(--muted, #6e6e6e);
}
.layout-sheet-hud-actions {
display: flex;
gap: 5px;
}
/* Leer-Hinweis (keine Ausschnitte / keine Viewports). */
.layout-sheet-empty {
position: absolute;
left: 50%;
bottom: 14px;
transform: translateX(-50%);
z-index: 3;
max-width: 70%;
padding: 6px 12px;
text-align: center;
font-size: 11px;
color: var(--muted, #9a9a9a);
background: color-mix(in srgb, var(--panel, #1d1d1d) 80%, transparent);
border-radius: 5px;
pointer-events: none;
}
/* Toast (nicht-blockierende Kurzmeldungen, Ersatz für window.alert)
Oben rechts, gestapelt, über allem (auch Modals) window.alert war im
WKWebView ohnehin lautlos wirkungslos, siehe state/notifySlice.ts. */
.toast-stack {
position: fixed;
top: 16px;
right: 16px;
z-index: 2000;
display: flex;
flex-direction: column;
gap: 8px;
max-width: min(360px, calc(100vw - 32px));
pointer-events: none;
}
.toast-item {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 12px;
background: var(--panel);
border: 1px solid var(--border);
border-left: 3px solid var(--accent);
border-radius: 8px;
box-shadow: var(--shadow-3);
color: var(--ink);
font-size: 12px;
line-height: 1.4;
cursor: pointer;
pointer-events: auto;
animation: toast-in 0.15s ease-out;
}
.toast-item.toast-warning {
border-left-color: #d8a83c;
}
.toast-item.toast-error {
border-left-color: var(--danger);
}
.toast-message {
flex: 1 1 auto;
white-space: pre-wrap;
}
.toast-close {
flex: 0 0 auto;
border: none;
background: transparent;
color: var(--ink-2);
font-size: 15px;
line-height: 1;
cursor: pointer;
padding: 0 2px;
}
.toast-close:hover {
color: var(--ink);
}
@keyframes toast-in {
from {
opacity: 0;
transform: translateY(-6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
+30
View File
@@ -597,6 +597,8 @@ export function RichTextEditor({
const isItalic = isMarkActive(value, queryRange, "italic");
const isUnderline = isMarkActive(value, queryRange, "underline");
const isStrike = isMarkActive(value, queryRange, "strike");
const isSuper = isMarkActive(value, queryRange, "super");
const isSub = isMarkActive(value, queryRange, "sub");
const currentSize = commonMarkValue(value, queryRange, "sizePt") ?? 12;
const rawColor = commonMarkValue(value, queryRange, "color") ?? "#ededed";
@@ -659,6 +661,34 @@ export function RichTextEditor({
<s>S</s>
</button>
{/* Hochgestellt */}
<button
type="button"
className={`rt-btn${isSuper ? " rt-btn--active" : ""}`}
title={t("rt.super")}
aria-label={t("rt.super")}
onMouseDown={(e) => {
e.preventDefault();
handleToggleMark("super");
}}
>
x<sup>2</sup>
</button>
{/* Tiefgestellt */}
<button
type="button"
className={`rt-btn${isSub ? " rt-btn--active" : ""}`}
title={t("rt.sub")}
aria-label={t("rt.sub")}
onMouseDown={(e) => {
e.preventDefault();
handleToggleMark("sub");
}}
>
x<sub>2</sub>
</button>
<div className="rt-toolbar-sep" />
{/* Schriftgrösse */}
+1 -1
View File
@@ -46,7 +46,7 @@ export const DEFAULT_MARQUEE_COLOR = "#CEB188";
* bleibt aber (anders als das sehr blasse Kori) auch auf hellem
* Papier-Hintergrund gut sichtbar. Per Picker im Einstellungs-Fenster änderbar.
*/
export const DEFAULT_SNAP_COLOR = "#5FA1C9";
export const DEFAULT_SNAP_COLOR = "#E0BE5A";
/** Liefert einen Swatch nach `id`, oder `undefined`. */
export function findAccentSwatch(id: string): AccentSwatch | undefined {
+30
View File
@@ -554,6 +554,32 @@ const arcTool: Tool = {
onCancel: (s) => [s, { draft: null, done: true }],
};
// Text: Platzhalter-Werkzeug, gekoppelt an den `text`-Befehl (Ankerpunkt →
// getippte Zeile). Wie Kreis/Bogen nicht floorOnly (freies 2D-Element).
const textTool: Tool = {
id: "text",
labelKey: "tool.text",
hintKey: () => "tool.text.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 }],
};
// Textspalte: Platzhalter-Werkzeug, gekoppelt an den `textbox`-Befehl (Anker →
// Spaltenbreite → getippte Zeile). Freies 2D-Element (nicht floorOnly).
const textboxTool: Tool = {
id: "textbox",
labelKey: "tool.textbox",
hintKey: () => "tool.textbox.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 }],
};
// ── Registry ─────────────────────────────────────────────────────────────────
const TOOLS: Record<ToolId, Tool> = {
@@ -569,6 +595,8 @@ const TOOLS: Record<ToolId, Tool> = {
rect: rectTool,
circle: circleTool,
arc: arcTool,
text: textTool,
textbox: textboxTool,
};
/** Liefert das Werkzeug zur ID. */
@@ -590,4 +618,6 @@ export const TOOL_ORDER: ToolId[] = [
"rect",
"circle",
"arc",
"text",
"textbox",
];
+77 -2
View File
@@ -10,14 +10,17 @@
// die App hält den State und wendet `commitTransform` immutabel an.
import type {
Column,
Drawing2D,
Drawing2DGeom,
ExtrudedSolid,
Project,
Vec2,
Wall,
} from "../model/types";
import { wallTypeThickness } from "../model/types";
import { wallCorners } from "../model/geometry";
import { columnFootprint } from "../geometry/column";
import type { DraftShape } from "./types";
import { uniqueId } from "./types";
@@ -28,6 +31,10 @@ export type CopyMode = "move" | "copy" | "array" | "distribute"; // U / I / O /
export interface TransformSelection {
wallIds: string[];
drawingId: string | null;
/** Gewählter extrudierter Körper (truck-Integration); nur move.ts setzt ihn. */
extrudedSolidId?: string | null;
/** Gewählte Stütze (Tragwerk); von move/copy/mirror gesetzt. */
columnId?: string | null;
}
/** Wie viele Klickpunkte die Operation braucht (Drehen = 3, sonst 2). */
@@ -95,7 +102,15 @@ interface SelDrawingItem {
kind: "drawing";
drawing: Drawing2D;
}
type AnyItem = SelItem | SelDrawingItem;
interface SelExtrudedItem {
kind: "extrudedSolid";
solid: ExtrudedSolid;
}
interface SelColumnItem {
kind: "column";
column: Column;
}
type AnyItem = SelItem | SelDrawingItem | SelExtrudedItem | SelColumnItem;
function gatherItems(project: Project, sel: TransformSelection): AnyItem[] {
const items: AnyItem[] = [];
@@ -105,9 +120,33 @@ function gatherItems(project: Project, sel: TransformSelection): AnyItem[] {
const d = project.drawings2d.find((x) => x.id === sel.drawingId);
if (d) items.push({ kind: "drawing", drawing: d });
}
if (sel.extrudedSolidId) {
const s = (project.extrudedSolids ?? []).find((x) => x.id === sel.extrudedSolidId);
if (s) items.push({ kind: "extrudedSolid", solid: s });
}
if (sel.columnId) {
const c = (project.columns ?? []).find((x) => x.id === sel.columnId);
if (c) items.push({ kind: "column", column: c });
}
return items;
}
/**
* Transformiert eine Stütze unter einer affinen Punkt-Abbildung `fn`
* (Verschiebung/Drehung/Spiegelung): der Einfügepunkt wandert mit; die Profil-
* Drehung folgt der Abbildung, indem ein Richtungsvektor (Länge 1 unter dem
* aktuellen Winkel) mit-transformiert und der neue Winkel daraus gemessen wird
* das ergibt für alle drei Operationen die korrekte neue Ausrichtung.
*/
function mvColumn(col: Column, fn: (p: Vec2) => Vec2): Column {
const base = col.position;
const rot = col.rotation ?? 0;
const tip = { x: base.x + Math.cos(rot), y: base.y + Math.sin(rot) };
const nb = fn(base);
const nt = fn(tip);
return { ...col, position: nb, rotation: Math.atan2(nt.y - nb.y, nt.x - nb.x) };
}
const mvGeom = (geom: Drawing2DGeom, fn: (p: Vec2) => Vec2): Drawing2DGeom => {
switch (geom.shape) {
case "line":
@@ -157,6 +196,12 @@ function itemPreview(project: Project, item: AnyItem, fn: (p: Vec2) => Vec2): Dr
{ kind: "line", a, b },
];
}
if (item.kind === "extrudedSolid") {
return [{ kind: "poly", pts: item.solid.points.map(fn), closed: true }];
}
if (item.kind === "column") {
return [{ kind: "poly", pts: columnFootprint(item.column).map(fn), closed: true }];
}
const g = mvGeom(item.drawing.geom, fn);
switch (g.shape) {
case "line":
@@ -215,6 +260,8 @@ export function commitTransform(
let walls = project.walls;
let drawings2d = project.drawings2d;
let extrudedSolids = project.extrudedSolids ?? [];
let columns = project.columns ?? [];
// U (move): Original an die Endlage (t=1) transformieren.
if (movesOriginal(mode)) {
@@ -225,20 +272,46 @@ export function commitTransform(
d.id === sel.drawingId ? { ...d, geom: mvGeom(d.geom, fn) } : d,
);
}
if (sel.extrudedSolidId) {
extrudedSolids = extrudedSolids.map((s) =>
s.id === sel.extrudedSolidId
? {
...s,
points: s.points.map(fn),
circle: s.circle ? { ...s.circle, center: fn(s.circle.center) } : undefined,
}
: s,
);
}
if (sel.columnId) {
columns = columns.map((c) => (c.id === sel.columnId ? mvColumn(c, fn) : c));
}
}
// Kopien anhängen (I/O/P bzw. mirror copy).
const newWalls: Wall[] = [];
const newDrawings: Drawing2D[] = [];
const newSolids: ExtrudedSolid[] = [];
const newColumns: Column[] = [];
for (const t of copyTs(op, mode, count)) {
const fn = transformFn(op, pts, t);
for (const item of items) {
if (item.kind === "wall") {
const w = item.wall;
newWalls.push({ ...w, id: uniqueId("W"), start: fn(w.start), end: fn(w.end) });
} else {
} else if (item.kind === "drawing") {
const d = item.drawing;
newDrawings.push({ ...d, id: uniqueId("dr2d"), geom: mvGeom(d.geom, fn) });
} else if (item.kind === "column") {
newColumns.push({ ...mvColumn(item.column, fn), id: uniqueId("column") });
} else {
const s = item.solid;
newSolids.push({
...s,
id: uniqueId("extrude"),
points: s.points.map(fn),
circle: s.circle ? { ...s.circle, center: fn(s.circle.center) } : undefined,
});
}
}
}
@@ -247,5 +320,7 @@ export function commitTransform(
...project,
walls: newWalls.length ? [...walls, ...newWalls] : walls,
drawings2d: newDrawings.length ? [...drawings2d, ...newDrawings] : drawings2d,
extrudedSolids: newSolids.length ? [...extrudedSolids, ...newSolids] : extrudedSolids,
columns: newColumns.length ? [...columns, ...newColumns] : columns,
};
}
+3 -1
View File
@@ -20,7 +20,9 @@ export type ToolId =
| "polyline"
| "rect"
| "circle"
| "arc";
| "arc"
| "text"
| "textbox";
// ── Snapping ───────────────────────────────────────────────────────────────
+2 -1
View File
@@ -3,6 +3,7 @@
// Text-Anzeige des Hex-Werts, die nicht anklickbar/editierbar war.
import { useEffect, useState } from "react";
import { t } from "../i18n";
export function ColorHexField({
value,
@@ -32,7 +33,7 @@ export function ColorHexField({
return (
<span className="attr-color">
<label className="attr-color-swatch-wrap" title="Farbe wählen">
<label className="attr-color-swatch-wrap" title={t("attr.pickColor")}>
<span className="attr-color-swatch" style={{ background: value }} />
<input
type="color"
+43 -4
View File
@@ -1,7 +1,7 @@
// 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.
// (swisstopo) sowie OSM-Features (Gebäude/Strassen/Wasser/Grün/Parkplätze/
// Bahn/Wald) 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
@@ -34,6 +34,9 @@ type Sources = {
osmRoads: boolean;
osmWater: boolean;
osmGreen: boolean;
osmParking: boolean;
osmRailway: boolean;
osmForest: boolean;
};
const DEFAULT_SOURCES: Sources = {
@@ -43,6 +46,9 @@ const DEFAULT_SOURCES: Sources = {
osmRoads: true,
osmWater: true,
osmGreen: true,
osmParking: false,
osmRailway: false,
osmForest: false,
};
export function ContextImportDialog({
@@ -98,7 +104,10 @@ export function ContextImportDialog({
sources.osmBuildings ||
sources.osmRoads ||
sources.osmWater ||
sources.osmGreen;
sources.osmGreen ||
sources.osmParking ||
sources.osmRailway ||
sources.osmForest;
const runImport = async () => {
if (!selected || !anySource) return;
@@ -121,12 +130,18 @@ export function ContextImportDialog({
roads: sources.osmRoads,
water: sources.osmWater,
green: sources.osmGreen,
parking: sources.osmParking,
railway: sources.osmRailway,
forest: sources.osmForest,
};
if (
osmSel.buildings ||
osmSel.roads ||
osmSel.water ||
osmSel.green
osmSel.green ||
osmSel.parking ||
osmSel.railway ||
osmSel.forest
) {
const r = await fetchOsm(center, radius, osmSel, origin);
origin = r.origin;
@@ -305,6 +320,30 @@ export function ContextImportDialog({
/>
{t("ctxImport.src.osmGreen")}
</label>
<label className="cim-check">
<input
type="checkbox"
checked={sources.osmParking}
onChange={() => toggle("osmParking")}
/>
{t("ctxImport.src.osmParking")}
</label>
<label className="cim-check">
<input
type="checkbox"
checked={sources.osmRailway}
onChange={() => toggle("osmRailway")}
/>
{t("ctxImport.src.osmRailway")}
</label>
<label className="cim-check">
<input
type="checkbox"
checked={sources.osmForest}
onChange={() => toggle("osmForest")}
/>
{t("ctxImport.src.osmForest")}
</label>
</div>
</section>
+125
View File
@@ -0,0 +1,125 @@
// Schnellexport-Dialog (modal) für die Direkt-Formate CSV/IFC/OBJ/STL aus dem
// Topbar-Export-Menü: Format wählen + Dateiname festlegen, dann speichern.
// (PDF/DXF haben eigene Options-Dialoge.) Später bekommen „Ausschnitte" einen
// vordefinierten Namen + Speicherort; dies ist der interaktive Schnellweg.
//
// Muster wie ExportDxfDialog: abgedunkeltes Overlay, Klick auf Hintergrund + Esc
// schliessen. Rein gesteuert — meldet Format + Dateiname über `onSave` nach oben
// (App führt Erzeugung + Download aus). Bezeichner englisch, UI deutsch (t()).
import { useEffect, useState } from "react";
import { t } from "../i18n";
import { Dropdown } from "./Dropdown";
/** Direkt-herunterladbare Exportformate (mit Datei-Endung). */
export type ExportSaveFormat = "csv" | "ifc" | "obj" | "stl";
/** Datei-Endung je Format. */
export const EXPORT_FORMAT_EXT: Record<ExportSaveFormat, string> = {
csv: "csv",
ifc: "ifc",
obj: "obj",
stl: "stl",
};
export interface ExportSaveDialogProps {
/** Vorausgewähltes Format (aus dem angeklickten Menüeintrag). */
initialFormat: ExportSaveFormat;
/** Basis-Dateiname OHNE Endung (i. d. R. Projektname). */
baseName: string;
onSave: (format: ExportSaveFormat, filename: string) => void;
onClose: () => void;
}
/** Endung sicher an den Basisnamen hängen (doppelte Endung vermeiden). */
function withExt(base: string, ext: string): string {
const trimmed = base.trim() || "modell";
const lower = trimmed.toLowerCase();
return lower.endsWith(`.${ext}`) ? trimmed : `${trimmed}.${ext}`;
}
export function ExportSaveDialog({
initialFormat,
baseName,
onSave,
onClose,
}: ExportSaveDialogProps) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const [format, setFormat] = useState<ExportSaveFormat>(initialFormat);
// Der Dateiname wird OHNE Endung editiert; die Endung folgt aus dem Format.
const [name, setName] = useState(baseName);
const filename = withExt(name, EXPORT_FORMAT_EXT[format]);
const onConfirm = () => onSave(format, filename);
return (
<div className="imp-overlay" onClick={onClose}>
<div
className="imp-dialog"
role="dialog"
aria-modal="true"
aria-label={t("exportSave.title")}
onClick={(e) => e.stopPropagation()}
>
<header className="imp-head">
<span className="imp-head-title">{t("exportSave.title")}</span>
<button className="imp-close" onClick={onClose} aria-label={t("export.close")}>
×
</button>
</header>
<div className="imp-body">
<section className="imp-section">
<div className="imp-section-title">{t("exportSave.format")}</div>
<Dropdown
value={format}
onChange={(v) => setFormat(v as ExportSaveFormat)}
title={t("exportSave.format")}
options={[
{ value: "csv", label: t("exportSave.fmt.csv") },
{ value: "ifc", label: t("exportSave.fmt.ifc") },
{ value: "obj", label: t("exportSave.fmt.obj") },
{ value: "stl", label: t("exportSave.fmt.stl") },
]}
/>
</section>
<section className="imp-section">
<div className="imp-section-title">{t("exportSave.filename")}</div>
<input
className="settings-num-input"
type="text"
value={name}
autoFocus
spellCheck={false}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") onConfirm();
}}
placeholder="modell"
/>
<div className="imp-note">
{filename} · {t("exportSave.locationNote")}
</div>
</section>
</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>
);
}
+313
View File
@@ -0,0 +1,313 @@
// Erstell-Dialoge für den Layouts-Baum (DOSSIER A3): „Neues Layout" und „Neues
// Masterlayout". Beide liefern beim Bestätigen die Startwerte (Papier/Custom-
// Grösse/Ausrichtung bzw. Master-Vorlage) nach oben; das Panel legt das Element
// an und versetzt es direkt in den Inline-Rename-Modus.
//
// Muster wie ExportSaveDialog: `imp-*`-Overlay/Dialog, Klick auf Hintergrund +
// Esc schliessen, Enter bestätigt. KEIN window.prompt/confirm (im Tauri-WKWebView
// deaktiviert). Bezeichner englisch, UI-Text deutsch via t() (CONVENTIONS.md).
import { useEffect, useState } from "react";
import { t } from "../i18n";
import type {
LayoutOrientation,
LayoutPaperFormat,
MasterLayout,
} from "../model/types";
import { PAPER_FORMAT_GROUPS } from "../panels/layoutModel";
/** Startwerte eines neuen Blatts/Masters (Grösse). */
export interface LayoutSizeStart {
paper: LayoutPaperFormat;
orientation: LayoutOrientation;
customWidthMm?: number;
customHeightMm?: number;
}
/** Startwerte eines neuen Layouts: entweder Master-Vorlage ODER freie Grösse. */
export interface NewLayoutStart extends LayoutSizeStart {
/** Gebundenes Masterlayout (erbt dessen Papier/Ausrichtung); sonst undefined. */
masterId?: string;
}
type SizeMode = LayoutPaperFormat | "custom";
/** Baut die Grösse-Startwerte aus dem Grössen-Auswahlzustand. */
function sizeStart(
mode: SizeMode,
orientation: LayoutOrientation,
wMm: number,
hMm: number,
): LayoutSizeStart {
if (mode === "custom") {
return {
paper: "a4",
orientation,
customWidthMm: Math.max(1, Math.round(wMm)),
customHeightMm: Math.max(1, Math.round(hMm)),
};
}
return { paper: mode, orientation };
}
/** Geteilte Grössen-Auswahl: Format (A4/A3) als Segment ODER freie mm-Felder + Ausrichtung. */
function SizePicker({
mode,
setMode,
orientation,
setOrientation,
wMm,
setWMm,
hMm,
setHMm,
}: {
mode: SizeMode;
setMode: (m: SizeMode) => void;
orientation: LayoutOrientation;
setOrientation: (o: LayoutOrientation) => void;
wMm: number;
setWMm: (v: number) => void;
hMm: number;
setHMm: (v: number) => void;
}) {
return (
<>
<section className="imp-section">
<div className="imp-section-title">{t("layouts.size.format")}</div>
<select
className="imp-select"
value={mode}
onChange={(e) => setMode(e.target.value as SizeMode)}
>
{PAPER_FORMAT_GROUPS.map((g) => (
<optgroup
key={g.label}
label={g.label === "A" ? t("layouts.paperGroup.a") : t("layouts.paperGroup.b")}
>
{g.formats.map((f) => (
<option key={f} value={f}>
{f.toUpperCase()}
</option>
))}
</optgroup>
))}
<option value="custom">{t("layouts.size.custom")}</option>
</select>
</section>
{mode === "custom" ? (
<section className="imp-section">
<div className="imp-size-row">
<label className="imp-size-field">
<span>{t("layouts.size.widthMm")}</span>
<input
className="settings-num-input"
type="number"
min={1}
value={wMm}
onChange={(e) => setWMm(parseFloat(e.target.value) || 0)}
/>
</label>
<span className="imp-size-x">×</span>
<label className="imp-size-field">
<span>{t("layouts.size.heightMm")}</span>
<input
className="settings-num-input"
type="number"
min={1}
value={hMm}
onChange={(e) => setHMm(parseFloat(e.target.value) || 0)}
/>
</label>
</div>
</section>
) : (
<section className="imp-section">
<div className="imp-section-title">{t("layouts.orientation")}</div>
<div className="imp-seg">
{(["portrait", "landscape"] as const).map((o) => (
<button
key={o}
type="button"
className={`imp-seg-btn${orientation === o ? " active" : ""}`}
onClick={() => setOrientation(o)}
>
{o === "portrait" ? t("layouts.portrait") : t("layouts.landscape")}
</button>
))}
</div>
</section>
)}
</>
);
}
/** Overlay-Rahmen (geteilt von beiden Dialogen). */
function DialogFrame({
title,
onClose,
onConfirm,
children,
}: {
title: string;
onClose: () => void;
onConfirm: () => void;
children: React.ReactNode;
}) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
else if (e.key === "Enter") onConfirm();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose, onConfirm]);
return (
<div className="imp-overlay" onClick={onClose}>
<div
className="imp-dialog"
role="dialog"
aria-modal="true"
aria-label={title}
onClick={(e) => e.stopPropagation()}
>
<header className="imp-head">
<span className="imp-head-title">{title}</span>
<button className="imp-close" onClick={onClose} aria-label={t("export.close")}>
×
</button>
</header>
<div className="imp-body">{children}</div>
<footer className="imp-foot">
<button className="imp-btn" onClick={onClose}>
{t("export.cancel")}
</button>
<button className="imp-btn primary" onClick={onConfirm}>
{t("layouts.size.create")}
</button>
</footer>
</div>
</div>
);
}
/** Dialog „Neues Masterlayout" — Format/freie Grösse + Ausrichtung. */
export function NewMasterLayoutDialog({
onCreate,
onClose,
}: {
onCreate: (start: LayoutSizeStart) => void;
onClose: () => void;
}) {
const [mode, setMode] = useState<SizeMode>("a4");
const [orientation, setOrientation] = useState<LayoutOrientation>("portrait");
const [wMm, setWMm] = useState(210);
const [hMm, setHMm] = useState(297);
const confirm = () => onCreate(sizeStart(mode, orientation, wMm, hMm));
return (
<DialogFrame title={t("layouts.newMaster.title")} onClose={onClose} onConfirm={confirm}>
<SizePicker
mode={mode}
setMode={setMode}
orientation={orientation}
setOrientation={setOrientation}
wMm={wMm}
setWMm={setWMm}
hMm={hMm}
setHMm={setHMm}
/>
</DialogFrame>
);
}
/**
* Dialog Neues Layout" Masterlayout als Vorlage (erbt dessen Grösse) ODER
* freie Grösse (Format/Custom + Ausrichtung).
*/
export function NewLayoutDialog({
masters,
onCreate,
onClose,
}: {
masters: MasterLayout[];
onCreate: (start: NewLayoutStart) => void;
onClose: () => void;
}) {
const hasMasters = masters.length > 0;
const [source, setSource] = useState<"master" | "free">(
hasMasters ? "master" : "free",
);
const [masterId, setMasterId] = useState<string>(masters[0]?.id ?? "");
const [mode, setMode] = useState<SizeMode>("a4");
const [orientation, setOrientation] = useState<LayoutOrientation>("portrait");
const [wMm, setWMm] = useState(210);
const [hMm, setHMm] = useState(297);
const confirm = () => {
if (source === "master" && masterId) {
// Grösse erbt der Aufrufer vom Master; wir liefern nur die Bindung + eine
// sinnvolle Rückfall-Grösse (A4 hoch), falls der Master unauflösbar ist.
onCreate({ masterId, paper: "a4", orientation: "portrait" });
return;
}
onCreate(sizeStart(mode, orientation, wMm, hMm));
};
return (
<DialogFrame title={t("layouts.newLayout.title")} onClose={onClose} onConfirm={confirm}>
<section className="imp-section">
<div className="imp-section-title">{t("layouts.newLayout.source")}</div>
<label className="imp-radio">
<input
type="radio"
checked={source === "master"}
disabled={!hasMasters}
onChange={() => setSource("master")}
/>
<span>{t("layouts.newLayout.fromMaster")}</span>
</label>
<label className="imp-radio">
<input
type="radio"
checked={source === "free"}
onChange={() => setSource("free")}
/>
<span>{t("layouts.newLayout.freeSize")}</span>
</label>
</section>
{source === "master" ? (
<section className="imp-section">
{hasMasters ? (
<select
className="imp-select"
value={masterId}
onChange={(e) => setMasterId(e.target.value)}
>
{masters.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</select>
) : (
<div className="imp-note">{t("layouts.newLayout.noMasters")}</div>
)}
</section>
) : (
<SizePicker
mode={mode}
setMode={setMode}
orientation={orientation}
setOrientation={setOrientation}
wMm={wMm}
setWMm={setWMm}
hMm={hMm}
setHMm={setHMm}
/>
)}
</DialogFrame>
);
}
File diff suppressed because it is too large Load Diff
+75
View File
@@ -0,0 +1,75 @@
// Meldung, wenn eine Projektdatei bereits in einer anderen Instanz gesperrt ist
// (Lock-Konflikt, s. src/io/projectFile.ts acquireProjectLock). Ersetzt
// `window.confirm` (im Tauri-WKWebView deaktiviert) — gleiches Overlay-Muster
// wie PromptDialog (imp-overlay/imp-dialog/imp-head/imp-body/imp-foot/imp-btn).
import { useEffect } from "react";
import { t } from "../i18n";
import type { LockInfo } from "../io/projectFile";
export interface LockConflictDialogProps {
/** Info aus der Lock-Sidecar-Datei (pid/hostname/timestamp) — kann fehlen. */
info: LockInfo | null;
/** Projekt ohne Lock nur lesend öffnen (kein Schreibzugriff-Anspruch). */
onReadOnly: () => void;
/** Lock ignorieren und trotzdem schreibend öffnen (Risiko: zwei Schreiber). */
onForceOpen: () => void;
onCancel: () => void;
}
export function LockConflictDialog({
info,
onReadOnly,
onForceOpen,
onCancel,
}: LockConflictDialogProps) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onCancel();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onCancel]);
return (
<div className="imp-overlay" onClick={onCancel}>
<div
className="imp-dialog"
role="dialog"
aria-modal="true"
aria-label={t("lock.title")}
onClick={(e) => e.stopPropagation()}
>
<header className="imp-head">
<span className="imp-head-title">{t("lock.title")}</span>
<button className="imp-close" onClick={onCancel} aria-label={t("export.close")}>
×
</button>
</header>
<div className="imp-body">
<section className="imp-section">
<div className="imp-section-title">{t("lock.message")}</div>
{info && (
<div className="imp-section-title" style={{ opacity: 0.7, fontWeight: 400 }}>
{t("lock.messageInfo", { host: info.hostname, pid: String(info.pid) })}
</div>
)}
</section>
</div>
<footer className="imp-foot">
<button className="imp-btn" onClick={onCancel}>
{t("export.cancel")}
</button>
<button className="imp-btn danger" onClick={onForceOpen} title={t("lock.forceOpenWarning")}>
{t("lock.forceOpen")}
</button>
<button className="imp-btn primary" onClick={onReadOnly}>
{t("lock.openReadOnly")}
</button>
</footer>
</div>
</div>
);
}
+92
View File
@@ -0,0 +1,92 @@
// Generischer Text-Eingabe-Dialog — ersetzt `window.prompt` überall im Code.
// GRUND: `window.prompt`/`window.confirm` sind im Tauri-WKWebView deaktiviert
// (liefern sofort `null` zurück) — jede Stelle, die sie nutzt, war in der
// Desktop-App faktisch kaputt (kein Name kam je an). Dieser Dialog ist der
// einheitliche Ersatz: gleiches Muster wie ExportSaveDialog/SettingsDialog
// (abgedunkeltes Overlay, Klick auf Hintergrund + Esc schliessen), Enter
// bestätigt. Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import { useEffect, useState } from "react";
import { t } from "../i18n";
export interface PromptDialogProps {
/** Kopfzeile des Dialogs. */
title: string;
/** Feld-Beschriftung/Frage (entspricht dem früheren `window.prompt`-Text). */
label: string;
/** Vorbelegter Wert (entspricht dem früheren zweiten `window.prompt`-Arg). */
defaultValue?: string;
/** Eingabetyp — "text" (Default) oder "number" für reine Zahlen-Prompts. */
type?: "text" | "number";
onConfirm: (value: string) => void;
onClose: () => void;
}
export function PromptDialog({
title,
label,
defaultValue = "",
type = "text",
onConfirm,
onClose,
}: PromptDialogProps) {
const [value, setValue] = useState(defaultValue);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const commit = () => {
const v = value.trim();
if (v) onConfirm(v);
};
return (
<div className="imp-overlay" onClick={onClose}>
<div
className="imp-dialog"
role="dialog"
aria-modal="true"
aria-label={title}
onClick={(e) => e.stopPropagation()}
>
<header className="imp-head">
<span className="imp-head-title">{title}</span>
<button className="imp-close" onClick={onClose} aria-label={t("export.close")}>
×
</button>
</header>
<div className="imp-body">
<section className="imp-section">
<div className="imp-section-title">{label}</div>
<input
className="settings-num-input"
type={type}
autoFocus
spellCheck={false}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") commit();
}}
/>
</section>
</div>
<footer className="imp-foot">
<button className="imp-btn" onClick={onClose}>
{t("export.cancel")}
</button>
<button className="imp-btn primary" onClick={commit}>
{t("export.confirm")}
</button>
</footer>
</div>
</div>
);
}
+886 -4
View File
@@ -16,18 +16,24 @@
// Rendering noch Persistenz. Bezeichner englisch, UI-Text deutsch
// (CONVENTIONS.md).
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import type {
CeilingType,
Component,
ComponentMaterial,
DoorType,
HatchPattern,
HatchStyle,
Layer,
LineStyle,
OverrideConditionType,
OverrideOperator,
OverrideRule,
Project,
StairType,
WallType,
WindowType,
} from "../model/types";
import { PEN_WEIGHTS } from "../model/types";
import { parseLin } from "../io/linParser";
@@ -720,6 +726,95 @@ function ColorSwatch({ color, size = 30 }: { color: string; size?: number }) {
);
}
/**
* Baut aus einem zugewiesenen Bauteil-Material ein `MaterialAsset` für die
* PBR-Kugel-Vorschau (`requestMaterialPreview`): verweist es auf die
* Bibliothek (`libraryId`), wird deren Asset wiederverwendet (Name/Kategorie
* fürs Tooltip); sonst entsteht aus den hochgeladenen Karten ein Ad-hoc-Asset.
*/
function materialAssetOf(material: ComponentMaterial): MaterialAsset {
if (material.libraryId) {
const asset = MATERIAL_LIBRARY.find((a) => a.id === material.libraryId);
if (asset) return asset;
}
return {
id: material.libraryId ?? "custom",
name: t("material.uploaded"),
category: "",
maps: {
color: material.color,
normal: material.normal,
roughness: material.roughness,
metalness: material.metalness,
displacement: material.displacement,
ao: material.ao,
},
};
}
/**
* PBR-Kugel-Vorschau eines zugewiesenen Bauteil-Materials dieselbe
* Lazy-Render-Logik wie `MaterialLibraryTile` (IntersectionObserver +
* `requestMaterialPreview`, geteilter Renderer), nur ohne Klick-Interaktion,
* als Thumbnail-Ersatz für `ColorSwatch`/`HatchSwatch` sobald ein Bauteil ein
* Material trägt.
*/
function ComponentMaterialSphere({
material,
size = 30,
}: {
material: ComponentMaterial;
size?: number;
}) {
const asset = useMemo(
() => materialAssetOf(material),
[
material.libraryId,
material.color,
material.normal,
material.roughness,
material.metalness,
material.displacement,
material.ao,
],
);
const ref = useRef<HTMLSpanElement | null>(null);
const [visible, setVisible] = useState(false);
const [previewUrl, setPreviewUrl] = useState<string | undefined>(undefined);
useEffect(() => {
const el = ref.current;
if (!el || visible) return;
const io = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) setVisible(true);
},
{ rootMargin: "200px" },
);
io.observe(el);
return () => io.disconnect();
}, [visible]);
useEffect(() => {
if (!visible) return;
const handleReady = (url: string) => setPreviewUrl(url);
const cached = requestMaterialPreview(asset, handleReady);
if (cached) setPreviewUrl(cached);
return () => cancelMaterialPreview(asset, handleReady);
}, [visible, asset]);
return (
<span
ref={ref}
className="mat-sphere"
title={asset.name || undefined}
style={{ width: size, height: size, flex: "0 0 auto" }}
>
{previewUrl && <img src={previewUrl} alt="" draggable={false} />}
</span>
);
}
/** Detail-Panel EINES Bauteils (rechte Seite des Master-Detail-Layouts). */
function ComponentDetail({
component,
@@ -755,7 +850,9 @@ function ComponentDetail({
</div>
<div className="res-md-preview">
{previewHatch ? (
{component.material ? (
<ComponentMaterialSphere material={component.material} size={128} />
) : previewHatch ? (
<HatchSwatch hatch={previewHatch} size={128} />
) : (
<ColorSwatch color={component.foreground ?? component.color} size={128} />
@@ -863,7 +960,9 @@ function ComponentsTab({
onClick={() => setSelectedId(c.id)}
>
<span className="res-md-row-thumb">
{hatch ? (
{c.material ? (
<ComponentMaterialSphere material={c.material} size={30} />
) : hatch ? (
<HatchSwatch hatch={hatch} size={30} />
) : (
<ColorSwatch color={c.foreground ?? c.color} />
@@ -1859,6 +1958,424 @@ function patToHatches(text: string): Omit<HatchStyle, "id">[] {
}));
}
// ── Bauteil-Typen (Tür/Fenster/Treppe) ─────────────────────────────────────
// Formular-basierte Bibliotheken (im Gegensatz zu Wand-/Deckenstilen, die
// schichtbasiert über LayeredStylesTab laufen): jeder Typ ist ein flaches
// Feld-Bündel (Bauart, Maße, …), daher genügt ein generischer Master-Detail-
// Rumpf ({@link TypeLibraryTab}), der die konkreten Felder je Typ als Render-
// Funktion einreicht — analog zum Bauteile-Tab (Liste links, Editor rechts).
const DOOR_KIND_OPTIONS: { value: DoorType["kind"]; labelKey: string }[] = [
{ value: "dreh", labelKey: "resources.doorKind.dreh" },
{ value: "schiebe", labelKey: "resources.doorKind.schiebe" },
{ value: "wandoeffnung", labelKey: "resources.doorKind.wandoeffnung" },
];
const DOOR_LEAF_STYLE_OPTIONS: { value: DoorType["leafStyle"]; labelKey: string }[] = [
{ value: "glatt", labelKey: "resources.leafStyle.glatt" },
{ value: "kassette", labelKey: "resources.leafStyle.kassette" },
{ value: "glas", labelKey: "resources.leafStyle.glas" },
];
const WINDOW_KIND_OPTIONS: { value: WindowType["kind"]; labelKey: string }[] = [
{ value: "dreh", labelKey: "resources.windowKind.dreh" },
{ value: "kipp", labelKey: "resources.windowKind.kipp" },
{ value: "drehkipp", labelKey: "resources.windowKind.drehkipp" },
{ value: "fest", labelKey: "resources.windowKind.fest" },
{ value: "schiebe", labelKey: "resources.windowKind.schiebe" },
];
const WINDOW_GLAZING_OPTIONS: { value: WindowType["glazing"]; labelKey: string }[] = [
{ value: "einfach", labelKey: "resources.glazing.einfach" },
{ value: "zweifach", labelKey: "resources.glazing.zweifach" },
{ value: "dreifach", labelKey: "resources.glazing.dreifach" },
];
const WINDOW_SILL_BOARD_OPTIONS: { value: NonNullable<WindowType["sillBoard"]>; labelKey: string }[] = [
{ value: "keine", labelKey: "resources.sillBoard.keine" },
{ value: "innen", labelKey: "resources.sillBoard.innen" },
{ value: "aussen", labelKey: "resources.sillBoard.aussen" },
{ value: "beide", labelKey: "resources.sillBoard.beide" },
];
const STAIR_STRUCTURE_OPTIONS: { value: StairType["structure"]; labelKey: string }[] = [
{ value: "massiv", labelKey: "resources.structure.massiv" },
{ value: "beton", labelKey: "resources.structure.beton" },
{ value: "wange", labelKey: "resources.structure.wange" },
{ value: "aufgesattelt", labelKey: "resources.structure.aufgesattelt" },
{ value: "spindel", labelKey: "resources.structure.spindel" },
];
const STAIR_RAILING_OPTIONS: { value: NonNullable<StairType["railing"]>; labelKey: string }[] = [
{ value: "keine", labelKey: "resources.railing.keine" },
{ value: "links", labelKey: "resources.railing.links" },
{ value: "rechts", labelKey: "resources.railing.rechts" },
{ value: "beide", labelKey: "resources.railing.beide" },
];
/**
* Generischer Rumpf für formular-basierte Typ-Bibliotheken (Tür-/Fenster-/
* Treppentypen): Liste links (Name je Zeile, + Neu" im Fuß), Editor rechts
* (Umbenennen-Feld + Löschen-Knopf im Kopf, darunter die typspezifischen
* Felder aus `renderFields`). Handler sind optional (nur gesetzt, wenn die
* einbettende App sie bereitstellt) Aufrufe laufen daher über `?.()`.
*/
function TypeLibraryTab<T extends { id: string; name: string }>({
types,
hintKey,
emptyKey,
placeholderKey,
deleteKeyPrefix,
onAdd,
onPatch,
onDelete,
renderFields,
}: {
types: T[];
hintKey: string;
emptyKey: string;
placeholderKey: string;
deleteKeyPrefix: string;
onAdd?: () => void;
onPatch?: (id: string, patch: Partial<T>) => void;
onDelete?: (id: string) => void;
renderFields: (item: T, onPatch: (patch: Partial<T>) => void) => React.ReactNode;
}) {
const [selectedId, setSelectedId] = useState<string | null>(null);
const selected = types.find((x) => x.id === selectedId) ?? types[0];
return (
<div className="res-tab">
<div className="res-hint">{t(hintKey)}</div>
<div className="res-md">
<div className="res-md-list">
{types.map((item) => (
<button
key={item.id}
className={`res-md-row${selected?.id === item.id ? " active" : ""}`}
onClick={() => setSelectedId(item.id)}
>
<span className="res-md-row-name">{item.name}</span>
</button>
))}
{types.length === 0 && (
<div className="res-md-empty">{t(emptyKey)}</div>
)}
<div className="res-md-listfoot">
<button className="res-add" onClick={() => onAdd?.()}>
<span className="res-add-plus">+</span> {t("resources.add")}
</button>
</div>
</div>
<div className="res-md-detail">
{selected ? (
<div className="res-md-detail-inner" key={selected.id}>
<div className="res-md-head">
<input
className="res-input res-md-rename"
value={selected.name}
placeholder={t(placeholderKey)}
onChange={(e) =>
onPatch?.(selected.id, { name: e.target.value } as Partial<T>)
}
/>
<DeleteButton
onClick={() => onDelete?.(selected.id)}
title={t(deleteKeyPrefix, { name: selected.name })}
/>
</div>
<div className="res-fields">
{renderFields(selected, (patch) => onPatch?.(selected.id, patch))}
</div>
</div>
) : (
<div className="res-md-empty">{t(emptyKey)}</div>
)}
</div>
</div>
</div>
);
}
/** Tab „Türtypen" — Bauart, Blattausführung/-zahl, Zarge und Standardmaße. */
function DoorStylesTab({
project,
onAddDoorType,
onPatchDoorType,
onDeleteDoorType,
}: {
project: Project;
onAddDoorType?: () => void;
onPatchDoorType?: (id: string, patch: Partial<DoorType>) => void;
onDeleteDoorType?: (id: string) => void;
}) {
return (
<TypeLibraryTab
types={project.doorTypes ?? []}
hintKey="resources.doorTypes.hint"
emptyKey="resources.doorTypes.empty"
placeholderKey="resources.doorTypes.placeholder"
deleteKeyPrefix="resources.delete.doorType"
onAdd={onAddDoorType}
onPatch={onPatchDoorType}
onDelete={onDeleteDoorType}
renderFields={(door, patch) => (
<>
<FieldRow label={t("resources.field.doorKind")}>
<SelectField
value={door.kind}
onChange={(kind) => patch({ kind })}
options={DOOR_KIND_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))}
/>
</FieldRow>
<FieldRow label={t("resources.field.leafCount")}>
<Segmented
value={String(door.leafCount)}
onChange={(v) => patch({ leafCount: (v === "2" ? 2 : 1) as 1 | 2 })}
options={[
{ value: "1", label: t("resources.leafCount.1") },
{ value: "2", label: t("resources.leafCount.2") },
]}
/>
</FieldRow>
<FieldRow label={t("resources.field.leafStyle")}>
<SelectField
value={door.leafStyle}
onChange={(leafStyle) => patch({ leafStyle })}
options={DOOR_LEAF_STYLE_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))}
/>
</FieldRow>
{door.leafStyle === "glas" && (
<FieldRow label={t("resources.field.glazingRatio")}>
<NumberField
value={door.glazingRatio ?? 0.6}
onChange={(glazingRatio) => patch({ glazingRatio })}
step={0.01}
min={0}
max={1}
/>
</FieldRow>
)}
<FieldRow label={t("resources.field.frameThickness")}>
<NumberField
value={door.frameThickness}
onChange={(frameThickness) => patch({ frameThickness })}
step={0.01}
min={0}
/>
</FieldRow>
<FieldRow label={t("resources.field.frameDepth")}>
<NumberField
value={door.frameDepth ?? 0}
onChange={(frameDepth) => patch({ frameDepth })}
step={0.01}
min={0}
/>
</FieldRow>
<FieldRow label={t("resources.field.defaultWidth")}>
<NumberField
value={door.defaultWidth}
onChange={(defaultWidth) => patch({ defaultWidth })}
step={0.01}
min={0}
/>
</FieldRow>
<FieldRow label={t("resources.field.defaultHeight")}>
<NumberField
value={door.defaultHeight}
onChange={(defaultHeight) => patch({ defaultHeight })}
step={0.01}
min={0}
/>
</FieldRow>
<FieldRow label={t("resources.field.threshold")}>
<input
type="checkbox"
checked={!!door.threshold}
onChange={(e) => patch({ threshold: e.target.checked })}
/>
</FieldRow>
</>
)}
/>
);
}
/** Tab „Fenstertypen" — Öffnungsart, Flügelzahl, Verglasung, Zarge, Brüstung. */
function WindowStylesTab({
project,
onAddWindowType,
onPatchWindowType,
onDeleteWindowType,
}: {
project: Project;
onAddWindowType?: () => void;
onPatchWindowType?: (id: string, patch: Partial<WindowType>) => void;
onDeleteWindowType?: (id: string) => void;
}) {
return (
<TypeLibraryTab
types={project.windowTypes ?? []}
hintKey="resources.windowTypes.hint"
emptyKey="resources.windowTypes.empty"
placeholderKey="resources.windowTypes.placeholder"
deleteKeyPrefix="resources.delete.windowType"
onAdd={onAddWindowType}
onPatch={onPatchWindowType}
onDelete={onDeleteWindowType}
renderFields={(win, patch) => (
<>
<FieldRow label={t("resources.field.windowKind")}>
<SelectField
value={win.kind}
onChange={(kind) => patch({ kind })}
options={WINDOW_KIND_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))}
/>
</FieldRow>
<FieldRow label={t("resources.field.wingCount")}>
<NumberField
value={win.wingCount}
onChange={(wingCount) => patch({ wingCount: Math.round(wingCount) })}
step={1}
min={1}
max={4}
/>
</FieldRow>
<FieldRow label={t("resources.field.glazing")}>
<SelectField
value={win.glazing}
onChange={(glazing) => patch({ glazing })}
options={WINDOW_GLAZING_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))}
/>
</FieldRow>
<FieldRow label={t("resources.field.frameThickness")}>
<NumberField
value={win.frameThickness}
onChange={(frameThickness) => patch({ frameThickness })}
step={0.01}
min={0}
/>
</FieldRow>
<FieldRow label={t("resources.field.frameDepth")}>
<NumberField
value={win.frameDepth ?? 0}
onChange={(frameDepth) => patch({ frameDepth })}
step={0.01}
min={0}
/>
</FieldRow>
<FieldRow label={t("resources.field.defaultSillHeight")}>
<NumberField
value={win.defaultSillHeight}
onChange={(defaultSillHeight) => patch({ defaultSillHeight })}
step={0.01}
min={0}
/>
</FieldRow>
<FieldRow label={t("resources.field.defaultWidth")}>
<NumberField
value={win.defaultWidth}
onChange={(defaultWidth) => patch({ defaultWidth })}
step={0.01}
min={0}
/>
</FieldRow>
<FieldRow label={t("resources.field.defaultHeight")}>
<NumberField
value={win.defaultHeight}
onChange={(defaultHeight) => patch({ defaultHeight })}
step={0.01}
min={0}
/>
</FieldRow>
<FieldRow label={t("resources.field.sillBoard")}>
<SelectField
value={win.sillBoard ?? "keine"}
onChange={(sillBoard) => patch({ sillBoard })}
options={WINDOW_SILL_BOARD_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))}
/>
</FieldRow>
</>
)}
/>
);
}
/** Tab „Treppentypen" — Tragart, Stufenausbildung, Geländer und Laufbreite. */
function StairStylesTab({
project,
onAddStairType,
onPatchStairType,
onDeleteStairType,
}: {
project: Project;
onAddStairType?: () => void;
onPatchStairType?: (id: string, patch: Partial<StairType>) => void;
onDeleteStairType?: (id: string) => void;
}) {
return (
<TypeLibraryTab
types={project.stairTypes ?? []}
hintKey="resources.stairTypes.hint"
emptyKey="resources.stairTypes.empty"
placeholderKey="resources.stairTypes.placeholder"
deleteKeyPrefix="resources.delete.stairType"
onAdd={onAddStairType}
onPatch={onPatchStairType}
onDelete={onDeleteStairType}
renderFields={(stair, patch) => (
<>
<FieldRow label={t("resources.field.structure")}>
<SelectField
value={stair.structure}
onChange={(structure) => patch({ structure })}
options={STAIR_STRUCTURE_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))}
/>
</FieldRow>
<FieldRow label={t("resources.field.closedRisers")}>
<input
type="checkbox"
checked={!!stair.closedRisers}
onChange={(e) => patch({ closedRisers: e.target.checked })}
/>
</FieldRow>
<FieldRow label={t("resources.field.treadThickness")}>
<NumberField
value={stair.treadThickness}
onChange={(treadThickness) => patch({ treadThickness })}
step={0.01}
min={0}
/>
</FieldRow>
<FieldRow label={t("resources.field.nosing")}>
<NumberField
value={stair.nosing ?? 0}
onChange={(nosing) => patch({ nosing })}
step={0.01}
min={0}
/>
</FieldRow>
<FieldRow label={t("resources.field.railing")}>
<SelectField
value={stair.railing ?? "keine"}
onChange={(railing) => patch({ railing })}
options={STAIR_RAILING_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))}
/>
</FieldRow>
<FieldRow label={t("resources.field.stairWidth")}>
<NumberField
value={stair.defaultWidth}
onChange={(defaultWidth) => patch({ defaultWidth })}
step={0.01}
min={0}
/>
</FieldRow>
</>
)}
/>
);
}
// ── Materialien (PBR-Bibliothek durchsuchen) ───────────────────────────────
// Eigenständiger Tab, der die eingebaute Materialbibliothek (MATERIAL_LIBRARY)
// als Kachel-Grid mit gerenderter PBR-Kugel-Vorschau zeigt — Name/Kategorie
@@ -2025,6 +2542,313 @@ function MaterialsTab() {
);
}
// ── Grafische Overrides (Regel-Engine, A1) ─────────────────────────────────
// Master-Detail wie Linien/Wandstile: Liste links = Regeln in PRIORITÄTS-
// Reihenfolge (oben gewinnt pro Aktions-Feld) mit Aktiv-Checkbox, Auf/Ab und
// Löschen; Detail rechts = Bedingung (Kriterium/Operator/Wert) + optionale
// Aktionen (Farbe/Strichstärke/Linienstil). Reines Rendering-Overlay — die
// Auswertung liegt in src/overrides/engine.ts, der Einhängepunkt in
// plan/generatePlan.ts; Elementdaten bleiben unverändert.
/** Bedingungs-Typen (UI-Beschriftung übersetzt, Werte englisch). */
const OVERRIDE_TYPE_OPTIONS: { value: OverrideConditionType; labelKey: string }[] = [
{ value: "layer_name", labelKey: "resources.overrides.condType.layer" },
{ value: "object_name", labelKey: "resources.overrides.condType.object" },
];
/** Vergleichs-Operatoren (UI-Beschriftung übersetzt, Werte englisch). */
const OVERRIDE_OP_OPTIONS: { value: OverrideOperator; labelKey: string }[] = [
{ value: "equals", labelKey: "resources.overrides.op.equals" },
{ value: "contains", labelKey: "resources.overrides.op.contains" },
{ value: "starts_with", labelKey: "resources.overrides.op.startsWith" },
{ value: "not_equals", labelKey: "resources.overrides.op.notEquals" },
];
/** Kompakte Bedingungs-Zusammenfassung für die Listenzeile. */
function overrideConditionSummary(rule: OverrideRule): string {
const type = OVERRIDE_TYPE_OPTIONS.find((o) => o.value === rule.condition.type);
const op = OVERRIDE_OP_OPTIONS.find((o) => o.value === rule.condition.operator);
return `${type ? t(type.labelKey) : rule.condition.type} ${
op ? t(op.labelKey) : rule.condition.operator
} ${rule.condition.value}"`;
}
/** Optionale Aktion: Checkbox (an/aus) + Steuerelement, solange sie an ist. */
function OptionalAction({
label,
active,
onToggle,
children,
}: {
label: string;
active: boolean;
onToggle: (on: boolean) => void;
children?: React.ReactNode;
}) {
return (
<div className="res-field">
<span className="res-field-label">
<label className="res-ov-action-toggle">
<input
type="checkbox"
checked={active}
onChange={(e) => onToggle(e.target.checked)}
/>{" "}
{label}
</label>
</span>
<span className="res-field-control">
{active ? children : <span className="res-joint-none"></span>}
</span>
</div>
);
}
/** Detail-Panel EINER Override-Regel (rechte Seite des Master-Detail-Layouts). */
function OverrideRuleDetail({
rule,
project,
onPatch,
onDelete,
}: {
rule: OverrideRule;
project: Project;
onPatch: (patch: Partial<OverrideRule>) => void;
onDelete: () => void;
}) {
const patchCondition = (patch: Partial<OverrideRule["condition"]>) =>
onPatch({ condition: { ...rule.condition, ...patch } });
const patchActions = (patch: Partial<OverrideRule["actions"]>) =>
onPatch({ actions: { ...rule.actions, ...patch } });
// Sentinel „—": kein Linienstil-Override.
const NONE = "__none__";
const lineOptions = [
{ value: NONE, label: t("resources.overrides.linetype.none") },
...project.lineStyles.map((l) => ({ value: l.id, label: l.name })),
];
return (
<div className="res-md-detail-inner">
<div className="res-md-head">
<input
className="res-input res-md-rename"
value={rule.name}
placeholder={t("resources.overrides.placeholder")}
onChange={(e) => onPatch({ name: e.target.value })}
/>
<DeleteButton onClick={onDelete} title={t("resources.overrides.delete")} />
</div>
<div className="res-hint">{t("resources.overrides.condition")}</div>
<FieldRow label={t("resources.overrides.condType")}>
<SelectField
value={rule.condition.type}
onChange={(type) => patchCondition({ type })}
options={OVERRIDE_TYPE_OPTIONS.map((o) => ({
value: o.value,
label: t(o.labelKey),
}))}
/>
</FieldRow>
<FieldRow label={t("resources.overrides.operator")}>
<SelectField
value={rule.condition.operator}
onChange={(operator) => patchCondition({ operator })}
options={OVERRIDE_OP_OPTIONS.map((o) => ({
value: o.value,
label: t(o.labelKey),
}))}
/>
</FieldRow>
<FieldRow label={t("resources.overrides.value")}>
<TextField
value={rule.condition.value}
placeholder={t("resources.overrides.value.placeholder")}
onChange={(value) => patchCondition({ value })}
/>
</FieldRow>
<div className="res-hint">{t("resources.overrides.actions")}</div>
{/* Vorschlagswerte für die Strichstärke (Standard-Stiftbreiten in mm). */}
<datalist id="ov-pen-weights">
{PEN_WEIGHTS.map((w) => (
<option key={w} value={w} />
))}
</datalist>
<OptionalAction
label={t("resources.overrides.action.color")}
active={rule.actions.color !== undefined}
onToggle={(on) => patchActions({ color: on ? "#808080" : undefined })}
>
<ColorField
color={rule.actions.color ?? "#808080"}
onChange={(color) => patchActions({ color })}
/>
</OptionalAction>
<OptionalAction
label={t("resources.overrides.action.lineweight")}
active={rule.actions.lineweight !== undefined}
onToggle={(on) => patchActions({ lineweight: on ? 0.35 : undefined })}
>
<NumberField
value={rule.actions.lineweight ?? 0.35}
onChange={(lineweight) => patchActions({ lineweight: Math.max(0, lineweight) })}
step={0.05}
min={0}
list="ov-pen-weights"
/>
</OptionalAction>
<OptionalAction
label={t("resources.overrides.action.linetype")}
active={rule.actions.linetypeId !== undefined}
onToggle={(on) =>
patchActions({
linetypeId: on ? project.lineStyles[0]?.id : undefined,
})
}
>
<SelectField
value={rule.actions.linetypeId ?? NONE}
onChange={(v) =>
patchActions({ linetypeId: v === NONE ? undefined : v })
}
options={lineOptions}
/>
</OptionalAction>
</div>
);
}
/** Tab „Overrides": Regelliste (Priorität von oben nach unten) + Detail. */
function OverridesTab({
project,
onSetOverrideRules,
}: {
project: Project;
onSetOverrideRules?: (rules: OverrideRule[]) => void;
}) {
const rules = project.overrideRules ?? [];
const [selectedId, setSelectedId] = useState<string | null>(null);
const selected = rules.find((r) => r.id === selectedId) ?? rules[0];
const setRules = (next: OverrideRule[]) => onSetOverrideRules?.(next);
const patchRule = (id: string, patch: Partial<OverrideRule>) =>
setRules(rules.map((r) => (r.id === id ? { ...r, ...patch } : r)));
const addRule = () => {
const rule: OverrideRule = {
id: `ov-${Date.now()}`,
name: t("resources.overrides.defaultName"),
enabled: true,
condition: { type: "layer_name", operator: "contains", value: "" },
actions: {},
};
setRules([...rules, rule]);
setSelectedId(rule.id);
};
const deleteRule = (id: string) => setRules(rules.filter((r) => r.id !== id));
const moveRule = (id: string, dir: -1 | 1) => {
const i = rules.findIndex((r) => r.id === id);
const j = i + dir;
if (i < 0 || j < 0 || j >= rules.length) return;
const next = rules.slice();
[next[i], next[j]] = [next[j], next[i]];
setRules(next);
};
return (
<div className="res-tab">
<div className="res-hint">{t("resources.overrides.hint")}</div>
<div className="res-md">
<div className="res-md-list">
{rules.map((r) => (
<div
key={r.id}
role="button"
tabIndex={0}
className={`res-md-row res-ov-row${
selected?.id === r.id ? " active" : ""
}`}
style={{ display: "flex", alignItems: "center", gap: 6, cursor: "pointer" }}
onClick={() => setSelectedId(r.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") setSelectedId(r.id);
}}
>
<input
type="checkbox"
checked={r.enabled}
title={t("resources.overrides.enabled")}
onClick={(e) => e.stopPropagation()}
onChange={(e) => patchRule(r.id, { enabled: e.target.checked })}
/>
<span
className="res-md-row-name"
style={{ flex: 1, opacity: r.enabled ? 1 : 0.5 }}
>
{r.name || t("resources.overrides.placeholder")}
<span
style={{ display: "block", fontSize: "0.85em", opacity: 0.65 }}
>
{overrideConditionSummary(r)}
</span>
</span>
<button
type="button"
className="res-layer-btn"
title={t("resources.overrides.moveUp")}
disabled={rules[0]?.id === r.id}
onClick={(e) => {
e.stopPropagation();
moveRule(r.id, -1);
}}
>
</button>
<button
type="button"
className="res-layer-btn"
title={t("resources.overrides.moveDown")}
disabled={rules[rules.length - 1]?.id === r.id}
onClick={(e) => {
e.stopPropagation();
moveRule(r.id, 1);
}}
>
</button>
</div>
))}
{rules.length === 0 && (
<div className="res-md-empty">{t("resources.overrides.empty")}</div>
)}
<div className="res-md-listfoot">
<button
className="res-add"
onClick={addRule}
disabled={!onSetOverrideRules}
>
<span className="res-add-plus">+</span> {t("resources.add")}
</button>
</div>
</div>
<div className="res-md-detail">
{selected ? (
<OverrideRuleDetail
key={selected.id}
rule={selected}
project={project}
onPatch={(patch) => patchRule(selected.id, patch)}
onDelete={() => deleteRule(selected.id)}
/>
) : (
<div className="res-md-empty">{t("resources.overrides.empty")}</div>
)}
</div>
</div>
</div>
);
}
// ── Gemeinsame kleine Bausteine ────────────────────────────────────────────
function EmptyState({ text }: { text: string }) {
@@ -2039,7 +2863,11 @@ type TabId =
| "lines"
| "wallStyles"
| "ceilingStyles"
| "materials";
| "doorStyles"
| "windowStyles"
| "stairStyles"
| "materials"
| "overrides";
const TABS: { id: TabId; labelKey: string }[] = [
{ id: "components", labelKey: "resources.tab.components" },
@@ -2047,7 +2875,11 @@ const TABS: { id: TabId; labelKey: string }[] = [
{ id: "lines", labelKey: "resources.tab.lines" },
{ id: "wallStyles", labelKey: "resources.tab.wallStyles" },
{ id: "ceilingStyles", labelKey: "resources.tab.ceilingStyles" },
{ id: "doorStyles", labelKey: "resources.tab.doorStyles" },
{ id: "windowStyles", labelKey: "resources.tab.windowStyles" },
{ id: "stairStyles", labelKey: "resources.tab.stairStyles" },
{ id: "materials", labelKey: "resources.tab.materials" },
{ id: "overrides", labelKey: "resources.tab.overrides" },
];
/** Bündel der immutablen Mutationen (von App über setProject bereitgestellt). */
@@ -2072,6 +2904,26 @@ export interface ResourceManagerHandlers {
/** Import: fügt fertige (id-lose) Linienstile/Schraffuren hinzu (.lin/.pat). */
onImportLineStyles: (styles: Omit<LineStyle, "id">[]) => void;
onImportHatches: (hatches: Omit<HatchStyle, "id">[]) => void;
/**
* Grafische Override-Regeln: ersetzt die GANZE Regelliste (Reihenfolge =
* Priorität). Optional, damit bestehende Einbettungen (z. B. ResourcesPanel)
* ohne Änderung weiter kompilieren ohne Handler ist der Tab nur lesend.
*/
onSetOverrideRules?: (rules: OverrideRule[]) => void;
/**
* Bauteil-Typen (Tür/Fenster/Treppe) Bibliotheks-CRUD, analog Wand-/
* Deckentypen. Optional, damit bestehende Einbettungen (z. B. ResourcesPanel)
* ohne Änderung weiter kompilieren; ohne Handler bleiben die Tabs verborgen.
*/
onAddDoorType?: () => void;
onPatchDoorType?: (id: string, patch: Partial<DoorType>) => void;
onDeleteDoorType?: (id: string) => void;
onAddWindowType?: () => void;
onPatchWindowType?: (id: string, patch: Partial<WindowType>) => void;
onDeleteWindowType?: (id: string) => void;
onAddStairType?: () => void;
onPatchStairType?: (id: string, patch: Partial<StairType>) => void;
onDeleteStairType?: (id: string) => void;
}
// ── Schwebendes Fenster (nicht-modal) ──────────────────────────────────────
@@ -2265,7 +3117,37 @@ export function ResourceManager({
onDeleteCeilingType={handlers.onDeleteCeilingType}
/>
)}
{tab === "doorStyles" && (
<DoorStylesTab
project={project}
onAddDoorType={handlers.onAddDoorType}
onPatchDoorType={handlers.onPatchDoorType}
onDeleteDoorType={handlers.onDeleteDoorType}
/>
)}
{tab === "windowStyles" && (
<WindowStylesTab
project={project}
onAddWindowType={handlers.onAddWindowType}
onPatchWindowType={handlers.onPatchWindowType}
onDeleteWindowType={handlers.onDeleteWindowType}
/>
)}
{tab === "stairStyles" && (
<StairStylesTab
project={project}
onAddStairType={handlers.onAddStairType}
onPatchStairType={handlers.onPatchStairType}
onDeleteStairType={handlers.onDeleteStairType}
/>
)}
{tab === "materials" && <MaterialsTab />}
{tab === "overrides" && (
<OverridesTab
project={project}
onSetOverrideRules={handlers.onSetOverrideRules}
/>
)}
</div>
{/* Greifer unten rechts (Größe ändern). */}
+9
View File
@@ -10,6 +10,7 @@
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import { useEffect, useState } from "react";
import type { ReactNode } from "react";
import { t } from "../i18n";
import { ACCENT_SWATCHES } from "../theme/accents";
import type { Project } from "../model/types";
@@ -32,6 +33,8 @@ export interface SettingsDialogProps {
* Live-Remount der Renderer-Hooks).
*/
onRendererModeChange: (mode: RendererMode) => void;
/** Arbeitsumgebung-Auswahl (Fenster-/Dock-Layouts) — früher in der TopBar. */
layoutMenu: ReactNode;
onClose: () => void;
}
@@ -45,6 +48,7 @@ export function SettingsDialog({
rendererMode,
webGpuAvailable,
onRendererModeChange,
layoutMenu,
onClose,
}: SettingsDialogProps) {
useEffect(() => {
@@ -98,6 +102,11 @@ export function SettingsDialog({
</header>
<div className="imp-body">
<section className="imp-section">
<div className="imp-section-title">{t("settings.section.workspace")}</div>
{layoutMenu}
</section>
<section className="imp-section">
<div className="imp-section-title">{t("status.renderer")}</div>
<span className="sb-renderer-toggle" role="group" aria-label={t("status.renderer")}>
+81
View File
@@ -0,0 +1,81 @@
// Toast — nicht-blockierende Kurzmeldungen (Ersatz für `window.alert`, das im
// Tauri-WKWebView deaktiviert ist und lautlos nichts tut, siehe
// state/notifySlice.ts). Liest `notifications` aus dem Store und rendert
// einen gestapelten Container oben rechts; jede Meldung verschwindet
// automatisch nach ~4s oder per Klick sofort.
//
// Auto-Dismiss-Timer sitzt HIER (nicht in der Slice): jede gemountete Meldung
// bekommt so GENAU einen Timer, sauber an ihren Lebenszyklus gebunden (kein
// Doppel-Timer bei erneutem `notify()`-Aufruf mit identischer Nachricht).
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import { useEffect } from "react";
import { useStore } from "../state/appStore";
import { t } from "../i18n";
import type { NotifyKind } from "../state/notifySlice";
const AUTO_DISMISS_MS = 4000;
/** Top-level Toast-Stack — einmal in App.tsx gerendert. */
export function Toast() {
const notifications = useStore((s) => s.notifications);
const dismissNotify = useStore((s) => s.dismissNotify);
if (notifications.length === 0) return null;
return (
<div className="toast-stack" role="status" aria-live="polite">
{notifications.map((n) => (
<ToastItem
key={n.id}
id={n.id}
message={n.message}
kind={n.kind}
onDismiss={dismissNotify}
/>
))}
</div>
);
}
function ToastItem({
id,
message,
kind,
onDismiss,
}: {
id: string;
message: string;
kind: NotifyKind;
onDismiss: (id: string) => void;
}) {
// Auto-Dismiss: EIN Timer pro gemounteter Meldung, an ihre id gebunden.
useEffect(() => {
const timer = window.setTimeout(() => onDismiss(id), AUTO_DISMISS_MS);
return () => window.clearTimeout(timer);
// onDismiss ist referenzstabil (Store-Action) — bewusst nur auf `id` reagieren.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
return (
<div
className={`toast-item toast-${kind}`}
onClick={() => onDismiss(id)}
role="alert"
>
<span className="toast-message">{message}</span>
<button
type="button"
className="toast-close"
aria-label={t("dock.close")}
onClick={(e) => {
e.stopPropagation();
onDismiss(id);
}}
>
×
</button>
</div>
);
}
+601 -135
View File
@@ -8,15 +8,18 @@
// aus App, Aktionen melden sich über Callbacks zurück. Bezeichner englisch,
// UI-Text/Kommentare deutsch (CONVENTIONS.md).
import type { ReactNode } from "react";
import type { Project } from "../model/types";
import { t } from "../i18n";
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Dropdown } from "./Dropdown";
import type { DropdownItem, DropdownOption } from "./Dropdown";
import { PromptDialog } from "./PromptDialog";
import { WindowControls } from "./WindowControls";
import { RibbonTabs } from "./ribbon/RibbonBar";
import type { RibbonTabId } from "./ribbon/ribbonItems";
import { getCommand } from "../commands/registry";
import { CommandIcon } from "./ribbon/CommandIcon";
import {
applyMark,
applyPreset,
@@ -58,11 +61,25 @@ export type RenderMode =
| "hidden";
/**
* Kanonischer Blickwinkel der 3D-Ansicht (Vectorworks/DOSSIER-Stil). front",
* top", „side", iso" springen auf die jeweilige Standardansicht; „perspective"
* ist der freie 3/4-Blick. Wirkt nur in der Perspektive.
* Kanonischer Blickwinkel der 3D-Ansicht (Vectorworks/DOSSIER-Stil). front"/
* back"/„side"/left" springen auf die vier Kardinalrichtungen (Vorne/Hinten/
* Rechts/Links KEINE Himmelsrichtungs-Labels, da es noch keinen Nordwinkel
* gibt); top" auf die Draufsicht. „iso"/isoFrontLeft"/„isoBackRight"/
* isoBackLeft" sind die vier oberen Iso-Oktanten (untere vier bleiben im
* Hochbau ungenutzt) iso" (vorne-oben-rechts) ist der bestehende Default.
* perspective" ist der freie 3/4-Blick. Wirkt nur in der Perspektive.
*/
export type View3d = "front" | "top" | "side" | "iso" | "perspective";
export type View3d =
| "front"
| "back"
| "side"
| "left"
| "top"
| "iso"
| "isoFrontLeft"
| "isoBackRight"
| "isoBackLeft"
| "perspective";
/**
* Farb-Modus des Grundrisses: color" zeigt Kategorie-/Element-Farben (heutiges
@@ -103,6 +120,21 @@ export interface TopBarProps {
onExportDxf: () => void;
/** Lädt die Bauteilliste des Projekts als CSV herunter. */
onExportSchedule: () => void;
/** Lädt das Projekt als IFC4-STEP-Datei herunter. */
onExportIfc: () => void;
onExportObj: () => void;
onExportStl: () => void;
// Kamera-Settings (FOV), ZUSÄTZLICH zur bestehenden im „Ansichten"-Ribbon-
// Tab (ViewRibbonTab) und im 3D-Ribbon-Tab (Ribbon3dTab) — hier direkt neben
// dem CSV-Export in der schmalen Chrome-Zeile, damit sie immer erreichbar
// ist (Nutzer-Wunsch). Dieselben Felder/Handler wie dort.
viewType: ViewType;
fov: number;
onFov: (n: number) => void;
/** Presets nur sinnvoll, solange ein Geschoss aktiv ist (wie `viewToggleEnabled`
* in {@link ViewRibbonTabProps}). */
cameraEnabled: boolean;
// Ressourcen-Fenster.
resourcesOpen: boolean;
@@ -112,7 +144,6 @@ export interface TopBarProps {
onOpenSettings: () => void;
/** Layout-Menü (von App geliefert, damit dessen Logik dort bleibt). */
layoutMenu: ReactNode;
// ── Datei-Menü (Burger, ganz rechts) ───────────────────────────────────────
/** Öffnet den DXF/DWG-Import-Datei-Dialog. */
@@ -200,14 +231,17 @@ function ComboMenu({
// Dropdown gesteuert ist, halten wir die aktuelle Liste im State und
// aktualisieren sie beim Trigger-Klick (siehe `items`-Aufbau unten).
const [names, setNames] = useState<string[]>([]);
// Name-Erfassung über PromptDialog statt window.prompt (im Tauri-WKWebView
// deaktiviert — liefert sofort null, nichts würde je gespeichert).
const [saving, setSaving] = useState(false);
const refresh = () => setNames(list());
const save = () => {
const name = window.prompt(savePrompt);
if (!name) return;
const save = () => setSaving(true);
const commitSave = (name: string) => {
onSave(name.trim());
refresh();
setSaving(false);
};
// Aktions-Menü (analog ContextMenu-Items): „Aktuelle speichern" oben, dann je
@@ -240,6 +274,14 @@ function ComboMenu({
placeholder={t("combo.placeholder")}
title={title}
/>
{saving && (
<PromptDialog
title={title}
label={savePrompt}
onConfirm={commitSave}
onClose={() => setSaving(false)}
/>
)}
</span>
);
}
@@ -326,6 +368,133 @@ function CameraMenu({
);
}
/** Die vier oberen Iso-Oktanten (untere vier bleiben im Hochbau ungenutzt). */
const ISO_VARIANTS: View3d[] = ["iso", "isoFrontLeft", "isoBackRight", "isoBackLeft"];
/** Häkchen vor der aktiven Iso-Zeile (gleiche Glyphe wie `Dropdown`s Check). */
function IsoCheck() {
return (
<svg width="12" height="12" viewBox="0 0 12 12" aria-hidden="true">
<path
d="M2.5 6.2 L4.7 8.6 L9.5 3"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
/**
* Iso-Oktanten als Split-Button (Vectorworks-Stil): Klick auf das Würfel-Icon
* springt direkt auf die zuletzt gewählte Iso-Ansicht (Default iso" =
* vorne-oben-rechts, s. View3d); der schmale Pfeil daneben öffnet ein Popover
* mit allen vier oberen Iso-Oktanten (untere vier bleiben im Hochbau ungenutzt).
* Popover-Markup/Klassen wie `Dropdown`s Listenmodus (`ctx-menu`/`tb-dd-item`),
* Positionierung wie `CameraMenu` (fixed, kein Portal entkommt dem
* `overflow:hidden` der Oberleiste).
*/
function IsoMenu({
view3d,
onView3d,
viewType,
onViewType,
disabled,
}: {
view3d: View3d;
onView3d: (v: View3d) => void;
viewType: ViewType;
onViewType: (v: ViewType) => void;
disabled: boolean;
}) {
const [open, setOpen] = useState(false);
const [pos, setPos] = useState<{ left: number; top: number }>({ left: 0, top: 0 });
const wrapRef = useRef<HTMLDivElement | null>(null);
const caretRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, [open]);
const current: View3d = ISO_VARIANTS.includes(view3d) ? view3d : "iso";
const active = viewType === "perspektive" && ISO_VARIANTS.includes(view3d);
const select = (v: View3d) => {
onView3d(v);
if (viewType !== "perspektive") onViewType("perspektive");
setOpen(false);
};
const toggle = () => {
const next = !open;
if (next && caretRef.current) {
const r = caretRef.current.getBoundingClientRect();
setPos({ left: r.left, top: r.bottom + 6 });
}
setOpen(next);
};
return (
<div className="tb-popwrap view-icon-split" ref={wrapRef}>
<button
className={`view-icon view-icon-split-main${active ? " active" : ""}`}
disabled={disabled}
onClick={() => select(current)}
title={disabled ? t("topbar.viewGroup.disabled") : `${t(`view3d.${current}`)}${t(`view3d.${current}.hint`)}`}
aria-label={t("view3d.iso")}
>
<ViewIcon name="iso" />
</button>
<button
ref={caretRef}
className={`view-icon-caret${open ? " active" : ""}`}
disabled={disabled}
onClick={toggle}
title={disabled ? t("topbar.viewGroup.disabled") : `${t("view3d.iso.menu")}${t("view3d.iso.menu.hint")}`}
aria-label={t("view3d.iso.menu")}
aria-haspopup="true"
aria-expanded={open}
>
<svg width="9" height="6" viewBox="0 0 10 6" aria-hidden="true">
<path d="M0 0l5 6 5-6z" fill="currentColor" />
</svg>
</button>
{open && !disabled && (
<div
className="ctx-menu tb-dd-menu"
role="menu"
aria-label={t("view3d.iso.menu")}
style={{ left: pos.left, top: pos.top }}
>
{ISO_VARIANTS.map((v) => (
<button
key={v}
type="button"
role="menuitemradio"
aria-checked={view3d === v}
className={`ctx-item tb-dd-item${view3d === v ? " is-active" : ""}`}
onClick={() => select(v)}
title={t(`view3d.${v}.hint`)}
>
<span className="tb-dd-check-slot">{view3d === v && <IsoCheck />}</span>
<span className="ctx-item-label">{t(`view3d.${v}`)}</span>
</button>
))}
</div>
)}
</div>
);
}
/**
* Parst eine Massstab-Eingabe 1:35" ODER „35" zum Nenner N (gerundet, > 0).
* Liefert null bei ungültiger/leerer Eingabe.
@@ -365,9 +534,19 @@ function ViewIcon({ name }: { name: View3d | "camera" | "grundriss" }) {
</svg>
);
case "front":
// Kardinal-Quartett (front/back/side/left): Quadrat + kleiner Steg an der
// Aussenkante zeigt, von welcher Seite die Kamera blickt (Vorne = unten).
return (
<svg {...s}>
<rect x="3.5" y="3.5" width="9" height="9" />
<line x1="8" y1="12.5" x2="8" y2="14.3" />
</svg>
);
case "back":
return (
<svg {...s}>
<rect x="3.5" y="3.5" width="9" height="9" />
<line x1="8" y1="1.7" x2="8" y2="3.5" />
</svg>
);
case "top":
@@ -381,7 +560,14 @@ function ViewIcon({ name }: { name: View3d | "camera" | "grundriss" }) {
return (
<svg {...s}>
<rect x="3.5" y="3.5" width="9" height="9" />
<line x1="8" y1="3.5" x2="8" y2="12.5" />
<line x1="12.5" y1="8" x2="14.3" y2="8" />
</svg>
);
case "left":
return (
<svg {...s}>
<rect x="3.5" y="3.5" width="9" height="9" />
<line x1="1.7" y1="8" x2="3.5" y2="8" />
</svg>
);
case "perspective":
@@ -515,6 +701,8 @@ export function TextGroup({
const [defBold, setDefBold] = useState(false);
const [defItalic, setDefItalic] = useState(false);
const [defUnderline, setDefUnderline] = useState(false);
// Freie Grösse über PromptDialog statt window.prompt (Tauri-WKWebView).
const [sizePromptOpen, setSizePromptOpen] = useState(false);
// Effektiver Bereich am Ziel: expliziter Bereich oder — ohne Bereich — das
// ganze Dokument (mind. 1 Zeichen, damit isMarkActive/applyMark greifen).
@@ -624,17 +812,17 @@ export function TextGroup({
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);
setSizePromptOpen(true);
return;
}
if (value === "__current__") return;
setSizeValue(Number(value));
};
const onSizePromptConfirm = (input: string) => {
const n = Math.round(Number(input.trim()));
if (Number.isFinite(n) && n > 0) setSizeValue(n);
setSizePromptOpen(false);
};
return (
<div className="tb-group tb-textgroup" role="group" aria-label={t("text.group")}>
@@ -763,6 +951,16 @@ export function TextGroup({
</button>
</div>
</div>
{sizePromptOpen && (
<PromptDialog
title={t("text.size.custom")}
label={t("text.size.customPrompt")}
defaultValue={String(curSize > 0 ? curSize : 12)}
type="number"
onConfirm={onSizePromptConfirm}
onClose={() => setSizePromptOpen(false)}
/>
)}
</div>
);
}
@@ -904,6 +1102,200 @@ function QaSep() {
* Ressourcen/Einstellungen als kleine Icon-Knöpfe direkt in der TopBar-Zeile.
* Ersetzt das Burger-Menü (`AppMenu`). Rein gesteuert über Callbacks (i18n via t).
*/
/**
* Kompakte Kamera-Gruppe der Quick-Access-Leiste: NUR das Kamera-Settings-
* Popover (FOV), damit es immer erreichbar ist, ohne die Ansichts-Presets
* (die bleiben unverändert im Ansichten"-Tab und im 3D-Ribbon-Tab).
*/
function QaCameraGroup({
viewType,
fov,
onFov,
cameraEnabled,
}: {
viewType: ViewType;
fov: number;
onFov: (n: number) => void;
cameraEnabled: boolean;
}) {
return (
<div className="tb-qa-camera" role="toolbar" aria-label={t("topbar.viewGroup")}>
<CameraMenu
fov={fov}
onFov={onFov}
disabled={!cameraEnabled || viewType !== "perspektive"}
/>
</div>
);
}
/** App-Version (spiegelt src-tauri/tauri.conf.json). */
const APP_VERSION = "0.1.0";
/**
* Export-Sammelmenü: EIN Export"-Knopf in der Quick-Access-Leiste öffnet ein
* Dropdown (wie ein Kontextmenü) mit allen Export-Formaten ersetzt die früher
* einzeln aufgereihten Export-Icons. Popover fixed positioniert (die Oberleiste
* hat overflow:hidden), Muster wie {@link CameraMenu}.
*/
function ExportMenu({
onExportPdf,
onExportDxf,
onExportSchedule,
onExportIfc,
onExportObj,
onExportStl,
exportEnabled,
}: {
onExportPdf: () => void;
onExportDxf: () => void;
onExportSchedule: () => void;
onExportIfc: () => void;
onExportObj: () => void;
onExportStl: () => void;
exportEnabled: boolean;
}) {
const [open, setOpen] = useState(false);
const [pos, setPos] = useState<{ left: number; top: number }>({ left: 0, top: 0 });
const wrapRef = useRef<HTMLDivElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
// Das Popover hängt per Portal an body (NICHT in wrapRef) — der Outside-Handler
// muss es daher separat als „innen" behandeln, sonst schliesst der mousedown
// das Menü vor dem Klick.
const popRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
const target = e.target as Node;
if (wrapRef.current?.contains(target)) return;
if (popRef.current?.contains(target)) return;
setOpen(false);
};
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, [open]);
const toggle = () => {
const next = !open;
if (next && btnRef.current) {
const r = btnRef.current.getBoundingClientRect();
setPos({ left: r.left, top: r.bottom + 6 });
}
setOpen(next);
};
const pick = (fn: () => void) => {
setOpen(false);
fn();
};
const items: { icon: string; label: string; onClick: () => void; disabled?: boolean }[] = [
{ icon: "picture_as_pdf", label: t("file.exportPdf"), onClick: onExportPdf, disabled: !exportEnabled },
{ icon: "output", label: t("file.exportDxf"), onClick: onExportDxf, disabled: !exportEnabled },
{ icon: "table_view", label: t("file.exportSchedule"), onClick: onExportSchedule },
{ icon: "deployed_code", label: t("file.exportIfc"), onClick: onExportIfc },
{ icon: "view_in_ar", label: t("file.exportObj"), onClick: onExportObj },
{ icon: "landscape", label: t("file.exportStl"), onClick: onExportStl },
];
return (
<div className="tb-popwrap" ref={wrapRef}>
<button
ref={btnRef}
type="button"
className={`tb-qa-btn${open ? " active" : ""}`}
onClick={toggle}
title={t("file.export")}
aria-label={t("file.export")}
aria-haspopup="true"
aria-expanded={open}
>
<TbIcon name="ios_share" />
</button>
{open &&
createPortal(
<div
ref={popRef}
className="tb-popover tb-menu"
role="menu"
aria-label={t("file.export")}
style={{ left: pos.left, top: pos.top }}
>
{items.map((it) => (
<button
key={it.label}
type="button"
className="ctx-item"
role="menuitem"
disabled={it.disabled}
onClick={() => pick(it.onClick)}
>
<span className="ctx-item-icon">
<TbIcon name={it.icon} />
</span>
<span className="ctx-item-label">{it.label}</span>
</button>
))}
</div>,
document.body,
)}
</div>
);
}
/**
* Über"-Dialog (In-App, dunkel) App-Marke, Version, Kurzbeschreibung und die
* Lizenzen der wichtigsten Open-Source-Bausteine. Öffnet per Klick auf die
* Wortmarke. Klick ausserhalb / Esc schliesst.
*/
function AboutDialog({ onClose }: { onClose: () => void }) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [onClose]);
return (
<div className="about-overlay" onMouseDown={onClose}>
<div
className="about-dialog"
role="dialog"
aria-modal="true"
aria-label={t("about.title")}
onMouseDown={(e) => e.stopPropagation()}
>
<div className="about-brand">
dossier<span className="brand-dot">.</span>
</div>
<div className="about-version">
{t("about.version")} {APP_VERSION}
</div>
<p className="about-desc">{t("about.desc")}</p>
<div className="about-meta">
<div>{t("about.copyright")}</div>
<div>{t("about.license")}</div>
<div>{t("about.suite")}</div>
</div>
<div className="about-sec-label">{t("about.licenses")}</div>
<ul className="about-licenses">
<li>React · Vite MIT</li>
<li>Tauri MIT / Apache-2.0</li>
<li>wgpu (render3d) MIT / Apache-2.0</li>
<li>truck · csgrs Apache-2.0</li>
<li>OpenCascade.js LGPL-2.1</li>
<li>LibreDWG GPL-3.0</li>
<li>acadrust MPL-2.0</li>
</ul>
<button type="button" className="about-close" onClick={onClose}>
{t("about.close")}
</button>
</div>
</div>
);
}
function QuickAccess({
onOpenProject,
onSaveProject,
@@ -911,10 +1303,17 @@ function QuickAccess({
onExportPdf,
onExportDxf,
onExportSchedule,
onExportIfc,
onExportObj,
onExportStl,
onToggleResources,
onOpenSettings,
resourcesOpen,
exportEnabled,
viewType,
fov,
onFov,
cameraEnabled,
}: {
onOpenProject: () => void;
onSaveProject: () => void;
@@ -922,10 +1321,17 @@ function QuickAccess({
onExportPdf: () => void;
onExportDxf: () => void;
onExportSchedule: () => void;
onExportIfc: () => void;
onExportObj: () => void;
onExportStl: () => void;
onToggleResources: () => void;
onOpenSettings: () => void;
resourcesOpen: boolean;
exportEnabled: boolean;
viewType: ViewType;
fov: number;
onFov: (n: number) => void;
cameraEnabled: boolean;
}) {
return (
<div className="tb-qa" role="toolbar" aria-label={t("file.menu")}>
@@ -933,22 +1339,21 @@ function QuickAccess({
<QaButton icon="save" label={t("file.save")} onClick={onSaveProject} />
<QaSep />
<QaButton icon="download" label={t("file.import")} onClick={onImport} />
<QaButton
icon="picture_as_pdf"
label={t("file.exportPdf")}
onClick={onExportPdf}
disabled={!exportEnabled}
<ExportMenu
onExportPdf={onExportPdf}
onExportDxf={onExportDxf}
onExportSchedule={onExportSchedule}
onExportIfc={onExportIfc}
onExportObj={onExportObj}
onExportStl={onExportStl}
exportEnabled={exportEnabled}
/>
<QaButton
icon="output"
label={t("file.exportDxf")}
onClick={onExportDxf}
disabled={!exportEnabled}
/>
<QaButton
icon="table_view"
label={t("file.exportSchedule")}
onClick={onExportSchedule}
<QaSep />
<QaCameraGroup
viewType={viewType}
fov={fov}
onFov={onFov}
cameraEnabled={cameraEnabled}
/>
<QaSep />
<QaButton
@@ -972,8 +1377,6 @@ export interface ViewRibbonTabProps {
onViewType: (v: ViewType) => void;
view3d: View3d;
onView3d: (v: View3d) => void;
fov: number;
onFov: (n: number) => void;
detail: DetailLevel;
onDetail: (d: DetailLevel) => void;
detailEnabled: boolean;
@@ -1009,8 +1412,6 @@ export function ViewRibbonTab({
onViewType,
view3d,
onView3d,
fov,
onFov,
detail,
onDetail,
detailEnabled,
@@ -1030,20 +1431,22 @@ export function ViewRibbonTab({
combos,
textTarget,
}: ViewRibbonTabProps) {
// „frei" wird über einen Prompt erfasst; akzeptiert „1:35" ODER „35".
// „frei" wird über einen In-App-Dialog erfasst (window.prompt ist im Tauri-
// WKWebView deaktiviert); akzeptiert „1:35" ODER „35".
const [scalePromptOpen, setScalePromptOpen] = useState(false);
const onScaleChange = (value: string) => {
if (value === "__custom__") {
const input = window.prompt(
t("topbar.scale.prompt"),
`1:${scaleDenominator}`,
);
const n = parseScaleInput(input);
if (n != null) onApplyScale(n);
setScalePromptOpen(true);
return;
}
const n = Number(value);
if (Number.isFinite(n) && n > 0) onApplyScale(n);
};
const onScalePromptConfirm = (input: string) => {
const n = parseScaleInput(input);
if (n != null) onApplyScale(n);
setScalePromptOpen(false);
};
const inPresets = SCALE_PRESETS.includes(scaleDenominator);
const scaleOptions: DropdownOption[] = [
...(inPresets
@@ -1080,35 +1483,43 @@ export function ViewRibbonTab({
["perspective", t("view3d.perspective")],
["front", t("view3d.front")],
["side", t("view3d.side")],
["back", t("view3d.back")],
["left", t("view3d.left")],
] as [View3d, string][]
).map(([key, label]) => (
<button
key={key}
className={`view-icon${
viewToggleEnabled && viewType === "perspektive" && view3d === key
? " active"
: ""
}`}
onClick={() => {
onView3d(key);
if (viewType !== "perspektive") onViewType("perspektive");
}}
disabled={!viewToggleEnabled}
title={
viewToggleEnabled
? `${label}${t(`view3d.${key}.hint`)}`
: t("topbar.viewGroup.disabled")
}
aria-label={label}
>
<ViewIcon name={key} />
</button>
))}
<CameraMenu
fov={fov}
onFov={onFov}
disabled={!viewToggleEnabled || viewType !== "perspektive"}
/>
).map(([key, label]) =>
key === "iso" ? (
<IsoMenu
key={key}
view3d={view3d}
onView3d={onView3d}
viewType={viewType}
onViewType={onViewType}
disabled={!viewToggleEnabled}
/>
) : (
<button
key={key}
className={`view-icon${
viewToggleEnabled && viewType === "perspektive" && view3d === key
? " active"
: ""
}`}
onClick={() => {
onView3d(key);
if (viewType !== "perspektive") onViewType("perspektive");
}}
disabled={!viewToggleEnabled}
title={
viewToggleEnabled
? `${label}${t(`view3d.${key}.hint`)}`
: t("topbar.viewGroup.disabled")
}
aria-label={label}
>
<ViewIcon name={key} />
</button>
),
)}
</div>
</div>
<Sep />
@@ -1140,8 +1551,9 @@ export function ViewRibbonTab({
</div>
<Sep />
{/* Detailgrad (grob/mittel/fein) — wirkt im Grundriss. */}
<div className="tb-group" role="group" aria-label={t("topbar.detailGroup")}>
{/* Detailgrad (grob/mittel/fein) + Darstellungsart übereinander gestapelt
(Nutzer-Wunsch) gleiches Stapel-Muster wie die Kombinationen. */}
<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
@@ -1160,6 +1572,34 @@ export function ViewRibbonTab({
]}
/>
</label>
<label className="tb-field tb-field-row">
<span className="tb-label">{t("display.style")}</span>
{viewType === "perspektive" ? (
<Dropdown
value={renderMode}
disabled={!renderModeEnabled}
onChange={(v) => onRenderMode(v as RenderMode)}
title={t("display.style.hint")}
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") },
]}
/>
) : (
<Dropdown
value={planColorMode}
onChange={(v) => onPlanColorMode(v as PlanColorMode)}
title={t("display.style.hint")}
options={[
{ value: "color", label: t("display.style.color") },
{ value: "mono", label: t("display.style.mono") },
]}
/>
)}
</label>
</div>
<Sep />
@@ -1221,43 +1661,20 @@ export function ViewRibbonTab({
</div>
<Sep />
{/* Darstellungsart — Grundriss: Farbig/SW; Perspektive: Render-Modus. */}
<div className="tb-group" role="group" aria-label={t("display.style.group")}>
<label className="tb-field">
<span className="tb-label">{t("display.style")}</span>
{viewType === "perspektive" ? (
<Dropdown
value={renderMode}
disabled={!renderModeEnabled}
onChange={(v) => onRenderMode(v as RenderMode)}
title={t("display.style.hint")}
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") },
]}
/>
) : (
<Dropdown
value={planColorMode}
onChange={(v) => onPlanColorMode(v as PlanColorMode)}
title={t("display.style.hint")}
options={[
{ value: "color", label: t("display.style.color") },
{ value: "mono", label: t("display.style.mono") },
]}
/>
)}
</label>
</div>
<Sep />
{/* Text-/Font-Formatierung auf DERSELBEN Leiste (Nutzer-Wunsch: Text und
Ansichten zusammen) Stil/Schrift/Grösse + B/I/U · L/C/R + Zeilenhöhe,
formatiert das aktuelle Text-Ziel (Raumstempel) live. */}
<TextGroup textTarget={textTarget} />
{scalePromptOpen && (
<PromptDialog
title={t("topbar.scale.custom")}
label={t("topbar.scale.prompt")}
defaultValue={`1:${scaleDenominator}`}
onConfirm={onScalePromptConfirm}
onClose={() => setScalePromptOpen(false)}
/>
)}
</div>
);
}
@@ -1268,11 +1685,11 @@ export interface Ribbon3dTabProps {
onViewType: (v: ViewType) => void;
view3d: View3d;
onView3d: (v: View3d) => void;
fov: number;
onFov: (n: number) => void;
renderMode: RenderMode;
onRenderMode: (m: RenderMode) => void;
renderModeEnabled: boolean;
onRunCommand: (name: string) => void;
activeCommand: string | null;
}
/** 3D-Tab des Ribbons Kamera-Presets + Darstellungsart. Kein Duplikat des
@@ -1283,11 +1700,11 @@ export function Ribbon3dTab({
onViewType,
view3d,
onView3d,
fov,
onFov,
renderMode,
onRenderMode,
renderModeEnabled,
onRunCommand,
activeCommand,
}: Ribbon3dTabProps) {
return (
<div className="ribbon-views">
@@ -1309,27 +1726,35 @@ export function Ribbon3dTab({
["perspective", t("view3d.perspective")],
["front", t("view3d.front")],
["side", t("view3d.side")],
["back", t("view3d.back")],
["left", t("view3d.left")],
] as [View3d, string][]
).map(([key, label]) => (
<button
key={key}
className={`view-icon${viewToggleEnabled && viewType === "perspektive" && view3d === key ? " active" : ""}`}
onClick={() => {
onView3d(key);
if (viewType !== "perspektive") onViewType("perspektive");
}}
disabled={!viewToggleEnabled}
title={viewToggleEnabled ? `${label}${t(`view3d.${key}.hint`)}` : t("topbar.viewGroup.disabled")}
aria-label={label}
>
<ViewIcon name={key} />
</button>
))}
<CameraMenu
fov={fov}
onFov={onFov}
disabled={!viewToggleEnabled || viewType !== "perspektive"}
/>
).map(([key, label]) =>
key === "iso" ? (
<IsoMenu
key={key}
view3d={view3d}
onView3d={onView3d}
viewType={viewType}
onViewType={onViewType}
disabled={!viewToggleEnabled}
/>
) : (
<button
key={key}
className={`view-icon${viewToggleEnabled && viewType === "perspektive" && view3d === key ? " active" : ""}`}
onClick={() => {
onView3d(key);
if (viewType !== "perspektive") onViewType("perspektive");
}}
disabled={!viewToggleEnabled}
title={viewToggleEnabled ? `${label}${t(`view3d.${key}.hint`)}` : t("topbar.viewGroup.disabled")}
aria-label={label}
>
<ViewIcon name={key} />
</button>
),
)}
</div>
</div>
<Sep />
@@ -1351,6 +1776,26 @@ export function Ribbon3dTab({
/>
</label>
</div>
<Sep />
<div className="tb-group" role="group" aria-label={t("cmd.extrude.label")}>
{(() => {
const cmd = getCommand("extrude");
const label = cmd ? t(cmd.labelKey) : "extrude";
const active = activeCommand === "extrude";
return (
<button
type="button"
className={`ribbon-btn${active ? " active" : ""}`}
disabled={!viewToggleEnabled}
title={viewToggleEnabled ? label : t("tool.floorOnlyDisabled")}
onClick={() => onRunCommand("extrude")}
>
<CommandIcon name="extrude" />
<span>{label}</span>
</button>
);
})()}
</div>
</div>
);
}
@@ -1363,10 +1808,16 @@ export function TopBar({
onExportPdf,
onExportDxf,
onExportSchedule,
onExportIfc,
onExportObj,
onExportStl,
viewType,
fov,
onFov,
cameraEnabled,
resourcesOpen,
onToggleResources,
onOpenSettings,
layoutMenu,
onImport,
onSaveProject,
onOpenProject,
@@ -1393,6 +1844,8 @@ export function TopBar({
// Unter Tauri (native macOS-Ampeln oben links via titleBarStyle "Overlay")
// braucht die Zeile links Platz, damit `dossier.` nicht darunter liegt.
const [aboutOpen, setAboutOpen] = useState(false);
const tauriNativeTitlebar =
typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
@@ -1415,10 +1868,17 @@ export function TopBar({
Access-Leiste: alle Datei-/Export-Aktionen + Ressourcen/Einstellungen
als kleine Icons direkt in der Zeile (ersetzt das Burger-Menü). */}
<div className="tb-brandgroup">
<span className="brand-word">
<button
type="button"
className="brand-word"
onClick={() => setAboutOpen(true)}
title={t("about.title")}
aria-label={t("about.title")}
>
dossier<span className="brand-dot">.</span>
</span>
</button>
</div>
{aboutOpen && <AboutDialog onClose={() => setAboutOpen(false)} />}
<QuickAccess
onOpenProject={onOpenProject}
onSaveProject={onSaveProject}
@@ -1426,10 +1886,17 @@ export function TopBar({
onExportPdf={onExportPdf}
onExportDxf={onExportDxf}
onExportSchedule={onExportSchedule}
onExportIfc={onExportIfc}
onExportObj={onExportObj}
onExportStl={onExportStl}
onToggleResources={onToggleResources}
onOpenSettings={onOpenSettings}
resourcesOpen={resourcesOpen}
exportEnabled={zoomEnabled}
viewType={viewType}
fov={fov}
onFov={onFov}
cameraEnabled={cameraEnabled}
/>
<Sep />
@@ -1439,11 +1906,10 @@ export function TopBar({
Bänder: diese schmale Leiste (Chrome + Tabs) und den Inhalt. */}
<RibbonTabs tab={ribbonTab} onTab={onRibbonTab} />
{/* Rechte Gruppe: Layout-Menü · Projektname. Die Fensterknöpfe sitzen jetzt
ganz links (macOS-Titelleisten-Konvention); Datei-/Export-Aktionen in
der Quick-Access-Leiste (kein Burger). */}
{/* Rechte Gruppe: Projektname. Die Fensterknöpfe sitzen ganz links (macOS-
Titelleisten-Konvention); Datei-/Export-Aktionen in der Quick-Access-
Leiste; die Arbeitsumgebung (Layout) liegt jetzt in den Einstellungen. */}
<div className="topbar-right">
{layoutMenu}
<span className="project-name">{project.name}</span>
</div>
</header>
+304
View File
@@ -0,0 +1,304 @@
import { describe, expect, it } from "vitest";
import type { LayoutAnnotation, LayoutViewport } from "../model/types";
import {
annotationBoundsMm,
applyHandleDrag,
clampRectToSheet,
distanceToSegment,
fitTransform,
handleAt,
mmToScreen,
normalizeRect,
panBy,
pickAnnotation,
pickViewport,
screenToMm,
translateAnnotation,
vpRect,
zoomAtCursor,
type RectMm,
type SheetTransform,
} from "./layoutSheetMath";
const vp = (
id: string,
xMm: number,
yMm: number,
widthMm: number,
heightMm: number,
): LayoutViewport => ({ id, snapshotId: "s", xMm, yMm, widthMm, heightMm });
describe("mm ↔ screen", () => {
const t: SheetTransform = { scale: 2, tx: 100, ty: 50 };
it("mmToScreen applies scale + translate", () => {
expect(mmToScreen(t, { xMm: 10, yMm: 20 })).toEqual({ x: 120, y: 90 });
});
it("screenToMm is the inverse", () => {
const p = screenToMm(t, 120, 90);
expect(p.xMm).toBeCloseTo(10);
expect(p.yMm).toBeCloseTo(20);
});
it("roundtrips arbitrary points", () => {
const s = mmToScreen(t, { xMm: 37.5, yMm: -12.25 });
const m = screenToMm(t, s.x, s.y);
expect(m.xMm).toBeCloseTo(37.5);
expect(m.yMm).toBeCloseTo(-12.25);
});
});
describe("fitTransform", () => {
it("centres an A4 portrait sheet in a wide canvas", () => {
const t = fitTransform(210, 297, 1000, 600, 0.92);
// Höhe ist der Engpass: scale = 600*0.92/297.
expect(t.scale).toBeCloseTo((600 * 0.92) / 297);
// Zentriert: linker Rand = (1000 - 210*scale)/2.
expect(t.tx).toBeCloseTo((1000 - 210 * t.scale) / 2);
expect(t.ty).toBeCloseTo((600 - 297 * t.scale) / 2);
});
it("falls back to scale 1 on degenerate canvas", () => {
const t = fitTransform(210, 297, 0, 0);
expect(t.scale).toBe(1);
});
});
describe("zoomAtCursor", () => {
it("keeps the mm point under the cursor fixed", () => {
const t: SheetTransform = { scale: 2, tx: 100, ty: 50 };
const cursor = { x: 300, y: 220 };
const before = screenToMm(t, cursor.x, cursor.y);
const zt = zoomAtCursor(t, 1.25, cursor.x, cursor.y);
expect(zt.scale).toBeCloseTo(2.5);
const after = screenToMm(zt, cursor.x, cursor.y);
expect(after.xMm).toBeCloseTo(before.xMm);
expect(after.yMm).toBeCloseTo(before.yMm);
});
it("clamps scale to bounds", () => {
const t: SheetTransform = { scale: 30, tx: 0, ty: 0 };
const zt = zoomAtCursor(t, 4, 0, 0);
expect(zt.scale).toBeLessThanOrEqual(40);
});
});
describe("panBy", () => {
it("shifts translation, keeps scale", () => {
const t = panBy({ scale: 3, tx: 10, ty: 20 }, 5, -7);
expect(t).toEqual({ scale: 3, tx: 15, ty: 13 });
});
});
describe("handleAt", () => {
const r: RectMm = { xMm: 20, yMm: 20, widthMm: 100, heightMm: 60 };
it("detects corners with priority over edges", () => {
expect(handleAt(r, 20, 20, 3)).toBe("nw");
expect(handleAt(r, 120, 80, 3)).toBe("se");
});
it("detects edges", () => {
expect(handleAt(r, 70, 20, 3)).toBe("n");
expect(handleAt(r, 120, 50, 3)).toBe("e");
expect(handleAt(r, 20, 50, 3)).toBe("w");
expect(handleAt(r, 70, 80, 3)).toBe("s");
});
it("detects the body inside", () => {
expect(handleAt(r, 70, 50, 3)).toBe("body");
});
it("returns null well outside", () => {
expect(handleAt(r, 5, 5, 3)).toBe(null);
expect(handleAt(r, 200, 200, 3)).toBe(null);
});
});
describe("pickViewport", () => {
const vps = [vp("a", 0, 0, 100, 100), vp("b", 40, 40, 100, 100)];
it("returns the topmost (last) viewport in overlap", () => {
const hit = pickViewport(vps, 60, 60, 2);
expect(hit?.id).toBe("b");
expect(hit?.handle).toBe("body");
});
it("returns the lower one where only it is hit", () => {
const hit = pickViewport(vps, 10, 10, 2);
expect(hit?.id).toBe("a");
});
it("returns null on empty space", () => {
expect(pickViewport(vps, 300, 300, 2)).toBe(null);
});
});
describe("applyHandleDrag", () => {
const start: RectMm = { xMm: 20, yMm: 20, widthMm: 100, heightMm: 60 };
it("moves the whole rect for body", () => {
expect(applyHandleDrag(start, "body", 10, -5)).toEqual({
xMm: 30,
yMm: 15,
widthMm: 100,
heightMm: 60,
});
});
it("drags the east edge (width only)", () => {
const r = applyHandleDrag(start, "e", 25, 0);
expect(r).toEqual({ xMm: 20, yMm: 20, widthMm: 125, heightMm: 60 });
});
it("drags the west edge (x + width, right edge fixed)", () => {
const r = applyHandleDrag(start, "w", 30, 0);
expect(r.xMm).toBe(50);
expect(r.widthMm).toBe(70);
expect(r.xMm + r.widthMm).toBe(120); // rechte Kante unverändert
});
it("enforces min width when dragging west past the limit", () => {
const r = applyHandleDrag(start, "w", 200, 0, 5);
expect(r.widthMm).toBe(5);
expect(r.xMm + r.widthMm).toBe(120);
});
it("drags a corner on both axes", () => {
const r = applyHandleDrag(start, "se", 10, 20);
expect(r).toEqual({ xMm: 20, yMm: 20, widthMm: 110, heightMm: 80 });
});
it("enforces min height dragging north past the limit", () => {
const r = applyHandleDrag(start, "n", 0, 200, 5);
expect(r.heightMm).toBe(5);
expect(r.yMm + r.heightMm).toBe(80);
});
});
describe("normalizeRect", () => {
it("orders corners into min + positive size", () => {
expect(normalizeRect(120, 90, 20, 20)).toEqual({
xMm: 20,
yMm: 20,
widthMm: 100,
heightMm: 70,
});
});
});
describe("clampRectToSheet", () => {
it("keeps an in-bounds rect unchanged", () => {
const r: RectMm = { xMm: 10, yMm: 10, widthMm: 50, heightMm: 50 };
expect(clampRectToSheet(r, 210, 297)).toEqual(r);
});
it("pushes an overhanging rect back inside", () => {
const r: RectMm = { xMm: 200, yMm: 280, widthMm: 50, heightMm: 50 };
const c = clampRectToSheet(r, 210, 297);
expect(c.xMm).toBe(160);
expect(c.yMm).toBe(247);
expect(c.widthMm).toBe(50);
});
it("shrinks a rect larger than the sheet", () => {
const r: RectMm = { xMm: -20, yMm: -20, widthMm: 400, heightMm: 500 };
const c = clampRectToSheet(r, 210, 297);
expect(c).toEqual({ xMm: 0, yMm: 0, widthMm: 210, heightMm: 297 });
});
});
describe("vpRect", () => {
it("projects a viewport to its rect", () => {
expect(vpRect(vp("x", 5, 6, 7, 8))).toEqual({
xMm: 5,
yMm: 6,
widthMm: 7,
heightMm: 8,
});
});
});
// ── Annotationen (Linie/Rechteck/Text) ──────────────────────────────────────
const line = (id: string, x1: number, y1: number, x2: number, y2: number): LayoutAnnotation => ({
id,
kind: "line",
x1Mm: x1,
y1Mm: y1,
x2Mm: x2,
y2Mm: y2,
});
const rect = (id: string, x: number, y: number, w: number, h: number): LayoutAnnotation => ({
id,
kind: "rect",
xMm: x,
yMm: y,
widthMm: w,
heightMm: h,
});
const text = (id: string, x: number, y: number, t: string, h = 3.5): LayoutAnnotation => ({
id,
kind: "text",
xMm: x,
yMm: y,
text: t,
heightMm: h,
});
describe("distanceToSegment", () => {
it("returns perpendicular distance for a point beside the segment", () => {
expect(distanceToSegment(5, 3, 0, 0, 10, 0)).toBeCloseTo(3);
});
it("clamps to the nearest endpoint beyond the segment", () => {
expect(distanceToSegment(-4, 0, 0, 0, 10, 0)).toBeCloseTo(4);
expect(distanceToSegment(14, 0, 0, 0, 10, 0)).toBeCloseTo(4);
});
it("degenerates to point distance for a zero-length segment", () => {
expect(distanceToSegment(3, 4, 0, 0, 0, 0)).toBeCloseTo(5);
});
});
describe("annotationBoundsMm", () => {
it("rect: its own rectangle", () => {
expect(annotationBoundsMm(rect("r", 10, 20, 30, 40))).toEqual({
xMm: 10,
yMm: 20,
widthMm: 30,
heightMm: 40,
});
});
it("line: normalized bounding rect of both endpoints", () => {
expect(annotationBoundsMm(line("l", 30, 30, 10, 10))).toEqual({
xMm: 10,
yMm: 10,
widthMm: 20,
heightMm: 20,
});
});
it("text: box sits above the baseline, width grows with character count", () => {
const b = annotationBoundsMm(text("t", 10, 20, "AB", 4));
expect(b.xMm).toBe(10);
expect(b.yMm).toBe(16); // 20 - heightMm
expect(b.heightMm).toBeCloseTo(5); // heightMm * 1.25
expect(b.widthMm).toBeGreaterThan(0);
});
});
describe("pickAnnotation", () => {
it("hits a line within tolerance of the segment", () => {
expect(pickAnnotation([line("l", 0, 0, 10, 0)], 5, 1, 2)).toBe("l");
expect(pickAnnotation([line("l", 0, 0, 10, 0)], 5, 5, 2)).toBe(null);
});
it("hits a rect on its border and its interior", () => {
const r = rect("r", 20, 20, 10, 10);
expect(pickAnnotation([r], 20, 20, 1)).toBe("r"); // corner
expect(pickAnnotation([r], 25, 25, 1)).toBe("r"); // interior
expect(pickAnnotation([r], 100, 100, 1)).toBe(null);
});
it("hits a text annotation near its baseline box", () => {
const tx = text("t", 10, 20, "Hallo", 4);
expect(pickAnnotation([tx], 12, 18, 1)).toBe("t");
expect(pickAnnotation([tx], 200, 200, 1)).toBe(null);
});
it("returns the topmost (last) annotation in overlap, like pickViewport", () => {
const overlapping = [rect("a", 0, 0, 50, 50), rect("b", 10, 10, 50, 50)];
expect(pickAnnotation(overlapping, 30, 30, 1)).toBe("b");
});
});
describe("translateAnnotation", () => {
it("moves both endpoints of a line", () => {
const moved = translateAnnotation(line("l", 0, 0, 10, 10), 5, -5);
expect(moved).toMatchObject({ x1Mm: 5, y1Mm: -5, x2Mm: 15, y2Mm: 5 });
});
it("moves the origin of a rect", () => {
const moved = translateAnnotation(rect("r", 10, 10, 20, 20), 3, 4);
expect(moved).toMatchObject({ xMm: 13, yMm: 14, widthMm: 20, heightMm: 20 });
});
it("moves the anchor point of a text annotation", () => {
const moved = translateAnnotation(text("t", 10, 10, "Hi"), 1, 2);
expect(moved).toMatchObject({ xMm: 11, yMm: 12, text: "Hi" });
});
});
+429
View File
@@ -0,0 +1,429 @@
// Reine, testbare Geometrie für die In-Viewport-Layout-Ansicht (Phase 2b):
// Bildschirm↔Blatt-mm-Transform (Pan/Zoom-to-Cursor/Einpassen), Viewport-
// Trefferlogik (Body + 8 Resize-Griffe), Resize-Mathe mit Mindestgrösse,
// Rechteck-Normalisierung (Aufziehen) und Clamp an die Blattkanten.
//
// KEINE React-/DOM-Abhängigkeit — nur Zahlen. Das UI (LayoutSheet.tsx) baut
// darauf auf. Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import type { LayoutAnnotation, LayoutViewport } from "../model/types";
/**
* Affine Blatt-Transform: Bildschirm-Pixel = mm · scale + translate.
* `scale` ist px pro mm (immer > 0), `tx/ty` die Blatt-Ursprungslage (mm 0,0)
* in Canvas-Pixeln.
*/
export interface SheetTransform {
/** Pixel pro Millimeter (> 0). */
scale: number;
/** Bildschirm-X des Blatt-Ursprungs (mm x=0) in Canvas-Pixeln. */
tx: number;
/** Bildschirm-Y des Blatt-Ursprungs (mm y=0) in Canvas-Pixeln. */
ty: number;
}
/** Ein Punkt in Blatt-Millimetern. */
export interface Mm {
xMm: number;
yMm: number;
}
/** Ein achsparalleles Rechteck in Blatt-Millimetern. */
export interface RectMm {
xMm: number;
yMm: number;
widthMm: number;
heightMm: number;
}
/** Resize-Griff (8 Ränder) oder Körper (verschieben) eines Viewports. */
export type Handle =
| "nw"
| "n"
| "ne"
| "e"
| "se"
| "s"
| "sw"
| "w"
| "body";
export const MIN_SCALE = 0.05;
export const MAX_SCALE = 40;
function clamp(v: number, lo: number, hi: number): number {
return v < lo ? lo : v > hi ? hi : v;
}
// ── Transform ────────────────────────────────────────────────────────────────
/** Blatt-mm → Canvas-Pixel. */
export function mmToScreen(t: SheetTransform, p: Mm): { x: number; y: number } {
return { x: p.xMm * t.scale + t.tx, y: p.yMm * t.scale + t.ty };
}
/** Canvas-Pixel → Blatt-mm. */
export function screenToMm(t: SheetTransform, sx: number, sy: number): Mm {
return { xMm: (sx - t.tx) / t.scale, yMm: (sy - t.ty) / t.scale };
}
/**
* Einpass-Transform: Blatt (mm) zentriert in die Canvas-Fläche (px), mit
* `margin` (Anteil, z. B. 0.92 = 8 % Rand). Degenerierte Eingaben scale=1.
*/
export function fitTransform(
sheetWMm: number,
sheetHMm: number,
canvasWPx: number,
canvasHPx: number,
margin = 0.92,
): SheetTransform {
const sx = (canvasWPx * margin) / sheetWMm;
const sy = (canvasHPx * margin) / sheetHMm;
let scale = Math.min(sx, sy);
if (!Number.isFinite(scale) || scale <= 0) scale = 1;
scale = clamp(scale, MIN_SCALE, MAX_SCALE);
const tx = (canvasWPx - sheetWMm * scale) / 2;
const ty = (canvasHPx - sheetHMm * scale) / 2;
return { scale, tx, ty };
}
/**
* Zoom um den Cursor: der mm-Punkt unter (sx,sy) bleibt fix. `factor` > 1 zoomt
* rein, < 1 raus; der Massstab wird auf [MIN_SCALE, MAX_SCALE] geklemmt.
*/
export function zoomAtCursor(
t: SheetTransform,
factor: number,
sx: number,
sy: number,
): SheetTransform {
const newScale = clamp(t.scale * factor, MIN_SCALE, MAX_SCALE);
// mm-Punkt unter dem Cursor VOR dem Zoom.
const m = screenToMm(t, sx, sy);
// Neue Translation, damit derselbe mm-Punkt wieder unter (sx,sy) liegt.
return { scale: newScale, tx: sx - m.xMm * newScale, ty: sy - m.yMm * newScale };
}
/** Verschiebt die Transform um (dxPx,dyPx) Canvas-Pixel (Pan). */
export function panBy(t: SheetTransform, dxPx: number, dyPx: number): SheetTransform {
return { scale: t.scale, tx: t.tx + dxPx, ty: t.ty + dyPx };
}
// ── Viewport-Rechtecke / Treffer ─────────────────────────────────────────────
/** Rechteck-Sicht eines Viewports (nur die Blattgeometrie). */
export function vpRect(vp: LayoutViewport): RectMm {
return { xMm: vp.xMm, yMm: vp.yMm, widthMm: vp.widthMm, heightMm: vp.heightMm };
}
/** Punkt-in-Rechteck (mm), inklusive Rand. */
export function rectContains(r: RectMm, xMm: number, yMm: number): boolean {
return (
xMm >= r.xMm &&
xMm <= r.xMm + r.widthMm &&
yMm >= r.yMm &&
yMm <= r.yMm + r.heightMm
);
}
/**
* Griff (oder Körper) an einem mm-Punkt für EINEN Viewport. `tolMm` ist die
* Fangzone der Ecken/Kanten (in mm). Rückgabe null, wenn ausserhalb des
* Rechtecks (+Toleranz). Ecken haben Vorrang vor Kanten, Kanten vor Körper.
*/
export function handleAt(
r: RectMm,
xMm: number,
yMm: number,
tolMm: number,
): Handle | null {
const x0 = r.xMm;
const y0 = r.yMm;
const x1 = r.xMm + r.widthMm;
const y1 = r.yMm + r.heightMm;
// Ausserhalb des um tol vergrösserten Rechtecks → kein Treffer.
if (xMm < x0 - tolMm || xMm > x1 + tolMm || yMm < y0 - tolMm || yMm > y1 + tolMm) {
return null;
}
const nearL = Math.abs(xMm - x0) <= tolMm;
const nearR = Math.abs(xMm - x1) <= tolMm;
const nearT = Math.abs(yMm - y0) <= tolMm;
const nearB = Math.abs(yMm - y1) <= tolMm;
// Innerhalb der vertikalen/horizontalen Spanne (für Kanten-Treffer).
const inX = xMm >= x0 - tolMm && xMm <= x1 + tolMm;
const inY = yMm >= y0 - tolMm && yMm <= y1 + tolMm;
// Ecken zuerst.
if (nearL && nearT) return "nw";
if (nearR && nearT) return "ne";
if (nearR && nearB) return "se";
if (nearL && nearB) return "sw";
// Kanten.
if (nearT && inX) return "n";
if (nearB && inX) return "s";
if (nearL && inY) return "w";
if (nearR && inY) return "e";
// Körper (echt innen).
if (rectContains(r, xMm, yMm)) return "body";
return null;
}
/**
* Topmost-Treffer über eine Viewport-Liste (letzte = oberste, wie Z-Order der
* Darstellung). Liefert Id + Griff des ERSTEN Treffers von oben, sonst null.
*/
export function pickViewport(
viewports: LayoutViewport[],
xMm: number,
yMm: number,
tolMm: number,
): { id: string; handle: Handle } | null {
for (let i = viewports.length - 1; i >= 0; i--) {
const vp = viewports[i];
const h = handleAt(vpRect(vp), xMm, yMm, tolMm);
if (h) return { id: vp.id, handle: h };
}
return null;
}
// ── Resize / Move ────────────────────────────────────────────────────────────
/**
* Wendet einen Griff-Drag auf ein Start-Rechteck an: `handle` bestimmt, welche
* Kanten mit (dxMm,dyMm) wandern. Körper verschiebt das ganze Rechteck. Die
* Mindestbreite/-höhe `minMm` wird eingehalten (die feste Kante bleibt fix).
*/
export function applyHandleDrag(
start: RectMm,
handle: Handle,
dxMm: number,
dyMm: number,
minMm = 5,
): RectMm {
if (handle === "body") {
return { ...start, xMm: start.xMm + dxMm, yMm: start.yMm + dyMm };
}
let { xMm, yMm, widthMm, heightMm } = start;
const movesLeft = handle === "nw" || handle === "w" || handle === "sw";
const movesRight = handle === "ne" || handle === "e" || handle === "se";
const movesTop = handle === "nw" || handle === "n" || handle === "ne";
const movesBottom = handle === "sw" || handle === "s" || handle === "se";
if (movesLeft) {
// Rechte Kante fix bei start.xMm + start.widthMm.
const right = start.xMm + start.widthMm;
let nx = start.xMm + dxMm;
if (right - nx < minMm) nx = right - minMm;
xMm = nx;
widthMm = right - nx;
} else if (movesRight) {
let nw = start.widthMm + dxMm;
if (nw < minMm) nw = minMm;
widthMm = nw;
}
if (movesTop) {
const bottom = start.yMm + start.heightMm;
let ny = start.yMm + dyMm;
if (bottom - ny < minMm) ny = bottom - minMm;
yMm = ny;
heightMm = bottom - ny;
} else if (movesBottom) {
let nh = start.heightMm + dyMm;
if (nh < minMm) nh = minMm;
heightMm = nh;
}
return { xMm, yMm, widthMm, heightMm };
}
/**
* Normalisiert ein aufgezogenes Rechteck aus zwei Eckpunkten (mm) Min-Ecke +
* absolute Breite/Höhe. Für die Rechteck aufziehen"-Platzierung.
*/
export function normalizeRect(
x0: number,
y0: number,
x1: number,
y1: number,
): RectMm {
return {
xMm: Math.min(x0, x1),
yMm: Math.min(y0, y1),
widthMm: Math.abs(x1 - x0),
heightMm: Math.abs(y1 - y0),
};
}
/**
* Klemmt ein Rechteck so, dass es ganz auf dem Blatt (0..sheet) liegt. Ist das
* Rechteck breiter/höher als das Blatt, wird es auf Blattgrösse gestutzt und an
* (0,0) gelegt. Sonst bleibt die Grösse erhalten und nur die Position wandert.
*/
export function clampRectToSheet(
r: RectMm,
sheetWMm: number,
sheetHMm: number,
): RectMm {
let { xMm, yMm, widthMm, heightMm } = r;
if (widthMm > sheetWMm) {
widthMm = sheetWMm;
xMm = 0;
} else {
xMm = clamp(xMm, 0, sheetWMm - widthMm);
}
if (heightMm > sheetHMm) {
heightMm = sheetHMm;
yMm = 0;
} else {
yMm = clamp(yMm, 0, sheetHMm - heightMm);
}
return { xMm, yMm, widthMm, heightMm };
}
/** CSS-Cursor je Resize-Griff (Körper = move). */
export function handleCursor(handle: Handle): string {
switch (handle) {
case "nw":
case "se":
return "nwse-resize";
case "ne":
case "sw":
return "nesw-resize";
case "n":
case "s":
return "ns-resize";
case "e":
case "w":
return "ew-resize";
default:
return "move";
}
}
/**
* Die 8 Griff-Anker eines Rechtecks in mm (für die Darstellung der Griffe).
* Reihenfolge passend zu {@link Handle} (ohne body").
*/
export function handleAnchors(r: RectMm): { handle: Handle; xMm: number; yMm: number }[] {
const { xMm: x, yMm: y, widthMm: w, heightMm: h } = r;
const cx = x + w / 2;
const cy = y + h / 2;
return [
{ handle: "nw", xMm: x, yMm: y },
{ handle: "n", xMm: cx, yMm: y },
{ handle: "ne", xMm: x + w, yMm: y },
{ handle: "e", xMm: x + w, yMm: cy },
{ handle: "se", xMm: x + w, yMm: y + h },
{ handle: "s", xMm: cx, yMm: y + h },
{ handle: "sw", xMm: x, yMm: y + h },
{ handle: "w", xMm: x, yMm: cy },
];
}
// ── Annotationen (Linie/Rechteck/Text) ──────────────────────────────────────
// Annotationen tragen KEINE Resize-Griffe (nur verschieben + löschen) — daher
// reicht ein einfacher Treffer-Test (kein Handle-Ergebnis wie bei Viewports).
/** Kürzester Abstand (mm) eines Punkts zu einem Liniensegment. */
export function distanceToSegment(
px: number,
py: number,
x1: number,
y1: number,
x2: number,
y2: number,
): number {
const dx = x2 - x1;
const dy = y2 - y1;
const lenSq = dx * dx + dy * dy;
if (lenSq === 0) return Math.hypot(px - x1, py - y1);
const t = clamp(((px - x1) * dx + (py - y1) * dy) / lenSq, 0, 1);
const cx = x1 + t * dx;
const cy = y1 + t * dy;
return Math.hypot(px - cx, py - cy);
}
/**
* Umhüllendes Rechteck (mm) einer Annotation für Text nur eine GROBE Box
* (kein echtes Font-Measuring im SVG; Breite aus Zeichenzahl geschätzt), reicht
* aber für Trefferlogik + Auswahl-Rahmen. `yMm` einer Text-Annotation ist die
* Baseline, die Box liegt daher oberhalb davon.
*/
export function annotationBoundsMm(ann: LayoutAnnotation): RectMm {
if (ann.kind === "rect") {
return { xMm: ann.xMm, yMm: ann.yMm, widthMm: ann.widthMm, heightMm: ann.heightMm };
}
if (ann.kind === "line") {
return normalizeRect(ann.x1Mm, ann.y1Mm, ann.x2Mm, ann.y2Mm);
}
const widthMm = Math.max(ann.heightMm, ann.text.length * ann.heightMm * 0.6);
return {
xMm: ann.xMm,
yMm: ann.yMm - ann.heightMm,
widthMm,
heightMm: ann.heightMm * 1.25,
};
}
/**
* Trefferlogik (mm) für EINE Annotation: Linie Abstand zum Segment tolMm;
* Rechteck Rahmen ODER Innenfläche (wiederverwendet {@link handleAt}, jeder
* Nicht-null-Rückgabewert zählt als Treffer); Text Bounding-Box + Toleranz.
*/
export function annotationHit(
ann: LayoutAnnotation,
xMm: number,
yMm: number,
tolMm: number,
): boolean {
if (ann.kind === "line") {
return distanceToSegment(xMm, yMm, ann.x1Mm, ann.y1Mm, ann.x2Mm, ann.y2Mm) <= tolMm;
}
if (ann.kind === "rect") {
const r: RectMm = { xMm: ann.xMm, yMm: ann.yMm, widthMm: ann.widthMm, heightMm: ann.heightMm };
return handleAt(r, xMm, yMm, tolMm) !== null;
}
const b = annotationBoundsMm(ann);
return (
xMm >= b.xMm - tolMm &&
xMm <= b.xMm + b.widthMm + tolMm &&
yMm >= b.yMm - tolMm &&
yMm <= b.yMm + b.heightMm + tolMm
);
}
/**
* Topmost-Treffer über eine Annotations-Liste (letzte = oberste, wie
* {@link pickViewport}). Liefert die Id des ERSTEN Treffers von oben, sonst null.
*/
export function pickAnnotation(
annotations: LayoutAnnotation[],
xMm: number,
yMm: number,
tolMm: number,
): string | null {
for (let i = annotations.length - 1; i >= 0; i--) {
const a = annotations[i];
if (annotationHit(a, xMm, yMm, tolMm)) return a.id;
}
return null;
}
/** Verschiebt eine Annotation um ein mm-Delta — alle ihre Punkte wandern mit. */
export function translateAnnotation(
ann: LayoutAnnotation,
dxMm: number,
dyMm: number,
): LayoutAnnotation {
if (ann.kind === "line") {
return {
...ann,
x1Mm: ann.x1Mm + dxMm,
y1Mm: ann.y1Mm + dyMm,
x2Mm: ann.x2Mm + dxMm,
y2Mm: ann.y2Mm + dyMm,
};
}
return { ...ann, xMm: ann.xMm + dxMm, yMm: ann.yMm + dyMm };
}
+28
View File
@@ -36,6 +36,26 @@ export function CommandIcon({ name }: { name: string }) {
<rect x="5.5" y="5.5" width="8" height="8" />
</svg>
);
case "measure":
// Lineal/Massband: Strecke mit Endmarken + kleiner Skala.
return (
<svg {...common}>
<line x1="2.5" y1="12.5" x2="13.5" y2="3.5" />
<line x1="2" y1="11" x2="4" y2="13" />
<line x1="12.5" y1="2.5" x2="14.5" y2="4.5" />
<line x1="6" y1="9" x2="7" y2="10" />
<line x1="9" y1="6" x2="10" y2="7" />
</svg>
);
case "rotate":
// Kreisbogen mit Pfeil (Drehung) um einen Mittelpunkt.
return (
<svg {...common}>
<path d="M12.5 6 A5 5 0 1 0 13 9" />
<polyline points="10.5,5.5 12.5,6 12.9,3.8" />
<circle cx="8" cy="8" r="1" fill="currentColor" stroke="none" />
</svg>
);
case "mirror":
// Spiegelachse (gestrichelt) mit zwei gespiegelten Dreiecken.
return (
@@ -71,6 +91,14 @@ export function CommandIcon({ name }: { name: string }) {
<circle cx="8" cy="13" r="1.4" fill="currentColor" stroke="none" />
</svg>
);
case "column":
// Stütze: Quadrat-Querschnitt mit vertikalen Kanten (Pfeiler).
return (
<svg {...common}>
<rect x="5.5" y="2.5" width="5" height="11" />
<line x1="8" y1="2.5" x2="8" y2="13.5" strokeDasharray="1.6 1.6" />
</svg>
);
default:
return null;
}
+4 -2
View File
@@ -87,9 +87,11 @@ function RibbonButton({ item, ctx }: { item: RibbonItem; ctx: ButtonCtx }) {
</button>
);
}
// Befehls-Element: Label aus der Befehls-Registry ableiten.
// Befehls-Element: Label aus der Befehls-Registry ableiten. Für Aktionen ohne
// eigenes Engine-Kommando (z. B. „rotate", das über die interaktive
// Transformation läuft) auf den i18n-Schlüssel `cmd.<name>.label` zurückfallen.
const cmd = getCommand(item.name);
const label = cmd ? t(cmd.labelKey) : item.name;
const label = cmd ? t(cmd.labelKey) : t(`cmd.${item.name}.label`);
const active = ctx.activeCommand === item.name;
return (
<button
+5
View File
@@ -68,6 +68,8 @@ export const RIBBON_TABS: RibbonTab[] = [
t("rect"),
t("circle"),
t("arc"),
t("text"),
t("textbox"),
],
},
{
@@ -75,10 +77,12 @@ export const RIBBON_TABS: RibbonTab[] = [
items: [
c("move"),
c("copy"),
c("rotate"),
c("mirror"),
c("offset"),
c("trim"),
c("join"),
c("measure"),
],
},
],
@@ -99,6 +103,7 @@ export const RIBBON_TABS: RibbonTab[] = [
t("window"),
t("door"),
t("stair"),
c("column"),
t("ceiling"),
t("room"),
],
+156 -53
View File
@@ -23,6 +23,7 @@ import {
flattenCategories,
getCeilingType,
getComponent,
getStairType,
getWallType,
stairsOfFloor,
wallTypeThickness,
@@ -49,6 +50,7 @@ import type { Pick3dHit } from "./Wasm3DViewport";
import type { RenderMode, View3d } from "../ui/TopBar";
import type { ToolDraft } from "../tools/types";
import { applyAngleConstraint } from "../tools/snapping";
import { drawingGripVertices, drawingMoveAnchor } from "./drawingGrips";
/**
* Render-Entscheidung je Element (Geschoss bzw. Kategorie) im 3D gespiegelt
@@ -146,6 +148,14 @@ export function Viewport3D(
onToggleGrid={onToggleGrid}
onPick3d={onPick3d}
highlightLines={highlightLines}
// 3D-Editier-Griffe (s. Wasm3DViewport.tsx): dieselben Props/Callbacks
// wie die three.js-Sicht (renderer-agnostisch).
editWalls={props.editWalls}
editDrawings={props.editDrawings}
onEditVertex={props.onEditVertex}
onEditBody={props.onEditBody}
onEditWallTop={props.onEditWallTop}
onEditEnd={props.onEditEnd}
/>
);
}
@@ -1642,8 +1652,9 @@ function updateOrthoFrustum(
* Wendet einen kanonischen Blickwinkel-Preset (Vectorworks/DOSSIER) an und
* bestimmt die aktive Kamera:
* perspective PerspectiveCamera (Fluchtpunkt-Projektion, FOV wirkt).
* front/side/top/iso OrthographicCamera (echte parallele Projektion;
* Isometrie" zeigt parallele Kanten parallel, ohne Konvergenz).
* front/back/side/left/top/iso/isoFrontLeft/isoBackRight/isoBackLeft
* OrthographicCamera (echte parallele Projektion; Isometrie" zeigt
* parallele Kanten parallel, ohne Konvergenz).
*
* Für die aktive Kamera: Ziel = Modell-Zentrum, Position = Zentrum + Richtung ×
* Distanz, up=(0,1,0) (für top eine stabile up-Achse über winzigen Z-Anteil).
@@ -1669,21 +1680,40 @@ function applyView3d(
const size = bounds.getSize(new THREE.Vector3());
const radius = Math.max(0.5 * Math.hypot(size.x, size.y, size.z), 0.001);
// Blickrichtung je Preset (vom Zentrum zur Kamera, normalisiert).
// Blickrichtung je Preset (vom Zentrum zur Kamera, normalisiert). Kardinal-
// Paare sind exakte Gegenrichtungen (back = -front, left = -side); die vier
// Iso-Richtungen sind die oberen Oktanten (untere bleiben im Hochbau
// ungenutzt) — dieselbe Achs-Konvention wie `preset_camera` in render3d/
// src/math.rs (WASM-Pfad), s. View3d in TopBar.tsx.
let dir: THREE.Vector3;
switch (view3d) {
case "front":
dir = new THREE.Vector3(0, 0, 1);
break;
case "back":
dir = new THREE.Vector3(0, 0, -1);
break;
case "side":
dir = new THREE.Vector3(1, 0, 0);
break;
case "left":
dir = new THREE.Vector3(-1, 0, 0);
break;
case "top":
dir = new THREE.Vector3(0, 1, 0.0001); // winziger Z-Anteil → stabiles Up
break;
case "iso":
dir = new THREE.Vector3(1, 1, 1).normalize();
break;
case "isoFrontLeft":
dir = new THREE.Vector3(-1, 1, 1).normalize();
break;
case "isoBackRight":
dir = new THREE.Vector3(1, 1, -1).normalize();
break;
case "isoBackLeft":
dir = new THREE.Vector3(-1, 1, -1).normalize();
break;
case "perspective":
default:
// Heutiger 3/4-Blick (vorne-oben-rechts), etwas flacher als die Isometrie.
@@ -1923,48 +1953,8 @@ function drawing2DColor(project: Project, d: Drawing2D): string {
return cat?.color ?? "#888888";
}
/**
* Editierbare Eckpunkte eines 2D-Elements für die 3D-Vertex-Griffe in genau
* derselben Reihenfolge/Indizierung wie `drawingVertices`/`moveGripOf` im
* Store, damit ein gezogener Griff den richtigen Vertex mutiert. line/polyline/
* rect liefern Punkte; andere Formen (circle/arc/text) keine Vertex-Griffe.
*/
function drawingGripVertices(d: Drawing2D): Vec2[] {
const g = d.geom;
if (g.shape === "line") return [g.a, g.b];
if (g.shape === "polyline") return [...g.pts];
if (g.shape === "rect") {
return [
g.min,
{ x: g.max.x, y: g.min.y },
g.max,
{ x: g.min.x, y: g.max.y },
];
}
return [];
}
/**
* Ankerpunkt für den Verschiebe-Griff eines 2D-Elements (Schwerpunkt der
* Vertex-Griffe). Formen ohne Vertex-Griffe (circle/arc/text) liefern ihren
* Geometrie-Ursprung (Mittelpunkt bzw. Textanker), damit auch sie einen
* Verschiebe-Griff bekommen. null, wenn kein sinnvoller Anker existiert.
*/
function drawingMoveAnchor(d: Drawing2D, verts: Vec2[]): Vec2 | null {
if (verts.length > 0) {
let sx = 0;
let sy = 0;
for (const v of verts) {
sx += v.x;
sy += v.y;
}
return { x: sx / verts.length, y: sy / verts.length };
}
const g = d.geom;
if (g.shape === "circle" || g.shape === "arc") return g.center;
if (g.shape === "text") return g.at;
return null;
}
// drawingGripVertices/drawingMoveAnchor sind nach `./drawingGrips` ausgelagert
// (gemeinsam mit Wasm3DViewport.tsx genutzt — s. dortiger Modul-Kommentar).
/** Strecken eines 2D-Zeichenelements (line/polyline/rect); andere → leer. */
function drawing2DSegments(d: Drawing2D): [Vec2, Vec2][] {
@@ -2466,6 +2456,12 @@ function addCeilingMesh(
* einem). Das Zwischenpodest (L/Wendel) wird bis zu seiner Höhe extrudiert. Das
* Material folgt dem Render-Modus; ein neutraler Beton-Grauton dient als Default.
* `userData.stairId` trägt die Auswahl-ID für den Raycast.
*
* Ist ein Treppentyp (`StairType`) aufgelöst und dessen Tragart ungleich
* "massiv", wird die Treppe stattdessen UNTEN OFFEN dargestellt: jeder Tritt
* wird nur als dünne Platte (Dicke `treadThickness`) an seiner Oberkante
* modelliert, optional mit Trittkanten-Überstand (Nase) und/oder geschlossenen
* Setzstufen-Platten je nach `StairType`-Feldern.
*/
function addStairMeshes(
group: THREE.Group,
@@ -2478,6 +2474,7 @@ function addStairMeshes(
const totalRise = zTop - zBottom;
if (totalRise <= 1e-6) return;
const geo = stairGeometry(stair, totalRise);
const st = getStairType(project, stair);
// Material je Render-Modus. Ohne Component-Referenz nutzen wir einen neutralen
// Beton-Grauton (gedimmt heller/transparent).
@@ -2495,16 +2492,19 @@ function addStairMeshes(
opacity: greyed ? 0.35 : 1,
});
const extrudeBlock = (pts: Vec2[], top: number, idKey: string) => {
if (pts.length < 3 || top <= 1e-6) return;
// Extrudiert ein geschlossenes Grundriss-Polygon zu einer Platte zwischen
// `bottom` und `top` (beide relativ zur Treppen-UK `zBottom`). Analog zum
// Vollblock, aber mit frei wählbarer Unterkante statt immer 0.
const extrudeSlab = (pts: Vec2[], bottom: number, top: number) => {
const depth = top - bottom;
if (pts.length < 3 || depth <= 1e-6) return;
const shape = new THREE.Shape();
pts.forEach((v, i) => (i === 0 ? shape.moveTo(v.x, v.y) : shape.lineTo(v.x, v.y)));
shape.closePath();
const g = new THREE.ExtrudeGeometry(shape, { depth: top, bevelEnabled: false });
const g = new THREE.ExtrudeGeometry(shape, { depth, bevelEnabled: false });
const mesh = new THREE.Mesh(g, baseMat);
mesh.userData.stairId = stair.id;
void idKey;
// Plan (x,y) → Three (x, z=y); Extrusion nach Y, daher an die Block-OK setzen.
// Plan (x,y) → Three (x, z=y); Extrusion nach Y, daher an die Platten-OK setzen.
mesh.rotation.x = Math.PI / 2;
mesh.position.y = zBottom + top;
if (opts.renderMode === "hidden") {
@@ -2514,12 +2514,115 @@ function addStairMeshes(
group.add(mesh);
};
for (const tr of geo.treads) extrudeBlock(tr.pts, tr.topRise, `t${tr.index}`);
// Vollblock (Default/"massiv"): von der Treppen-UK bis zur Tritt-Oberkante.
const extrudeBlock = (pts: Vec2[], top: number) => extrudeSlab(pts, 0, top);
const centroidOf = (pts: Vec2[]): Vec2 => {
let x = 0, y = 0;
for (const p of pts) { x += p.x; y += p.y; }
return { x: x / pts.length, y: y / pts.length };
};
// Bestimmt die "vordere" Kante eines Tritt-Vierecks (die Kante in Richtung
// des nächsten Tritts). `target` ist der Schwerpunkt eines Nachbartritts;
// `preferNear` wählt die dem Ziel NÄCHSTE Kante (Nachbar = nächster Tritt),
// sonst die FERNSTE (Nachbar = vorheriger Tritt, Vorderkante liegt entgegengesetzt).
// Rückgabe: Indizes der beiden Eckpunkte der Vorderkante + deren Richtung
// (vom Trittschwerpunkt zur Kantenmitte, normiert).
const frontEdgeOf = (
pts: Vec2[],
target: Vec2,
preferNear: boolean,
): { a: number; b: number; dir: Vec2 } => {
const c = centroidOf(pts);
let best = 0;
let bestDist = preferNear ? Infinity : -Infinity;
for (let e = 0; e < pts.length; e++) {
const p0 = pts[e];
const p1 = pts[(e + 1) % pts.length];
const mid = { x: (p0.x + p1.x) / 2, y: (p0.y + p1.y) / 2 };
const d = Math.hypot(mid.x - target.x, mid.y - target.y);
if (preferNear ? d < bestDist : d > bestDist) {
bestDist = d;
best = e;
}
}
const p0 = pts[best];
const p1 = pts[(best + 1) % pts.length];
const mid = { x: (p0.x + p1.x) / 2, y: (p0.y + p1.y) / 2 };
const dx = mid.x - c.x;
const dy = mid.y - c.y;
const len = Math.hypot(dx, dy) || 1e-9;
return { a: best, b: (best + 1) % pts.length, dir: { x: dx / len, y: dy / len } };
};
if (!st || st.structure === "massiv") {
// Default/rückwärtskompatibel: durchgehend massive Vollblöcke.
for (const tr of geo.treads) extrudeBlock(tr.pts, tr.topRise);
} else {
// Offene Treppe (Wangen-, aufgesattelte oder Spindeltreppe): dünne
// Tritt-Platten statt Vollblöcken, darunter bleibt die Treppe offen.
const treads = geo.treads;
for (let k = 0; k < treads.length; k++) {
const tr = treads[k];
let pts = tr.pts;
// Nase (Nosing): die Vorderkante des Tritts (Richtung nächster Tritt)
// um den Überstand nach vorne verlängern. Die Vorderkante wird über den
// Nachbartritt (Schwerpunkt) bestimmt — für den letzten Tritt (kein
// Nachfolger) wird stattdessen die dem Vorgänger abgewandte Kante genutzt.
const nosing = st.nosing ?? 0;
if (nosing > 1e-6) {
const hasNext = k + 1 < treads.length;
const hasPrev = k > 0;
const ref = hasNext ? treads[k + 1] : hasPrev ? treads[k - 1] : null;
if (ref) {
const { a, b, dir } = frontEdgeOf(pts, centroidOf(ref.pts), hasNext);
const nosed = pts.slice();
nosed[a] = { x: pts[a].x + dir.x * nosing, y: pts[a].y + dir.y * nosing };
nosed[b] = { x: pts[b].x + dir.x * nosing, y: pts[b].y + dir.y * nosing };
pts = nosed;
}
}
// Tritt-Platte: dünne Scheibe, Oberkante an der Setzstufen-Oberkante.
extrudeSlab(pts, tr.topRise - st.treadThickness, tr.topRise);
// Geschlossene Setzstufe: vertikale Platte an der RÜCKKante dieses Tritts
// (= Vorderkante des Vorgängers), von dessen UK (`baseRise`) bis zu
// dieser Oberkante (`topRise`). Offene Treppe (closedRisers=false) lässt
// diese Fläche bewusst weg.
if (st.closedRisers) {
const hasNext = k + 1 < treads.length;
const ref = hasNext ? treads[k + 1] : k > 0 ? treads[k - 1] : null;
if (ref) {
// Rückkante = Gegenkante der Vorderkante desselben Tritts.
const front = frontEdgeOf(tr.pts, centroidOf(ref.pts), hasNext);
const n = tr.pts.length;
const backA = (front.a + Math.floor(n / 2)) % n;
const backB = (front.b + Math.floor(n / 2)) % n;
const riserPts = [tr.pts[backA], tr.pts[backB]];
// Dünne Platte: Rückkante zu einer flachen Wange in Setzrichtung
// aufgeweitet (minimaler Versatz nach vorn), damit ein extrudierbares
// Polygon entsteht.
const thin = Math.min(st.treadThickness, 0.02) || 0.01;
const offA = { x: tr.pts[backA].x - front.dir.x * thin, y: tr.pts[backA].y - front.dir.y * thin };
const offB = { x: tr.pts[backB].x - front.dir.x * thin, y: tr.pts[backB].y - front.dir.y * thin };
const riserPoly = [riserPts[0], riserPts[1], offB, offA];
extrudeSlab(riserPoly, tr.baseRise, tr.topRise);
}
}
// TODO Geländer (railing) noch nicht umgesetzt.
}
}
// Podest (L/Wendel-Auge): bis zur mittleren Höhe extrudieren, damit es sichtbar
// als Absatz erscheint (halber Gesamt-Rise als sinnvoller Default).
// als Absatz erscheint (halber Gesamt-Rise als sinnvoller Default). Bleibt
// unabhängig von der Tragart immer als Vollblock stehen.
if (geo.landing && geo.landing.length >= 3) {
// Höhe des Podests = Höhe des höchsten Tritts unter/an der Podestposition;
// wir nehmen die halbe Gesamt-Steighöhe als robusten Näherungswert.
extrudeBlock(geo.landing, totalRise * 0.5, "landing");
extrudeBlock(geo.landing, totalRise * 0.5);
}
}
+328 -28
View File
@@ -43,21 +43,90 @@
// Pixel an der Viewport-Höhe normiert), damit sich beide Pfade gleich anfühlen.
import { useEffect, useRef, useState } from "react";
import type { Project } from "../model/types";
import { projectToModel3d, pickGeometry } from "../plan/toWalls3d";
import type { Project, Wall, Drawing2D, Vec2 } from "../model/types";
import { wallVerticalExtent } from "../model/wall";
import { projectToModel3d, pickGeometry, TRUCK_MESH_READY_EVENT } from "../plan/toWalls3d";
import type { RenderMode, View3d } from "../ui/TopBar";
import { t } from "../i18n";
import { useWasm3dRenderer } from "./useWasm3dRenderer";
import type { Camera3d } from "./useWasm3dRenderer";
import { cameraRay, pickNearest } from "./raycast3d";
import { cameraRay, pickNearest, rayPlaneY, rayPlane, worldToScreen } from "./raycast3d";
import type { Vec3 } from "./raycast3d";
import { drawingGripVertices, drawingMoveAnchor } from "./drawingGrips";
/** Ein per 3D-Klick getroffenes Bauteil (Wand oder Decke), oder null (Leerraum). */
export type Pick3dHit = { kind: "wall" | "ceiling"; id: string } | null;
export type Pick3dHit =
| { kind: "wall" | "ceiling" | "opening" | "stair"; id: string }
| null;
/** Klick-vs-Drag-Schwelle (Pixel): bleibt die Maus zwischen pointerdown und
* pointerup darunter, gilt die Links-Geste als KLICK (Pick) statt als Orbit-Drag. */
const CLICK_THRESHOLD_PX = 4;
// ── 3D-Editier-Griffe ─────────────────────────────────────────────────────
// Volle Parität mit den three.js-Griffen (`Viewport3D.tsx`/`drawGrips`):
// Wand-Endpunkte + Höhen-Griff (OK der Wand) + Verschiebe-Griff (ganze Wand),
// dieselben drei Griff-Arten für gewählte 2D-Zeichnungen (Vertex + Verschieben).
// Bewusst NICHT nachgebaut: Snap/Koinzidenz-Mitführen beim Ziehen (mehrere
// gewählte Elemente mit gemeinsamer Ecke) — das bleibt dem three.js-Pfad
// vorbehalten, bis sich das dort bewährte Muster als eigener Bedarf zeigt.
//
// DARSTELLUNG: ECHTE DOM-Buttons (kleine Kreise, `position:absolute` über dem
// Canvas), nicht In-Szene-Geometrie. Ein erster Versuch zeichnete die Griffe
// als Drahtgitter-Kugeln über denselben `setHighlightLines`-Kanal wie den
// Auswahl-Umriss — Nutzer-Feedback 2026-07-06: "immernoch kein GUI button
// sondern geometrie" (zurecht: dünne Linien-Kugeln lesen sich nicht als
// klickbarer Punkt, ausserdem teilten sie sich die Farbe mit dem Umriss und
// sassen exakt auf dessen Ecken). Echte DOM-Buttons sind trivial zu treffen
// (native Pointer-Events statt Ray-Toleranz-Mathe) und sehen wie ein
// GUI-Element aus. Kosten: die Bildschirm-Position muss der Kamera folgen —
// `useEffect` unten pollt sie per `requestAnimationFrame`, solange Griffe
// aktiv sind (billige 2D-Projektion, kein WASM/GPU-Aufruf).
type GripKind = "vertex" | "height" | "move";
interface Grip {
kind: GripKind;
/** Element, dessen Griff das ist (Wand-ID ODER Drawing-ID). */
target: { wallId?: string; drawingId?: string };
/** Vertex-Index (nur `kind==="vertex"`); -1 sonst. */
index: number;
pos: Vec3;
}
/**
* Alle Griffe der gewählten Wände + 2D-Zeichnungen: pro Wand zwei Endpunkte
* (UK), ein Höhen-Griff (Achsmitte, OK) und ein Verschiebe-Griff (Achsmitte,
* knapp über UK) Positionen exakt wie `drawGrips` in `Viewport3D.tsx`. Pro
* Zeichnung ein Vertex-Griff je Eckpunkt + ein Verschiebe-Griff im Schwerpunkt,
* auf der Ebene des aktiven Geschosses (`drawingElevation`).
*/
function computeGrips(
project: Project,
walls: readonly Wall[],
drawings: readonly Drawing2D[],
drawingElevation: number,
): Grip[] {
const out: Grip[] = [];
for (const w of walls) {
const { zBottom, zTop } = wallVerticalExtent(project, w);
const target = { wallId: w.id };
out.push({ kind: "vertex", target, index: 0, pos: [w.start.x, zBottom, w.start.y] });
out.push({ kind: "vertex", target, index: 1, pos: [w.end.x, zBottom, w.end.y] });
const midX = (w.start.x + w.end.x) / 2;
const midY = (w.start.y + w.end.y) / 2;
out.push({ kind: "height", target, index: -1, pos: [midX, zTop, midY] });
out.push({ kind: "move", target, index: -1, pos: [midX, zBottom + 0.05, midY] });
}
for (const d of drawings) {
const verts = drawingGripVertices(d);
const target = { drawingId: d.id };
const y = drawingElevation + 0.02;
verts.forEach((v, i) => out.push({ kind: "vertex", target, index: i, pos: [v.x, y, v.y] }));
const anchor = drawingMoveAnchor(d, verts);
if (anchor) out.push({ kind: "move", target, index: -1, pos: [anchor.x, y + 0.03, anchor.y] });
}
return out;
}
/** Vertikaler Öffnungswinkel (Radiant) wie die three.js-PerspectiveCamera (50°),
* auch für die FREIE Orbit-Kamera hier (die Presets selbst nutzen den Rust-
* seitigen Default-FOV, s. `types::default_fov`, denselben Wert). */
@@ -175,9 +244,14 @@ function fitTargetDist(project: Project): {
const ISO_PITCH = Math.asin(1 / Math.sqrt(3));
const VIEW3D_ORBIT: Record<View3d, { yaw: number; pitch: number }> = {
front: { yaw: Math.PI / 2, pitch: 0 },
back: { yaw: -Math.PI / 2, pitch: 0 },
side: { yaw: 0, pitch: 0 },
left: { yaw: Math.PI, pitch: 0 },
top: { yaw: Math.PI / 2, pitch: PITCH_LIMIT },
iso: { yaw: Math.PI / 4, pitch: ISO_PITCH },
isoFrontLeft: { yaw: (3 * Math.PI) / 4, pitch: ISO_PITCH },
isoBackRight: { yaw: -Math.PI / 4, pitch: ISO_PITCH },
isoBackLeft: { yaw: (-3 * Math.PI) / 4, pitch: ISO_PITCH },
perspective: { yaw: Math.PI / 4, pitch: 0.5 },
};
@@ -190,6 +264,12 @@ export function Wasm3DViewport({
onToggleGrid,
onPick3d,
highlightLines = null,
editWalls = [],
editDrawings = [],
onEditVertex,
onEditBody,
onEditWallTop,
onEditEnd,
}: {
project: Project;
/** Kanonischer Blickwinkel (Oberleiste) — s. Datei-Kommentar. */
@@ -220,6 +300,22 @@ export function Wasm3DViewport({
* `null`/leeres Array = kein Highlight (leere Auswahl).
*/
highlightLines?: Float32Array | null;
/** Gewählte Wände — Quelle der 3D-Endpunkt-/Höhen-/Verschiebe-Griffe. */
editWalls?: Wall[];
/** Gewählte 2D-Zeichnungen — Quelle der 3D-Vertex-/Verschiebe-Griffe. */
editDrawings?: Drawing2D[];
/** Endpunkt-Griff gezogen: neuer XY-Modellpunkt für den Vertex `index` des Elements. */
onEditVertex?: (
target: { wallId?: string; drawingId?: string },
index: number,
model: Vec2,
) => Vec2;
/** Verschiebe-Griff gezogen: das Element um `delta` (XY) verschieben. */
onEditBody?: (target: { wallId?: string; drawingId?: string }, delta: Vec2) => void;
/** Höhen-Griff gezogen: neue absolute Oberkanten-Z der Wand. */
onEditWallTop?: (wallId: string, topZ: number) => void;
/** Ende eines Griff-Drags. */
onEditEnd?: () => void;
}) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
// Pick-Callback in einem Ref spiegeln, damit der (einmal registrierte)
@@ -227,6 +323,23 @@ export function Wasm3DViewport({
// die frische Store-Auswahl), ohne den Listener-Effekt neu aufzubauen.
const onPick3dRef = useRef(onPick3d);
onPick3dRef.current = onPick3d;
// Griff-Auswahl/Callbacks ebenfalls als Refs (gleiches Muster) — der Pointer-
// Listener-Effekt unten bleibt stabil ([ready, render]) und liest trotzdem
// stets die aktuelle Auswahl/den aktuellen Callback.
const editWallsRef = useRef(editWalls);
editWallsRef.current = editWalls;
const editDrawingsRef = useRef(editDrawings);
editDrawingsRef.current = editDrawings;
const onEditVertexRef = useRef(onEditVertex);
onEditVertexRef.current = onEditVertex;
const onEditBodyRef = useRef(onEditBody);
onEditBodyRef.current = onEditBody;
const onEditWallTopRef = useRef(onEditWallTop);
onEditWallTopRef.current = onEditWallTop;
const onEditEndRef = useRef(onEditEnd);
onEditEndRef.current = onEditEnd;
const groundElevationRef = useRef(groundElevation);
groundElevationRef.current = groundElevation;
const {
render,
updateModel,
@@ -263,6 +376,20 @@ export function Wasm3DViewport({
redrawRef.current();
}, [ready, project, updateModel]);
// truck-Extrusionen (s. toWalls3d.ts emitExtrudedSolids): die WASM-Extrusion
// läuft asynchron und ist beim ersten `updateModel` oben typischerweise noch
// nicht fertig — ohne diesen Re-Push bliebe sie trotz erfolgreichen Ladens
// unsichtbar, weil `project` sich dafür nicht ändert.
useEffect(() => {
if (!ready) return;
const onMeshReady = () => {
updateModel(projectRef.current);
redrawRef.current();
};
window.addEventListener(TRUCK_MESH_READY_EVENT, onMeshReady);
return () => window.removeEventListener(TRUCK_MESH_READY_EVENT, onMeshReady);
}, [ready, updateModel]);
// Blickwinkel-Praeset anwenden, wenn `view3d` wechselt (oder der Renderer
// bereit wird): springt über `set_view_preset` (Rust) auf die formatfüllende,
// ggf. orthografische Praeset-Kamera — „hinspringen, kein Lock", der
@@ -316,8 +443,19 @@ export function Wasm3DViewport({
// Schnittflaechen-Schraffur (Caps) aus den gecachten Waenden/Decken.
useEffect(() => {
if (!ready) return;
// Bei inaktivem Schnitt braucht es kein Ziel (Ebene ist ohnehin unsichtbar)
// — `fitTargetDist` NICHT aufrufen, das würde sonst bei JEDER Projekt-
// Änderung (auch waehrend eines Griff-Drags) unnoetig die komplette
// Wand-/Oeffnungs-/Decken-Geometrie erneut flatten, nur um eine Bounding-
// Box zu ziehen, die `updateModel` fuer denselben `project`-Stand gerade
// erst berechnet hat.
if (!sectionActive) {
setSectionPlane(false, [0, 0, 0], [0, 0, 1]);
redrawRef.current();
return;
}
const { target } = fitTargetDist(project);
setSectionPlane(sectionActive, target, [0, 0, 1]);
setSectionPlane(true, target, [0, 0, 1]);
redrawRef.current();
}, [ready, sectionActive, project, setSectionPlane]);
@@ -332,6 +470,45 @@ export function Wasm3DViewport({
redrawRef.current();
}, [ready, highlightLines, setHighlightLines]);
// Griff-Bildschirmpositionen: solange Wände/2D-Zeichnungen zum Editieren
// gewählt sind, pollt ein rAF-Loop ihre Griffpunkte durch die aktuelle
// Kamera auf Bildschirm-Pixel — die DOM-Buttons unten im JSX folgen so
// Orbit/Pan/Zoom UND Modelländerungen (Drag), ohne dass Kamerabewegungen
// einen WASM-Push auslösen (reine 2D-Projektionsmathe, kein GPU-Aufruf).
const [gripScreens, setGripScreens] = useState<{ grip: Grip; x: number; y: number }[]>([]);
useEffect(() => {
if (!ready || (editWalls.length === 0 && editDrawings.length === 0)) {
setGripScreens([]);
return;
}
let raf = 0;
const tick = () => {
const canvas = canvasRef.current;
const o = orbitRef.current;
if (canvas && o) {
const rect = canvas.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
const cam = orbitCamera(o);
const grips = computeGrips(
projectRef.current,
editWallsRef.current,
editDrawingsRef.current,
groundElevationRef.current,
);
const next: { grip: Grip; x: number; y: number }[] = [];
for (const g of grips) {
const p = worldToScreen(cam, g.pos, rect.width, rect.height);
if (p) next.push({ grip: g, x: p.x, y: p.y });
}
setGripScreens(next);
}
}
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [ready, editWalls, editDrawings]);
// Canvas-Größe beobachten: bei Layout-Änderung mit aktueller Kamera neu
// zeichnen (die Surface-Anpassung übernimmt der Hook im render()).
useEffect(() => {
@@ -342,6 +519,88 @@ export function Wasm3DViewport({
return () => ro.disconnect();
}, [ready]);
// ── Griff-Drag (DOM-Buttons, s. Datei-Kommentar oben) ─────────────────────
// Der Drag-Zustand lebt in einem Ref (kein State) — die Pointer-Handler an
// den Buttons sind normale React-Props, aber der Ref überlebt zwischen
// Pointer-Move-Events, ohne bei jedem Frame einen Re-Render auszulösen.
// "vertex"/"move" ziehen auf einer horizontalen Arbeitsebene (Wand-UK bzw.
// Geschossebene für Zeichnungen) — exakt wie `workplaneModel` im three.js-
// Pfad; "height" zieht auf einer VERTIKALEN Ebene entlang der Wandachse
// (analog `heightZAt` dort), damit eine überwiegend vertikale Mausbewegung
// auf eine Höhenänderung abbildet.
type GripDrag =
| { kind: "vertex"; target: { wallId?: string; drawingId?: string }; index: number; planeY: number }
| { kind: "move"; target: { wallId?: string; drawingId?: string }; planeY: number; lastModel: Vec2 | null }
| { kind: "height"; wallId: string };
const gripDragRef = useRef<GripDrag | null>(null);
const startGripDrag = (grip: Grip) => (e: React.PointerEvent) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
if (grip.kind === "height") {
if (!grip.target.wallId) return;
gripDragRef.current = { kind: "height", wallId: grip.target.wallId };
} else {
let planeY: number;
if (grip.target.wallId) {
const wall = editWallsRef.current.find((w) => w.id === grip.target.wallId);
if (!wall) return;
planeY = wallVerticalExtent(projectRef.current, wall).zBottom;
} else {
planeY = groundElevationRef.current;
}
gripDragRef.current =
grip.kind === "vertex"
? { kind: "vertex", target: grip.target, index: grip.index, planeY }
: { kind: "move", target: grip.target, planeY, lastModel: null };
}
(e.target as Element).setPointerCapture(e.pointerId);
};
const onGripPointerMove = (e: React.PointerEvent) => {
const drag = gripDragRef.current;
const o = orbitRef.current;
const canvas = canvasRef.current;
if (!drag || !o || !canvas) return;
const rect = canvas.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return;
const ndcX = ((e.clientX - rect.left) / rect.width) * 2 - 1;
const ndcY = 1 - ((e.clientY - rect.top) / rect.height) * 2;
const ray = cameraRay(orbitCamera(o), ndcX, ndcY, rect.width / rect.height);
if (drag.kind === "height") {
const wall = editWallsRef.current.find((w) => w.id === drag.wallId);
if (!wall) return;
const ux = wall.end.x - wall.start.x;
const uy = wall.end.y - wall.start.y;
const len = Math.hypot(ux, uy) || 1;
const midX = (wall.start.x + wall.end.x) / 2;
const midY = (wall.start.y + wall.end.y) / 2;
const hit = rayPlane(ray, [midX, 0, midY], [ux / len, 0, uy / len]);
if (!hit) return;
onEditWallTopRef.current?.(drag.wallId, hit[1]);
return;
}
const hit = rayPlaneY(ray, drag.planeY);
if (!hit) return;
const model: Vec2 = { x: hit[0], y: hit[2] };
if (drag.kind === "vertex") {
onEditVertexRef.current?.(drag.target, drag.index, model);
return;
}
// "move": inkrementelles Delta wie im three.js-Pendant (workplaneModel + lastModel).
if (drag.lastModel) {
const delta: Vec2 = { x: model.x - drag.lastModel.x, y: model.y - drag.lastModel.y };
if (delta.x !== 0 || delta.y !== 0) onEditBodyRef.current?.(drag.target, delta);
}
drag.lastModel = model;
};
const endGripDrag = () => {
if (!gripDragRef.current) return;
gripDragRef.current = null;
onEditEndRef.current?.();
};
// Maus-Navigation (LINKS = Orbit, MITTE/RECHTS = Pan, RAD = Zoom, s.
// Datei-Kommentar MAUS-SCHEMA). Verlässt damit IMMER die Praeset-Kamera und
// wechselt auf die freie perspektivische Orbit-Kamera (Kontinuität über den
@@ -358,40 +617,47 @@ export function Wasm3DViewport({
};
};
let drag:
| {
mode: "orbit" | "pan";
button: number;
x: number;
y: number;
downX: number;
downY: number;
moved: boolean;
}
| null = null;
let drag: {
mode: "orbit" | "pan";
button: number;
x: number;
y: number;
downX: number;
downY: number;
moved: boolean;
} | null = null;
const redraw = () => redrawRef.current();
// Cursor-Pixel (Client) → NDC, relativ zum Canvas-Rechteck. `null`, wenn
// das Canvas gerade keine Ausdehnung hat (z. B. verstecktes Layout).
const toNdc = (clientX: number, clientY: number) => {
const rect = canvas.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return null;
return {
ndcX: ((clientX - rect.left) / rect.width) * 2 - 1,
ndcY: 1 - ((clientY - rect.top) / rect.height) * 2, // Pixel-Y invertiert
aspect: rect.width / rect.height,
};
};
// Cursor-Pixel → Kamerastrahl (aktuelle Orbit-Kamera) → nächstes Bauteil.
// Meldet den Treffer (oder null) an den Aufrufer; `additive` wird vom
// pointerup-Modifier bestimmt. Frische Pick-Geometrie je Klick (selten genug).
const doPick = (clientX: number, clientY: number, additive: boolean) => {
const cb = onPick3dRef.current;
const o = orbitRef.current;
if (!cb || !o) return;
const rect = canvas.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return;
const ndcX = ((clientX - rect.left) / rect.width) * 2 - 1;
const ndcY = 1 - ((clientY - rect.top) / rect.height) * 2; // Pixel-Y invertiert
const aspect = rect.width / rect.height;
const ray = cameraRay(orbitCamera(o), ndcX, ndcY, aspect);
const { walls, slabs } = pickGeometry(projectRef.current);
const hit = pickNearest(ray, walls, slabs);
const ndc = toNdc(clientX, clientY);
if (!cb || !o || !ndc) return;
const ray = cameraRay(orbitCamera(o), ndc.ndcX, ndc.ndcY, ndc.aspect);
const { walls, slabs, openings, stairs } = pickGeometry(projectRef.current);
// Erweiterter Pick inkl. Öffnungen (Fenster/Türen) + Treppen — sonst wären
// im WASM-Viewport nur Wände/Decken anwählbar.
const hit = pickNearest(ray, walls, slabs, openings, stairs);
cb(hit ? { kind: hit.kind, id: hit.id } : null, additive);
};
const onPointerDown = (e: PointerEvent) => {
// LINKS (0) = Orbit, MITTE (1)/RECHTS (2) = Pan (s. MAUS-SCHEMA oben).
const mode = e.button === 0 ? "orbit" : e.button === 1 || e.button === 2 ? "pan" : null;
if (!mode) return;
e.preventDefault();
@@ -565,14 +831,48 @@ export function Wasm3DViewport({
Darstellungsart kommen von der Oberleiste (TopBar); das Grid-Overlay
unten ist der einzige eigene Umschalter hier. */}
<canvas ref={canvasRef} data-engine="render3d" />
{/* 3D-Editier-Griffe: echte DOM-Buttons statt In-Szene-Geometrie, s.
Datei-Kommentar oben. Position folgt der Kamera via `gripScreens`
(rAF-Loop). Farben wie die three.js-Griffe (`Viewport3D.tsx`,
gripVertMat/gripHeightMat/gripMoveMat): Vertex orange, Höhe blau,
Verschieben grün. */}
{gripScreens.map(({ grip, x, y }) => (
<div
key={`${grip.kind}:${grip.target.wallId ?? grip.target.drawingId}:${grip.index}`}
onPointerDown={startGripDrag(grip)}
onPointerMove={onGripPointerMove}
onPointerUp={endGripDrag}
onPointerCancel={endGripDrag}
title={t(
grip.kind === "height"
? "viewport3d.grip.height"
: grip.kind === "move"
? "viewport3d.grip.move"
: "viewport3d.grip.vertex",
)}
style={{
position: "absolute",
left: x - 8,
top: y - 8,
width: 16,
height: 16,
borderRadius: "50%",
background: grip.kind === "height" ? "#2f8fff" : grip.kind === "move" ? "#18b85a" : "#ff8c1a",
border: "2px solid rgba(0,0,0,0.6)",
boxShadow: "0 0 5px rgba(0,0,0,0.55)",
cursor: grip.kind === "height" ? "ns-resize" : "grab",
touchAction: "none",
}}
/>
))}
{/* Live-Schnitt-Umschalter (eigener Overlay-Button, MVP): vertikale
Schnittebene durch die Modellmitte, Blick +Y. Sitzt ueber dem
Grid-Button (falls vorhanden). Reine Ansichts-Option des Viewports. */}
<button
type="button"
aria-label="Schnittebene"
aria-label={t("viewport3d.section.label")}
onClick={() => setSectionActive((v) => !v)}
title={sectionActive ? "Schnittebene aus" : "Schnittebene ein"}
title={t(sectionActive ? "viewport3d.section.hide" : "viewport3d.section.show")}
aria-pressed={sectionActive}
style={{
position: "absolute",
+50
View File
@@ -0,0 +1,50 @@
// Griff-Geometrie eines 2D-Elements — gemeinsam genutzt von BEIDEN 3D-Sichten
// (ThreeViewport3D UND Wasm3DViewport), deshalb ein eigenes, neutrales Modul
// statt einer Definition in einer der beiden Viewport-Dateien (die sich sonst
// gegenseitig importieren müssten — Viewport3D.tsx rendert Wasm3DViewport,
// ein Re-Import in die Gegenrichtung wäre ein zyklischer Import).
import type { Drawing2D, Vec2 } from "../model/types";
/**
* Editierbare Eckpunkte eines 2D-Elements für die 3D-Vertex-Griffe in genau
* derselben Reihenfolge/Indizierung wie `drawingVertices`/`moveGripOf` im
* Store, damit ein gezogener Griff den richtigen Vertex mutiert. line/polyline/
* rect liefern Punkte; andere Formen (circle/arc/text) keine Vertex-Griffe.
*/
export function drawingGripVertices(d: Drawing2D): Vec2[] {
const g = d.geom;
if (g.shape === "line") return [g.a, g.b];
if (g.shape === "polyline") return [...g.pts];
if (g.shape === "rect") {
return [
g.min,
{ x: g.max.x, y: g.min.y },
g.max,
{ x: g.min.x, y: g.max.y },
];
}
return [];
}
/**
* Ankerpunkt für den Verschiebe-Griff eines 2D-Elements (Schwerpunkt der
* Vertex-Griffe). Formen ohne Vertex-Griffe (circle/arc/text) liefern ihren
* Geometrie-Ursprung (Mittelpunkt bzw. Textanker), damit auch sie einen
* Verschiebe-Griff bekommen. null, wenn kein sinnvoller Anker existiert.
*/
export function drawingMoveAnchor(d: Drawing2D, verts: Vec2[]): Vec2 | null {
if (verts.length > 0) {
let sx = 0;
let sy = 0;
for (const v of verts) {
sx += v.x;
sy += v.y;
}
return { x: sx / verts.length, y: sy / verts.length };
}
const g = d.geom;
if (g.shape === "circle" || g.shape === "arc") return g.center;
if (g.shape === "text") return g.at;
return null;
}
+93
View File
@@ -0,0 +1,93 @@
/**
* Dekodiert die Material-Farbkarten eines Projekts für den nativen render3d-
* Renderer (Stil Textured"). Der native Renderer bekommt KEINE Bild-Dateien
* er kennt weder Datei-Pfade noch einen PNG/JPG-Decoder. Deshalb dekodiert diese
* Datei die eindeutigen Material-Farb-Maps IM BROWSER (Image/ImageBitmap
* Canvas `getImageData`) zu rohen RGBA8-Pixeln und packt sie zu EINEM Puffer
* (je Textur eine 256×256-Ebene), den {@link WebModelRenderer.set_material_textures}
* als Textur-Array hochlädt.
*
* REIHENFOLGE-VERTRAG: Die Ebenen liegen exakt in der Reihenfolge von
* {@link wallMaterialColorMaps} dieselbe Funktion, aus der `toWalls3d` den
* `materialIndex` je Wand-Band ableitet (1-basiert; Ebene 0 = Fallback-
* Schachbrett). So zeigt Band-Index i+1 auf die (i)-te dekodierte Ebene, ohne
* dass eine explizite Zuordnungstabelle zwischen JS und Rust wandern muss.
*/
import type { Project } from "../model/types";
import { wallMaterialColorMaps } from "../plan/toWalls3d";
/** Kantenlänge einer Textur-Ebene in Texeln (muss zu render3d `TEXTURE_SIZE`). */
const TEX_SIZE = 256;
/** Ergebnis von {@link decodeMaterialTextures}: gepackte RGBA8-Ebenen + Anzahl. */
export interface DecodedMaterialTextures {
/** `count * TEX_SIZE * TEX_SIZE * 4` Bytes: je Material eine 256×256-RGBA-Ebene. */
data: Uint8Array;
/** Anzahl Material-Ebenen (== `wallMaterialColorMaps(project).length`). */
count: number;
/** Signatur (die URL-Liste), um unveränderte Material-Mengen nicht neu zu dekodieren. */
signature: string;
}
/**
* Dekodiert die eindeutigen Material-Farbkarten von `project` zu gepackten
* RGBA8-Ebenen. `null`, wenn das Projekt keine Material-Farbkarten benutzt oder
* kein Browser-Kontext vorliegt (SSR/Test) der Aufrufer lädt dann nichts hoch
* (render3d bleibt beim Schachbrett). Fehlgeschlagene Einzelbilder bleiben als
* schwarze Ebene erhalten (kein Abbruch der ganzen Menge).
*/
export async function decodeMaterialTextures(
project: Project,
): Promise<DecodedMaterialTextures | null> {
if (typeof document === "undefined") return null;
const urls = wallMaterialColorMaps(project);
if (urls.length === 0) return null;
const canvas = document.createElement("canvas");
canvas.width = TEX_SIZE;
canvas.height = TEX_SIZE;
const ctx = canvas.getContext("2d", { willReadFrequently: true });
if (!ctx) return null;
const bytesPerLayer = TEX_SIZE * TEX_SIZE * 4;
const data = new Uint8Array(urls.length * bytesPerLayer);
for (let i = 0; i < urls.length; i++) {
try {
const img = await loadImage(urls[i]);
ctx.clearRect(0, 0, TEX_SIZE, TEX_SIZE);
// Auf 256×256 skalieren (die Ebenen eines Arrays müssen gleich groß sein).
ctx.drawImage(img, 0, 0, TEX_SIZE, TEX_SIZE);
const pixels = ctx.getImageData(0, 0, TEX_SIZE, TEX_SIZE);
data.set(pixels.data, i * bytesPerLayer);
} catch {
// Fehlgeschlagenes Bild: Ebene bleibt schwarz (0) — render3d klemmt bei
// Bedarf ohnehin auf gültige Ebenen; die übrigen Materialien stimmen.
}
}
return { data, count: urls.length, signature: urls.join("") };
}
/**
* Lädt eine Bild-URL (Data-URL, Object-URL oder Asset-Pfad) zu einem
* zeichenbaren Bild. Nutzt `createImageBitmap` (schnell, entkoppelt vom
* DOM-Layout), fällt auf ein `HTMLImageElement` zurück, wo es fehlt.
*/
async function loadImage(url: string): Promise<CanvasImageSource> {
if (typeof createImageBitmap === "function" && typeof fetch === "function") {
try {
const res = await fetch(url);
const blob = await res.blob();
return await createImageBitmap(blob);
} catch {
// Fallback unten (z. B. wenn fetch die URL nicht mag).
}
}
return await new Promise<HTMLImageElement>((resolve, reject) => {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => resolve(img);
img.onerror = () => reject(new Error(`Bild laden fehlgeschlagen: ${url}`));
img.src = url;
});
}

Some files were not shown because too many files have changed in this diff Show More