2D-Plan-Renderer auf WebGL2 (GPU) + akkumulierter Funktionsstand
Neuer GPU-Renderer fuer den Grundriss (src/plan/glPlan/): Earcut-Tessellierung (konkav-faehig), gehrte Linienzuege (Miter), echte Papier-mm-Strichbreiten im Massstab (repliziert den SVG-printStrokeVb-Pfad), Hybrid mit scharfem SVG-Text- Overlay. GPU ist der Standardpfad; der SVG-Renderer bleibt automatischer Fallback, falls WebGL2/Shader nicht verfuegbar sind. Imperativer Pan (rAF + CSS-transform) fuer fluessige Interaktion ohne React-Re-Render je Frame. Enthaelt zudem den bisher nicht committeten Arbeitsstand des Browser-BIM (Oeffnungen, Treppen, Raeume, Decken, DXF-Export, Materialbibliothek, Kontext- Import, Tauri-Compute-Boundary-PoC).
This commit is contained in:
+1461
-109
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,250 @@
|
||||
// Ceiling — Decke als echter Engine-Befehl (analog polyline: ein GESCHLOSSENER
|
||||
// Umriss), committet aber ein einzelnes `Ceiling`-Bauteil statt einer 2D-Form.
|
||||
// Schritte:
|
||||
// 1) „Erster Umrisspunkt der Decke:" → Punkt
|
||||
// 2) „Nächster Punkt ( Schliessen Zurück ):" → Punkt … Der Umriss schließt per
|
||||
// Klick auf den Startpunkt, Option „Schliessen" (C) oder Enter (≥3 Punkte).
|
||||
//
|
||||
// Akzeptiert je Punkt Maus-Picks UND getippte Koordinaten (0,0 · r3,0 · 5<45) —
|
||||
// derselbe Eingabepfad wie Polylinie/Wand. Die fertige Decke rendert über die
|
||||
// bestehende Decken-Darstellung (generatePlan / 3D); der Draft zeigt den Umriss.
|
||||
//
|
||||
// Herkunft der Decken-Felder (CommandContext):
|
||||
// • wallTypeId ← ctx.activeWallTypeId (Fallback: erster Projekt-Wandtyp) — der
|
||||
// Schichtaufbau (Bauteil/Schraffur/Material) wird identisch zur Wand aufgelöst.
|
||||
// • floorId ← ctx.level.id (aktives Geschoss)
|
||||
// • categoryCode← "30" (Ebene „Decken"; Fallback ctx.defaultCategoryCode)
|
||||
|
||||
import type { Ceiling } from "../../model/types";
|
||||
import { normalizeOutline, ceilingArea } from "../../geometry/ceiling";
|
||||
import { uniqueId } from "../../tools/types";
|
||||
import type {
|
||||
Command,
|
||||
CommandContext,
|
||||
CommandField,
|
||||
CommandResult,
|
||||
CommandState,
|
||||
CmdOption,
|
||||
DraftShape,
|
||||
Project,
|
||||
ToolDraft,
|
||||
Vec2,
|
||||
} from "../types";
|
||||
|
||||
const EPS = 1e-6;
|
||||
const DEG = Math.PI / 180;
|
||||
const CLOSE_HIT = 0.08; // Modell-Meter: Klick nahe Startpunkt schließt den Umriss
|
||||
/** Default-Ebene (Kategorie) für Decken. */
|
||||
const CEILING_CATEGORY = "30";
|
||||
|
||||
const segLen = (a: Vec2, b: Vec2): number => Math.hypot(b.x - a.x, b.y - a.y);
|
||||
const segAngleDeg = (a: Vec2, b: Vec2): number =>
|
||||
(Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI;
|
||||
|
||||
/** Tab-Felder je weiteren Punkt: Länge + Winkel relativ zum letzten Punkt. */
|
||||
const CEIL_FIELDS: CommandField[] = [
|
||||
{ id: "length", labelKey: "cmd.field.length" },
|
||||
{ id: "angle", labelKey: "cmd.field.angle" },
|
||||
];
|
||||
|
||||
/** Nächster Punkt aus gelockten Feldern (length/angle) + Cursor, relativ zu `last`. */
|
||||
function nextFromFields(
|
||||
last: Vec2,
|
||||
locks: Record<string, number>,
|
||||
cursor: Vec2 | null,
|
||||
): Vec2 {
|
||||
const ref = cursor ?? last;
|
||||
const length = "length" in locks ? locks.length : segLen(last, ref);
|
||||
const angleDeg = "angle" in locks ? locks.angle : segAngleDeg(last, ref);
|
||||
const ang = angleDeg * DEG;
|
||||
return { x: last.x + Math.cos(ang) * length, y: last.y + Math.sin(ang) * length };
|
||||
}
|
||||
|
||||
/** Aktiver Aufbau-Typ-ID; Fallback auf den ersten Projekt-Wandtyp. */
|
||||
function activeTypeId(ctx: CommandContext): string {
|
||||
const wt =
|
||||
ctx.project.wallTypes.find((t) => t.id === ctx.activeWallTypeId) ??
|
||||
ctx.project.wallTypes[0];
|
||||
return wt ? wt.id : ctx.activeWallTypeId;
|
||||
}
|
||||
|
||||
/** Existiert die Decken-Kategorie („30")? Sonst Fallback auf die aktive Kategorie. */
|
||||
function ceilingCategory(ctx: CommandContext): string {
|
||||
const flat: { code: string }[] = [];
|
||||
const walk = (list: { code: string; children?: unknown[] }[]) => {
|
||||
for (const c of list) {
|
||||
flat.push({ code: c.code });
|
||||
if (c.children) walk(c.children as { code: string; children?: unknown[] }[]);
|
||||
}
|
||||
};
|
||||
walk(ctx.project.layers as { code: string; children?: unknown[] }[]);
|
||||
return flat.some((c) => c.code === CEILING_CATEGORY)
|
||||
? CEILING_CATEGORY
|
||||
: ctx.defaultCategoryCode;
|
||||
}
|
||||
|
||||
interface CeilIdle extends CommandState {
|
||||
phase: "start";
|
||||
}
|
||||
interface CeilDrawing extends CommandState {
|
||||
phase: "next";
|
||||
points: Vec2[];
|
||||
cursor: Vec2 | null;
|
||||
}
|
||||
type CeilState = CeilIdle | CeilDrawing;
|
||||
|
||||
const CLOSE: CmdOption = { id: "close", labelKey: "cmd.polyline.close" };
|
||||
const UNDO: CmdOption = { id: "undo", labelKey: "cmd.polyline.undo" };
|
||||
|
||||
/** Vorschau: geschlossener Umriss-Ring (ab 3 Punkten) + Gummiband + HUD. */
|
||||
function ceilingDraft(points: Vec2[], cursor: Vec2 | null): ToolDraft {
|
||||
const pts = cursor ? [...points, cursor] : points;
|
||||
const showClosed = pts.length >= 3;
|
||||
const preview: DraftShape[] = [{ kind: "poly", pts, closed: showClosed }];
|
||||
const draft: ToolDraft = { preview, vertices: points };
|
||||
const last = points[points.length - 1];
|
||||
if (cursor && last && segLen(last, cursor) >= EPS) {
|
||||
draft.hud = {
|
||||
at: cursor,
|
||||
text: `${segLen(last, cursor).toFixed(2)} m · ${Math.abs(
|
||||
(segAngleDeg(last, cursor) + 360) % 360,
|
||||
).toFixed(0)}°`,
|
||||
};
|
||||
}
|
||||
return draft;
|
||||
}
|
||||
|
||||
/** Hängt eine Decke ans Projekt (immutabel). Entartete Umrisse werden verworfen. */
|
||||
function appendCeiling(p: Project, pts: Vec2[], ctx: CommandContext): Project {
|
||||
const outline = normalizeOutline(pts);
|
||||
if (!outline || ceilingArea(outline) < 1e-4) return p;
|
||||
const ceiling: Ceiling = {
|
||||
id: uniqueId("C"),
|
||||
type: "ceiling",
|
||||
floorId: ctx.level.id,
|
||||
categoryCode: ceilingCategory(ctx),
|
||||
outline,
|
||||
wallTypeId: activeTypeId(ctx),
|
||||
};
|
||||
const ceilings = p.ceilings ? [...p.ceilings, ceiling] : [ceiling];
|
||||
return { ...p, ceilings };
|
||||
}
|
||||
|
||||
const idle = (): [CommandState, CommandResult] => [
|
||||
{ phase: "start", lastPoint: null } as CeilIdle,
|
||||
{ draft: null, done: true },
|
||||
];
|
||||
|
||||
export const ceilingCommand: Command = {
|
||||
name: "ceiling",
|
||||
labelKey: "cmd.ceiling.label",
|
||||
// Decken leben auf Geschossen — wie die Wand nur dort aktiv.
|
||||
floorOnly: true,
|
||||
prompt: (s) =>
|
||||
(s as CeilState).phase === "next" ? "cmd.ceiling.next" : "cmd.ceiling.start",
|
||||
accepts: (s) =>
|
||||
(s as CeilState).phase === "next"
|
||||
? ["point", "number", "option"]
|
||||
: ["point", "number"],
|
||||
options: (s) => {
|
||||
const ps = s as CeilState;
|
||||
if (ps.phase !== "next") return [];
|
||||
return ps.points.length >= 3 ? [CLOSE, UNDO] : [UNDO];
|
||||
},
|
||||
init: (): CeilIdle => ({ phase: "start", lastPoint: null }),
|
||||
|
||||
onInput: (state, input, ctx): [CommandState, CommandResult] => {
|
||||
const s = state as CeilState;
|
||||
|
||||
if (input.kind === "option") {
|
||||
if (s.phase !== "next") return [s, { draft: null }];
|
||||
if (input.id === "undo") {
|
||||
const pts = s.points.slice(0, -1);
|
||||
if (pts.length === 0)
|
||||
return [{ phase: "start", lastPoint: null } as CeilIdle, { draft: null }];
|
||||
const ns: CeilDrawing = {
|
||||
phase: "next",
|
||||
points: pts,
|
||||
cursor: s.cursor,
|
||||
lastPoint: pts[pts.length - 1],
|
||||
};
|
||||
return [ns, { draft: ceilingDraft(pts, s.cursor) }];
|
||||
}
|
||||
// Sofort schließen: committet die Decke (unabhängig, ≥3 Punkte).
|
||||
if (input.id === "close" && s.points.length >= 3) {
|
||||
const pts = s.points;
|
||||
return [
|
||||
{ phase: "start", lastPoint: null } as CeilIdle,
|
||||
{ draft: null, done: true, commit: (p) => appendCeiling(p, pts, ctx) },
|
||||
];
|
||||
}
|
||||
return [s, { draft: ceilingDraft(s.points, s.cursor) }];
|
||||
}
|
||||
|
||||
if (input.kind !== "point") {
|
||||
return [
|
||||
s,
|
||||
{ draft: s.phase === "next" ? ceilingDraft(s.points, s.cursor) : null },
|
||||
];
|
||||
}
|
||||
const pt = input.point;
|
||||
if (s.phase !== "next") {
|
||||
const ns: CeilDrawing = { phase: "next", points: [pt], cursor: pt, lastPoint: pt };
|
||||
return [ns, { draft: ceilingDraft([pt], pt) }];
|
||||
}
|
||||
// Klick nahe Startpunkt → schließen (committet die Decke).
|
||||
if (s.points.length >= 3 && segLen(s.points[0], pt) < CLOSE_HIT) {
|
||||
const pts = s.points;
|
||||
return [
|
||||
{ phase: "start", lastPoint: null } as CeilIdle,
|
||||
{ draft: null, done: true, commit: (p) => appendCeiling(p, pts, ctx) },
|
||||
];
|
||||
}
|
||||
// Null-Strecke (Klick auf denselben Punkt) ignorieren.
|
||||
const last = s.points[s.points.length - 1];
|
||||
if (segLen(last, pt) < EPS) {
|
||||
return [s, { draft: ceilingDraft(s.points, s.cursor) }];
|
||||
}
|
||||
const points = [...s.points, pt];
|
||||
const ns: CeilDrawing = { phase: "next", points, cursor: pt, lastPoint: pt };
|
||||
return [ns, { draft: ceilingDraft(points, pt) }];
|
||||
},
|
||||
|
||||
onMove: (state, point): [CommandState, CommandResult] => {
|
||||
const s = state as CeilState;
|
||||
if (s.phase !== "next") return [s, { draft: null }];
|
||||
const ns: CeilDrawing = { ...s, cursor: point };
|
||||
return [ns, { draft: ceilingDraft(s.points, point) }];
|
||||
},
|
||||
|
||||
// Enter/Space/Rechtsklick: Umriss schließen + committen (≥3 Punkte).
|
||||
onConfirm: (state, ctx): [CommandState, CommandResult] => {
|
||||
const s = state as CeilState;
|
||||
if (s.phase === "next" && s.points.length >= 3) {
|
||||
const pts = s.points;
|
||||
return [
|
||||
{ phase: "start", lastPoint: null } as CeilIdle,
|
||||
{ draft: null, done: true, commit: (p) => appendCeiling(p, pts, ctx) },
|
||||
];
|
||||
}
|
||||
return idle();
|
||||
},
|
||||
onCancel: (): [CommandState, CommandResult] => idle(),
|
||||
|
||||
// Tab-Feld-Zyklus nur im „next"-Schritt: Länge + Winkel relativ zum letzten Punkt.
|
||||
fields: (state) => ((state as CeilState).phase === "next" ? CEIL_FIELDS : []),
|
||||
pointFromFields: (state, locks, cursor) => {
|
||||
const s = state as CeilState;
|
||||
if (s.phase !== "next" || s.points.length === 0) return null;
|
||||
return nextFromFields(s.points[s.points.length - 1], locks, cursor);
|
||||
},
|
||||
fieldValues: (state, _locks, cursor): Record<string, number> => {
|
||||
const s = state as CeilState;
|
||||
if (s.phase !== "next" || s.points.length === 0 || !cursor) return {};
|
||||
const last = s.points[s.points.length - 1];
|
||||
return {
|
||||
length: segLen(last, cursor),
|
||||
angle: ((segAngleDeg(last, cursor) % 360) + 360) % 360,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -133,6 +133,11 @@ export const circleCommand: Command = {
|
||||
// Tab-Feld-Zyklus nur im „radius"-Schritt: ein Radius-Feld; der gelockte Wert
|
||||
// committet direkt (Punkt liegt auf dem Kreis, dist(center,point)=radius).
|
||||
fields: (state) => ((state as CircleState).phase === "radius" ? CIRCLE_FIELDS : []),
|
||||
fieldValues: (state, _locks, cursor): Record<string, number> => {
|
||||
const s = state as CircleState;
|
||||
if (s.phase !== "radius" || !cursor) return {};
|
||||
return { radius: dist(s.center, cursor) };
|
||||
},
|
||||
pointFromFields: (state, locks, cursor) => {
|
||||
const s = state as CircleState;
|
||||
if (s.phase !== "radius") return null;
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// Join — verbindet die gewählten 2D-Zeichenelemente an koinzidenten Endpunkten zu
|
||||
// Polylinien (geschlossen, wenn sich eine Kette schließt). Sofort-Befehl (kein
|
||||
// Punkt-Picken): läuft direkt auf der bestehenden Mehrfachauswahl. So ist „Ctrl+J"
|
||||
// und getipptes „join" EIN Pfad (§Task C).
|
||||
//
|
||||
// Wände werden nicht verbunden (2D-Editier-Operation); nicht-polyline Formen
|
||||
// (circle/arc/text) reicht `joinDrawings` unverändert durch.
|
||||
|
||||
import { joinDrawings } from "../../editors/splitJoin";
|
||||
import type { Drawing2D } from "../../model/types";
|
||||
import type {
|
||||
Command,
|
||||
CommandContext,
|
||||
CommandResult,
|
||||
CommandSelection,
|
||||
CommandState,
|
||||
Project,
|
||||
} from "../types";
|
||||
|
||||
/** Alle gewählten 2D-Element-IDs (Mehrfachauswahl mit Fallback aufs Einzelelement). */
|
||||
function selectedDrawingIds(sel: CommandSelection): string[] {
|
||||
if (sel.drawingIds && sel.drawingIds.length) return sel.drawingIds;
|
||||
return sel.drawingId ? [sel.drawingId] : [];
|
||||
}
|
||||
|
||||
/** Ersetzt die gewählten Quell-Drawings durch das Join-Ergebnis (immutabel). */
|
||||
function applyJoin(project: Project, ids: string[]): Project {
|
||||
const idSet = new Set(ids);
|
||||
const srcs = project.drawings2d.filter((d) => idSet.has(d.id));
|
||||
if (srcs.length < 2) return project; // <2 → nichts zu verbinden
|
||||
const joined: Drawing2D[] = joinDrawings(srcs);
|
||||
const rest = project.drawings2d.filter((d) => !idSet.has(d.id));
|
||||
return { ...project, drawings2d: [...rest, ...joined] };
|
||||
}
|
||||
|
||||
interface JoinState extends CommandState {
|
||||
phase: "done";
|
||||
}
|
||||
|
||||
const done = (): [CommandState, CommandResult] => [
|
||||
{ phase: "done", lastPoint: null } as JoinState,
|
||||
{ draft: null, done: true },
|
||||
];
|
||||
|
||||
export const joinCommand: Command = {
|
||||
name: "join",
|
||||
labelKey: "edit.join.label",
|
||||
prompt: () => "cmd.join.prompt",
|
||||
accepts: () => [],
|
||||
options: () => [],
|
||||
init: (): JoinState => ({ phase: "done", lastPoint: null }),
|
||||
|
||||
// Sofort auf der Auswahl ausführen (kein Pick nötig).
|
||||
autoRun: (ctx: CommandContext): CommandResult => {
|
||||
const ids = selectedDrawingIds(ctx.selection);
|
||||
if (ids.length < 2) return { draft: null, done: true };
|
||||
return { draft: null, done: true, commit: (p) => applyJoin(p, ids) };
|
||||
},
|
||||
|
||||
onInput: (): [CommandState, CommandResult] => done(),
|
||||
onMove: (state): [CommandState, CommandResult] => [state, { draft: null }],
|
||||
onConfirm: (): [CommandState, CommandResult] => done(),
|
||||
onCancel: (): [CommandState, CommandResult] => done(),
|
||||
};
|
||||
@@ -137,4 +137,12 @@ export const lineCommand: Command = {
|
||||
if (s.phase !== "end") return null;
|
||||
return lineEndFromFields(s.a, locks, cursor);
|
||||
},
|
||||
fieldValues: (state, _locks, cursor): Record<string, number> => {
|
||||
const s = state as LineState;
|
||||
if (s.phase !== "end" || !cursor) return {};
|
||||
return {
|
||||
length: segLen(s.a, cursor),
|
||||
angle: ((segAngleDeg(s.a, cursor) % 360) + 360) % 360,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
// Mirror — spiegelt die AKTUELLE Auswahl (Wände + ein Drawing2D) an einer frei
|
||||
// gewählten Achse (Rhino/Vectorworks-Spiegeln). Schritte:
|
||||
// 1) „Erster Achsenpunkt:" → Punkt
|
||||
// 2) „Zweiter Achsenpunkt:" → Punkt (Live-Vorschau der Spiegelung) → commit
|
||||
//
|
||||
// Die Auswahl kommt vorab über ctx.selection (erst selektieren, dann Befehl,
|
||||
// wie Move/Copy). Ist sie leer, ist nichts zu spiegeln: kurzer No-op mit Hinweis
|
||||
// („Nichts gewählt") und Beenden.
|
||||
//
|
||||
// Der Commit nutzt den bestehenden, getesteten `commitTransform` (op:"mirror",
|
||||
// mode:"copy") — er spiegelt Wände UND alle 2D-Formen immutabel an der Achse und
|
||||
// hängt das Spiegelbild als KOPIE an (das Original bleibt stehen). Das ist das
|
||||
// klassische Mirror „Kopie spiegeln" (Rhino/Vectorworks-Default).
|
||||
|
||||
import { commitTransform } from "../../tools/transform";
|
||||
import type { TransformSelection } from "../../tools/transform";
|
||||
import { transformPreview } from "../../tools/transform";
|
||||
import type {
|
||||
Command,
|
||||
CommandResult,
|
||||
CommandSelection,
|
||||
CommandState,
|
||||
Project,
|
||||
ToolDraft,
|
||||
Vec2,
|
||||
} from "../types";
|
||||
|
||||
const segLen = (a: Vec2, b: Vec2): number => Math.hypot(b.x - a.x, b.y - a.y);
|
||||
const segAngleDeg = (a: Vec2, b: Vec2): number =>
|
||||
(Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI;
|
||||
const EPS = 1e-6;
|
||||
|
||||
/** Hat die Auswahl überhaupt etwas Spiegelbares? */
|
||||
function hasSelection(sel: CommandSelection): boolean {
|
||||
return sel.wallIds.length > 0 || sel.drawingId !== null;
|
||||
}
|
||||
|
||||
/** Auswahl → TransformSelection (gleiche Form). */
|
||||
function toTransformSel(sel: CommandSelection): TransformSelection {
|
||||
return { wallIds: sel.wallIds, drawingId: sel.drawingId };
|
||||
}
|
||||
|
||||
// Zustand: erst ersten Achsenpunkt setzen, dann den zweiten (mit Auswahl-Snapshot).
|
||||
interface MirrorFirst extends CommandState {
|
||||
phase: "first";
|
||||
}
|
||||
interface MirrorSecond extends CommandState {
|
||||
phase: "second";
|
||||
sel: CommandSelection;
|
||||
a: Vec2;
|
||||
cursor: Vec2 | null;
|
||||
}
|
||||
// „done": leere Auswahl → No-op, Befehl beendet.
|
||||
interface MirrorDone extends CommandState {
|
||||
phase: "done";
|
||||
}
|
||||
type MirrorState = MirrorFirst | MirrorSecond | MirrorDone;
|
||||
|
||||
/** Live-Vorschau der an der Achse a→b gespiegelten Auswahl. */
|
||||
function mirrorDraft(
|
||||
project: Project,
|
||||
sel: CommandSelection,
|
||||
a: Vec2,
|
||||
b: Vec2,
|
||||
): ToolDraft {
|
||||
// Ohne echte Achse (Null-Strecke) nur den ersten Punkt markieren.
|
||||
if (segLen(a, b) < EPS) {
|
||||
return { preview: [], vertices: [a] };
|
||||
}
|
||||
const preview = transformPreview(
|
||||
project,
|
||||
toTransformSel(sel),
|
||||
"mirror",
|
||||
[a, b],
|
||||
"copy", // Spiegelbild als Kopie (Original bleibt stehen)
|
||||
1,
|
||||
);
|
||||
return {
|
||||
// Spiegelbild-Vorschau + die Achse selbst als Hilfslinie.
|
||||
preview: [...preview, { kind: "line", a, b }],
|
||||
// Beide Achsenpunkte als Stützpunkte (Marker).
|
||||
vertices: [a, b],
|
||||
hud: {
|
||||
at: b,
|
||||
text: `${((segAngleDeg(a, b) + 360) % 360).toFixed(0)}°`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const idle = (): [CommandState, CommandResult] => [
|
||||
{ phase: "done", lastPoint: null } as MirrorDone,
|
||||
{ draft: null, done: true },
|
||||
];
|
||||
|
||||
export const mirrorCommand: Command = {
|
||||
name: "mirror",
|
||||
labelKey: "cmd.mirror.label",
|
||||
prompt: (s) => {
|
||||
const ms = s as MirrorState;
|
||||
if (ms.phase === "second") return "cmd.mirror.second";
|
||||
if (ms.phase === "done") return "cmd.mirror.empty";
|
||||
return "cmd.mirror.first";
|
||||
},
|
||||
accepts: () => ["point"],
|
||||
options: () => [],
|
||||
|
||||
init: (): MirrorFirst => ({ phase: "first", lastPoint: null }),
|
||||
|
||||
onInput: (state, input, ctx): [CommandState, CommandResult] => {
|
||||
const s = state as MirrorState;
|
||||
if (s.phase === "done") return idle();
|
||||
// Leere Auswahl → nichts zu spiegeln (No-op + Hinweis, dann beenden).
|
||||
if (!hasSelection(ctx.selection)) {
|
||||
return [{ phase: "done", lastPoint: null } as MirrorDone, { draft: null, done: true }];
|
||||
}
|
||||
if (input.kind !== "point") {
|
||||
if (s.phase === "second") {
|
||||
return [s, { draft: s.cursor ? mirrorDraft(ctx.project, s.sel, s.a, s.cursor) : null }];
|
||||
}
|
||||
return [s, { draft: null }];
|
||||
}
|
||||
const pt = input.point;
|
||||
if (s.phase !== "second") {
|
||||
// Erster Achsenpunkt gesetzt → Auswahl-Snapshot mitführen.
|
||||
const next: MirrorSecond = {
|
||||
phase: "second",
|
||||
sel: ctx.selection,
|
||||
a: pt,
|
||||
cursor: pt,
|
||||
lastPoint: pt,
|
||||
};
|
||||
return [next, { draft: mirrorDraft(ctx.project, next.sel, pt, pt) }];
|
||||
}
|
||||
// Zweiter Achsenpunkt → commit (Auswahl an der Achse spiegeln). Null-Achse
|
||||
// verwerfen (kein Spiegeln ohne Richtung).
|
||||
if (segLen(s.a, pt) < EPS) {
|
||||
return [{ phase: "done", lastPoint: null } as MirrorDone, { draft: null, done: true }];
|
||||
}
|
||||
const a = s.a;
|
||||
const sel = s.sel;
|
||||
return [
|
||||
{ phase: "done", lastPoint: null } as MirrorDone,
|
||||
{
|
||||
draft: null,
|
||||
done: true,
|
||||
commit: (p) => commitTransform(p, toTransformSel(sel), "mirror", [a, pt], "copy", 1),
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
onMove: (state, point, _snap, ctx): [CommandState, CommandResult] => {
|
||||
const s = state as MirrorState;
|
||||
if (s.phase !== "second") return [s, { draft: null }];
|
||||
const ns: MirrorSecond = { ...s, cursor: point };
|
||||
return [ns, { draft: mirrorDraft(ctx.project, s.sel, s.a, point) }];
|
||||
},
|
||||
|
||||
onConfirm: (): [CommandState, CommandResult] => idle(),
|
||||
onCancel: (): [CommandState, CommandResult] => idle(),
|
||||
};
|
||||
@@ -0,0 +1,368 @@
|
||||
// Opening — Öffnung (Fenster/Tür) als Engine-Befehl. Sie wird in eine BESTEHENDE
|
||||
// Wand gehostet. Ablauf:
|
||||
// 1) „Wand für die Öffnung wählen:" → Klick auf/nahe eine Wand (Host).
|
||||
// 2) „Position entlang der Wand:" → Klick auf der Wandachse ODER getippter
|
||||
// Abstand vom Wand-Startpunkt (Zahl). Die Position wird auf die Achse
|
||||
// projiziert; der HUD zeigt den Abstand vom Wandanfang.
|
||||
// 3) Commit: hängt eine `Opening` ans Projekt.
|
||||
//
|
||||
// Tab-Felder im Positionsschritt: Breite / Höhe / Brüstung (sillHeight) /
|
||||
// Schwenkwinkel (nur Tür). Optionen: Fenster⇄Tür-Umschalter, Anschlag
|
||||
// (Scharnier start/end), Aufschlagseite (links/rechts), Richtung (innen/außen).
|
||||
//
|
||||
// Bezeichner englisch, sichtbarer Text via t() (CONVENTIONS.md).
|
||||
|
||||
import type { Opening } from "../../model/types";
|
||||
import { uniqueId } from "../../tools/types";
|
||||
import type {
|
||||
Command,
|
||||
CommandContext,
|
||||
CommandField,
|
||||
CommandResult,
|
||||
CommandState,
|
||||
CmdOption,
|
||||
DraftShape,
|
||||
Project,
|
||||
ToolDraft,
|
||||
Vec2,
|
||||
} from "../types";
|
||||
|
||||
/** Default-Ebene (Kategorie) für Öffnungen: „21" Türen/Fenster (Fallback: aktive). */
|
||||
const OPENING_CATEGORY = "21";
|
||||
/** Toleranz (Modell-Meter) für das Picken einer Host-Wand per Klick. */
|
||||
const WALL_PICK_DIST = 0.6;
|
||||
/** Default-Maße (Meter). */
|
||||
const DEF_WINDOW = { width: 1.2, height: 1.2, sill: 0.9 };
|
||||
const DEF_DOOR = { width: 0.9, height: 2.1, sill: 0 };
|
||||
|
||||
const sub = (a: Vec2, b: Vec2): Vec2 => ({ x: a.x - b.x, y: a.y - b.y });
|
||||
const dot = (a: Vec2, b: Vec2): number => a.x * b.x + a.y * b.y;
|
||||
const add = (a: Vec2, b: Vec2): Vec2 => ({ x: a.x + b.x, y: a.y + b.y });
|
||||
const scale = (a: Vec2, s: number): Vec2 => ({ x: a.x * s, y: a.y * s });
|
||||
|
||||
/** Existiert die Öffnungs-Kategorie („21")? Sonst Fallback auf die aktive. */
|
||||
function openingCategory(ctx: CommandContext): string {
|
||||
const flat: { code: string }[] = [];
|
||||
const walk = (list: { code: string; children?: unknown[] }[]) => {
|
||||
for (const c of list) {
|
||||
flat.push({ code: c.code });
|
||||
if (c.children) walk(c.children as { code: string; children?: unknown[] }[]);
|
||||
}
|
||||
};
|
||||
walk(ctx.project.layers as { code: string; children?: unknown[] }[]);
|
||||
return flat.some((c) => c.code === OPENING_CATEGORY)
|
||||
? OPENING_CATEGORY
|
||||
: ctx.defaultCategoryCode;
|
||||
}
|
||||
|
||||
/** Auf die Wandachse projizierter Fußpunkt + Parameter t (0..len) + Distanz. */
|
||||
function projectOntoWall(
|
||||
wall: { start: Vec2; end: Vec2 },
|
||||
p: Vec2,
|
||||
): { t: number; foot: Vec2; dist: number } {
|
||||
const ax = sub(wall.end, wall.start);
|
||||
const len2 = dot(ax, ax) || 1e-9;
|
||||
const raw = dot(sub(p, wall.start), ax) / len2; // 0..1
|
||||
const t = Math.max(0, Math.min(1, raw));
|
||||
const foot = add(wall.start, scale(ax, t));
|
||||
const dist = Math.hypot(p.x - foot.x, p.y - foot.y);
|
||||
return { t: t * Math.sqrt(len2), foot, dist };
|
||||
}
|
||||
|
||||
/** Nächste Wand auf dem aktiven Geschoss zum Klickpunkt (innerhalb Toleranz). */
|
||||
function pickWall(
|
||||
ctx: CommandContext,
|
||||
p: Vec2,
|
||||
): { id: string; start: Vec2; end: Vec2 } | null {
|
||||
let best: { id: string; start: Vec2; end: Vec2; d: number } | null = null;
|
||||
for (const w of ctx.project.walls) {
|
||||
if (w.floorId !== ctx.level.id) continue;
|
||||
const { dist } = projectOntoWall(w, p);
|
||||
if (dist <= WALL_PICK_DIST && (!best || dist < best.d)) {
|
||||
best = { id: w.id, start: w.start, end: w.end, d: dist };
|
||||
}
|
||||
}
|
||||
return best ? { id: best.id, start: best.start, end: best.end } : null;
|
||||
}
|
||||
|
||||
/** Tab-Felder im Positionsschritt: Breite/Höhe/Brüstung (+ Winkel bei Tür). */
|
||||
const BASE_FIELDS: CommandField[] = [
|
||||
{ id: "width", labelKey: "cmd.field.width" },
|
||||
{ id: "height", labelKey: "cmd.field.height" },
|
||||
{ id: "sill", labelKey: "cmd.opening.sillField" },
|
||||
];
|
||||
const SWING_FIELD: CommandField = { id: "swing", labelKey: "cmd.opening.swingField" };
|
||||
|
||||
const KIND: CmdOption = { id: "kind", labelKey: "cmd.opening.kind", value: "window" };
|
||||
const HINGE: CmdOption = { id: "hinge", labelKey: "cmd.opening.hinge", value: "start" };
|
||||
const SIDE: CmdOption = { id: "side", labelKey: "cmd.opening.side", value: "left" };
|
||||
const DIR: CmdOption = { id: "dir", labelKey: "cmd.opening.dir", value: "in" };
|
||||
|
||||
interface OpIdle extends CommandState {
|
||||
phase: "wall";
|
||||
kind: "window" | "door";
|
||||
hinge: "start" | "end";
|
||||
side: "left" | "right";
|
||||
dir: "in" | "out";
|
||||
width: number;
|
||||
height: number;
|
||||
sill: number;
|
||||
swingAngle: number;
|
||||
}
|
||||
interface OpPos extends CommandState {
|
||||
phase: "pos";
|
||||
wallId: string;
|
||||
start: Vec2;
|
||||
end: Vec2;
|
||||
cursor: Vec2 | null;
|
||||
posAlong: number; // Abstand vom Wandanfang (Meter)
|
||||
kind: "window" | "door";
|
||||
hinge: "start" | "end";
|
||||
side: "left" | "right";
|
||||
dir: "in" | "out";
|
||||
width: number;
|
||||
height: number;
|
||||
sill: number;
|
||||
swingAngle: number;
|
||||
}
|
||||
type OpState = OpIdle | OpPos;
|
||||
|
||||
function initState(kind: "window" | "door" = "window"): OpIdle {
|
||||
const def = kind === "door" ? DEF_DOOR : DEF_WINDOW;
|
||||
return {
|
||||
phase: "wall",
|
||||
lastPoint: null,
|
||||
kind,
|
||||
hinge: "start",
|
||||
side: "left",
|
||||
dir: "in",
|
||||
width: def.width,
|
||||
height: def.height,
|
||||
sill: def.sill,
|
||||
swingAngle: 90,
|
||||
};
|
||||
}
|
||||
|
||||
/** Vorschau: Lücken-Balken entlang der Wand + Positionsmarke. */
|
||||
function posDraft(s: OpPos): ToolDraft {
|
||||
const ax = sub(s.end, s.start);
|
||||
const len = Math.hypot(ax.x, ax.y) || 1e-9;
|
||||
const u = { x: ax.x / len, y: ax.y / len };
|
||||
const n = { x: -u.y, y: u.x };
|
||||
const from = Math.max(0, Math.min(s.posAlong, len));
|
||||
const to = Math.max(from, Math.min(from + s.width, len));
|
||||
const a = add(s.start, scale(u, from));
|
||||
const b = add(s.start, scale(u, to));
|
||||
const half = 0.15; // Vorschau-Balken quer zur Wand
|
||||
const quad: Vec2[] = [
|
||||
add(a, scale(n, half)),
|
||||
add(b, scale(n, half)),
|
||||
add(b, scale(n, -half)),
|
||||
add(a, scale(n, -half)),
|
||||
];
|
||||
const preview: DraftShape[] = [{ kind: "poly", pts: quad, closed: true }];
|
||||
// Symbol-Andeutung: Fenster = Mittellinie, Tür = Blatt-Linie in Schwenkrichtung.
|
||||
if (s.kind === "window") {
|
||||
preview.push({ kind: "line", a, b });
|
||||
} else {
|
||||
const swingSign = s.side === "left" ? 1 : -1;
|
||||
const dirSign = s.dir === "in" ? 1 : -1;
|
||||
const hinge = s.hinge === "start" ? a : b;
|
||||
const leafEnd = add(hinge, scale(n, swingSign * dirSign * s.width));
|
||||
preview.push({ kind: "line", a: hinge, b: leafEnd });
|
||||
}
|
||||
const draft: ToolDraft = { preview, vertices: [a, b] };
|
||||
const mid = add(a, scale(u, (to - from) / 2));
|
||||
draft.hud = {
|
||||
at: s.cursor ?? mid,
|
||||
text: `${from.toFixed(2)} m · ${s.kind === "door" ? "Tür" : "Fenster"} ${(
|
||||
s.width * 100
|
||||
).toFixed(0)}×${(s.height * 100).toFixed(0)}`,
|
||||
};
|
||||
return draft;
|
||||
}
|
||||
|
||||
/** Hängt eine Öffnung ans Projekt (immutabel). */
|
||||
function appendOpening(p: Project, s: OpPos, ctx: CommandContext): Project {
|
||||
const len = Math.hypot(s.end.x - s.start.x, s.end.y - s.start.y);
|
||||
const width = Math.max(0.1, Math.min(s.width, len));
|
||||
const position = Math.max(0, Math.min(s.posAlong, Math.max(0, len - width)));
|
||||
const opening: Opening = {
|
||||
id: uniqueId("O"),
|
||||
type: "opening",
|
||||
hostWallId: s.wallId,
|
||||
categoryCode: openingCategory(ctx),
|
||||
kind: s.kind,
|
||||
position,
|
||||
width,
|
||||
height: s.height,
|
||||
sillHeight: s.kind === "door" ? 0 : s.sill,
|
||||
...(s.kind === "door"
|
||||
? { hinge: s.hinge, swing: s.side, openingDir: s.dir, swingAngle: s.swingAngle }
|
||||
: {}),
|
||||
};
|
||||
const openings = p.openings ? [...p.openings, opening] : [opening];
|
||||
return { ...p, openings };
|
||||
}
|
||||
|
||||
const idle = (from?: OpState): [CommandState, CommandResult] => [
|
||||
initState(from?.kind ?? "window"),
|
||||
{ draft: null, done: true },
|
||||
];
|
||||
|
||||
export const openingCommand: Command = {
|
||||
name: "opening",
|
||||
labelKey: "cmd.opening.label",
|
||||
floorOnly: true,
|
||||
prompt: (s) =>
|
||||
(s as OpState).phase === "pos" ? "cmd.opening.pos" : "cmd.opening.wall",
|
||||
accepts: (s) =>
|
||||
(s as OpState).phase === "pos"
|
||||
? ["point", "number", "option"]
|
||||
: ["point", "option"],
|
||||
options: (s): CmdOption[] => {
|
||||
const st = s as OpState;
|
||||
const kind: CmdOption = { ...KIND, value: st.kind };
|
||||
if (st.kind === "door") {
|
||||
return [
|
||||
kind,
|
||||
{ ...HINGE, value: st.hinge },
|
||||
{ ...SIDE, value: st.side },
|
||||
{ ...DIR, value: st.dir },
|
||||
];
|
||||
}
|
||||
return [kind];
|
||||
},
|
||||
init: (): OpIdle => initState("window"),
|
||||
|
||||
onInput: (state, input, ctx): [CommandState, CommandResult] => {
|
||||
const s = state as OpState;
|
||||
|
||||
if (input.kind === "option") {
|
||||
// Umschalter zyklen ihren Wert. Bei kind-Wechsel Default-Maße übernehmen.
|
||||
const next = { ...s } as OpState;
|
||||
if (input.id === "kind") {
|
||||
next.kind = s.kind === "window" ? "door" : "window";
|
||||
const def = next.kind === "door" ? DEF_DOOR : DEF_WINDOW;
|
||||
next.width = def.width;
|
||||
next.height = def.height;
|
||||
next.sill = def.sill;
|
||||
} else if (input.id === "hinge") {
|
||||
next.hinge = s.hinge === "start" ? "end" : "start";
|
||||
} else if (input.id === "side") {
|
||||
next.side = s.side === "left" ? "right" : "left";
|
||||
} else if (input.id === "dir") {
|
||||
next.dir = s.dir === "in" ? "out" : "in";
|
||||
}
|
||||
return [next, { draft: next.phase === "pos" ? posDraft(next as OpPos) : null }];
|
||||
}
|
||||
|
||||
if (input.kind === "number") {
|
||||
// Im Positionsschritt: getippte Zahl = Abstand vom Wandanfang (Meter).
|
||||
if (s.phase === "pos") {
|
||||
const ns: OpPos = { ...s, posAlong: input.value };
|
||||
return [ns, { draft: posDraft(ns) }];
|
||||
}
|
||||
return [s, { draft: null }];
|
||||
}
|
||||
|
||||
if (input.kind !== "point") {
|
||||
return [s, { draft: s.phase === "pos" ? posDraft(s as OpPos) : null }];
|
||||
}
|
||||
|
||||
const pt = input.point;
|
||||
if (s.phase === "wall") {
|
||||
const w = pickWall(ctx, pt);
|
||||
if (!w) return [s, { draft: null }]; // kein Treffer: Schritt wiederholen
|
||||
const proj = projectOntoWall(w, pt);
|
||||
const ns: OpPos = {
|
||||
phase: "pos",
|
||||
lastPoint: pt,
|
||||
wallId: w.id,
|
||||
start: w.start,
|
||||
end: w.end,
|
||||
cursor: pt,
|
||||
posAlong: proj.t,
|
||||
kind: s.kind,
|
||||
hinge: s.hinge,
|
||||
side: s.side,
|
||||
dir: s.dir,
|
||||
width: s.width,
|
||||
height: s.height,
|
||||
sill: s.sill,
|
||||
swingAngle: s.swingAngle,
|
||||
};
|
||||
return [ns, { draft: posDraft(ns) }];
|
||||
}
|
||||
|
||||
// Positionsschritt: Klick projiziert auf die Achse → committen.
|
||||
const proj = projectOntoWall(s, pt);
|
||||
const ns: OpPos = { ...s, posAlong: proj.t, cursor: pt };
|
||||
return [
|
||||
idle(s)[0],
|
||||
{ draft: null, done: true, commit: (p) => appendOpening(p, ns, ctx) },
|
||||
];
|
||||
},
|
||||
|
||||
onMove: (state, point): [CommandState, CommandResult] => {
|
||||
const s = state as OpState;
|
||||
if (s.phase !== "pos") return [s, { draft: null }];
|
||||
const proj = projectOntoWall(s, point);
|
||||
const ns: OpPos = { ...s, posAlong: proj.t, cursor: point };
|
||||
return [ns, { draft: posDraft(ns) }];
|
||||
},
|
||||
|
||||
onConfirm: (state, ctx): [CommandState, CommandResult] => {
|
||||
const s = state as OpState;
|
||||
if (s.phase === "pos") {
|
||||
const ns = s;
|
||||
return [
|
||||
idle(s)[0],
|
||||
{ draft: null, done: true, commit: (p) => appendOpening(p, ns, ctx) },
|
||||
];
|
||||
}
|
||||
return idle(s);
|
||||
},
|
||||
onCancel: (state): [CommandState, CommandResult] => idle(state as OpState),
|
||||
|
||||
fields: (state) => {
|
||||
const s = state as OpState;
|
||||
if (s.phase !== "pos") return [];
|
||||
return s.kind === "door" ? [...BASE_FIELDS, SWING_FIELD] : BASE_FIELDS;
|
||||
},
|
||||
// Getippte Tab-Felder sticky in den State übernehmen; kein Koordinatenpunkt.
|
||||
pointFromFields: (state, locks) => {
|
||||
const s = state as OpState;
|
||||
if ("width" in locks && locks.width > 0) s.width = locks.width;
|
||||
if ("height" in locks && locks.height > 0) s.height = locks.height;
|
||||
if ("sill" in locks && locks.sill >= 0) s.sill = locks.sill;
|
||||
if ("swing" in locks && locks.swing > 0) s.swingAngle = locks.swing;
|
||||
return null;
|
||||
},
|
||||
fieldValues: (state): Record<string, number> => {
|
||||
const s = state as OpState;
|
||||
const out: Record<string, number> = {
|
||||
width: s.width,
|
||||
height: s.height,
|
||||
sill: s.sill,
|
||||
};
|
||||
if (s.kind === "door") out.swing = s.swingAngle;
|
||||
return out;
|
||||
},
|
||||
};
|
||||
|
||||
/** „Fenster"-Variante: identischer Befehl, startet aber im Fenster-Modus. */
|
||||
export const fensterCommand: Command = {
|
||||
...openingCommand,
|
||||
name: "fenster",
|
||||
labelKey: "cmd.opening.windowLabel",
|
||||
init: (): OpIdle => initState("window"),
|
||||
};
|
||||
|
||||
/** „Tür"-Variante: identischer Befehl, startet aber im Tür-Modus. */
|
||||
export const tuerCommand: Command = {
|
||||
...openingCommand,
|
||||
name: "tuer",
|
||||
labelKey: "cmd.opening.doorLabel",
|
||||
init: (): OpIdle => initState("door"),
|
||||
};
|
||||
@@ -44,21 +44,34 @@ function polyNextFromFields(last: Vec2, locks: Record<string, number>, cursor: V
|
||||
|
||||
interface PolyIdle extends CommandState {
|
||||
phase: "start";
|
||||
/** Gewählter Schließ-Modus (bleibt über den Befehl hinweg erhalten). */
|
||||
closed: boolean;
|
||||
}
|
||||
interface PolyDrawing extends CommandState {
|
||||
phase: "next";
|
||||
points: Vec2[];
|
||||
cursor: Vec2 | null;
|
||||
/** Soll der committete Zug als Ring (geschlossen) gelten? Default: offen. */
|
||||
closed: boolean;
|
||||
}
|
||||
type PolyState = PolyIdle | PolyDrawing;
|
||||
|
||||
/** Sofort-schließen-Aktion (nur ≥3 Punkte): committet als Ring und beendet. */
|
||||
const CLOSE: CmdOption = { id: "close", labelKey: "cmd.polyline.close" };
|
||||
const UNDO: CmdOption = { id: "undo", labelKey: "cmd.polyline.undo" };
|
||||
/** Toggle-Option: Ergebnis geschlossen (Ring) oder offen — im Feld-Row sichtbar. */
|
||||
const closedOption = (closed: boolean): CmdOption => ({
|
||||
id: "mode",
|
||||
labelKey: "cmd.polyline.closedOpt",
|
||||
value: closed ? "on" : "off",
|
||||
});
|
||||
|
||||
/** Vorschau (Zug + Gummiband zum Cursor) + HUD (Länge·Winkel des letzten Segments). */
|
||||
function polyDraft(points: Vec2[], cursor: Vec2 | null): ToolDraft {
|
||||
function polyDraft(points: Vec2[], cursor: Vec2 | null, closed = false): ToolDraft {
|
||||
const pts = cursor ? [...points, cursor] : points;
|
||||
const preview: DraftShape[] = [{ kind: "poly", pts, closed: false }];
|
||||
// Ring nur ab 3 Punkten zeigen; darunter bleibt die Vorschau offen.
|
||||
const showClosed = closed && pts.length >= 3;
|
||||
const preview: DraftShape[] = [{ kind: "poly", pts, closed: showClosed }];
|
||||
const draft: ToolDraft = { preview, vertices: points };
|
||||
const last = points[points.length - 1];
|
||||
if (cursor && last && segLen(last, cursor) >= EPS) {
|
||||
@@ -89,8 +102,9 @@ function appendPolyline(
|
||||
return { ...p, drawings2d: [...p.drawings2d, d] };
|
||||
}
|
||||
|
||||
const idle = (): [CommandState, CommandResult] => [
|
||||
{ phase: "start", lastPoint: null },
|
||||
/** Ruhezustand; behält den zuletzt gewählten Schließ-Modus bei. */
|
||||
const idle = (closed = false): [CommandState, CommandResult] => [
|
||||
{ phase: "start", lastPoint: null, closed } as PolyIdle,
|
||||
{ draft: null, done: true },
|
||||
];
|
||||
|
||||
@@ -98,77 +112,95 @@ export const polylineCommand: Command = {
|
||||
name: "polyline",
|
||||
labelKey: "cmd.polyline.label",
|
||||
prompt: (s) => ((s as PolyState).phase === "next" ? "cmd.polyline.next" : "cmd.polyline.start"),
|
||||
accepts: (s) =>
|
||||
(s as PolyState).phase === "next" ? ["point", "number", "option"] : ["point", "number"],
|
||||
// Auch im Startschritt darf der Schließ-Modus gewählt werden.
|
||||
accepts: () => ["point", "number", "option"],
|
||||
options: (s) => {
|
||||
const ps = s as PolyState;
|
||||
if (ps.phase !== "next") return [];
|
||||
return ps.points.length >= 2 ? [CLOSE, UNDO] : [UNDO];
|
||||
const toggle = closedOption(ps.closed);
|
||||
if (ps.phase !== "next") return [toggle];
|
||||
return ps.points.length >= 2 ? [CLOSE, toggle, UNDO] : [toggle, UNDO];
|
||||
},
|
||||
init: (): PolyIdle => ({ phase: "start", lastPoint: null }),
|
||||
init: (): PolyIdle => ({ phase: "start", lastPoint: null, closed: false }),
|
||||
|
||||
onInput: (state, input, ctx): [CommandState, CommandResult] => {
|
||||
const s = state as PolyState;
|
||||
|
||||
if (input.kind === "option") {
|
||||
// Schließ-Modus umschalten (Tastenkürzel „C" bzw. Klick im Feld-Row);
|
||||
// in jedem Schritt verfügbar, ohne den laufenden Zug zu beenden.
|
||||
if (input.id === "mode") {
|
||||
const ns = { ...s, closed: !s.closed } as PolyState;
|
||||
return [ns, { draft: s.phase === "next" ? polyDraft(s.points, s.cursor, ns.closed) : null }];
|
||||
}
|
||||
if (s.phase !== "next") return [s, { draft: null }];
|
||||
if (input.id === "undo") {
|
||||
const pts = s.points.slice(0, -1);
|
||||
if (pts.length === 0) return [{ phase: "start", lastPoint: null }, { draft: null }];
|
||||
const ns: PolyDrawing = { phase: "next", points: pts, cursor: s.cursor, lastPoint: pts[pts.length - 1] };
|
||||
return [ns, { draft: polyDraft(pts, s.cursor) }];
|
||||
if (pts.length === 0)
|
||||
return [{ phase: "start", lastPoint: null, closed: s.closed } as PolyIdle, { draft: null }];
|
||||
const ns: PolyDrawing = {
|
||||
phase: "next",
|
||||
points: pts,
|
||||
cursor: s.cursor,
|
||||
lastPoint: pts[pts.length - 1],
|
||||
closed: s.closed,
|
||||
};
|
||||
return [ns, { draft: polyDraft(pts, s.cursor, s.closed) }];
|
||||
}
|
||||
// Sofort schließen: committet als Ring (unabhängig vom Toggle-Zustand).
|
||||
if (input.id === "close" && s.points.length >= 3) {
|
||||
const pts = s.points;
|
||||
return [
|
||||
{ phase: "start", lastPoint: null },
|
||||
{ phase: "start", lastPoint: null, closed: s.closed } as PolyIdle,
|
||||
{ draft: null, done: true, commit: (p) => appendPolyline(p, pts, true, ctx) },
|
||||
];
|
||||
}
|
||||
return [s, { draft: polyDraft(s.points, s.cursor) }];
|
||||
return [s, { draft: polyDraft(s.points, s.cursor, s.closed) }];
|
||||
}
|
||||
|
||||
if (input.kind !== "point") {
|
||||
return [s, { draft: s.phase === "next" ? polyDraft(s.points, s.cursor) : null }];
|
||||
return [s, { draft: s.phase === "next" ? polyDraft(s.points, s.cursor, s.closed) : null }];
|
||||
}
|
||||
const pt = input.point;
|
||||
if (s.phase !== "next") {
|
||||
const ns: PolyDrawing = { phase: "next", points: [pt], cursor: pt, lastPoint: pt };
|
||||
return [ns, { draft: polyDraft([pt], pt) }];
|
||||
const ns: PolyDrawing = { phase: "next", points: [pt], cursor: pt, lastPoint: pt, closed: s.closed };
|
||||
return [ns, { draft: polyDraft([pt], pt, s.closed) }];
|
||||
}
|
||||
// Klick nahe Startpunkt → schließen.
|
||||
// Klick nahe Startpunkt → schließen (committet als Ring).
|
||||
if (s.points.length >= 3 && segLen(s.points[0], pt) < CLOSE_HIT) {
|
||||
const pts = s.points;
|
||||
return [
|
||||
{ phase: "start", lastPoint: null },
|
||||
{ phase: "start", lastPoint: null, closed: s.closed } as PolyIdle,
|
||||
{ draft: null, done: true, commit: (p) => appendPolyline(p, pts, true, ctx) },
|
||||
];
|
||||
}
|
||||
const points = [...s.points, pt];
|
||||
const ns: PolyDrawing = { phase: "next", points, cursor: pt, lastPoint: pt };
|
||||
return [ns, { draft: polyDraft(points, pt) }];
|
||||
const ns: PolyDrawing = { phase: "next", points, cursor: pt, lastPoint: pt, closed: s.closed };
|
||||
return [ns, { draft: polyDraft(points, pt, s.closed) }];
|
||||
},
|
||||
|
||||
onMove: (state, point): [CommandState, CommandResult] => {
|
||||
const s = state as PolyState;
|
||||
if (s.phase !== "next") return [s, { draft: null }];
|
||||
const ns: PolyDrawing = { ...s, cursor: point };
|
||||
return [ns, { draft: polyDraft(s.points, point) }];
|
||||
return [ns, { draft: polyDraft(s.points, point, s.closed) }];
|
||||
},
|
||||
|
||||
// Enter/Space/Rechtsklick: offenen Zug beenden (≥2 Punkte) und committen.
|
||||
// Enter/Space/Rechtsklick: Zug beenden (≥2 Punkte). Der Schließ-Modus des
|
||||
// Toggles bestimmt, ob als Ring oder offen committet wird. Ein Ring braucht
|
||||
// ≥3 Punkte, sonst fällt er auf „offen" zurück.
|
||||
onConfirm: (state, ctx): [CommandState, CommandResult] => {
|
||||
const s = state as PolyState;
|
||||
if (s.phase === "next" && s.points.length >= 2) {
|
||||
const pts = s.points;
|
||||
const close = s.closed && pts.length >= 3;
|
||||
return [
|
||||
{ phase: "start", lastPoint: null },
|
||||
{ draft: null, done: true, commit: (p) => appendPolyline(p, pts, false, ctx) },
|
||||
{ phase: "start", lastPoint: null, closed: s.closed } as PolyIdle,
|
||||
{ draft: null, done: true, commit: (p) => appendPolyline(p, pts, close, ctx) },
|
||||
];
|
||||
}
|
||||
return idle();
|
||||
return idle(s.closed);
|
||||
},
|
||||
onCancel: (): [CommandState, CommandResult] => idle(),
|
||||
onCancel: (state): [CommandState, CommandResult] => idle((state as PolyState).closed),
|
||||
|
||||
// Tab-Feld-Zyklus nur im „next"-Schritt: Länge + Winkel relativ zum letzten
|
||||
// Punkt. (Erster Punkt = freie Koordinate, kein Feld-Modus.)
|
||||
@@ -178,4 +210,13 @@ export const polylineCommand: Command = {
|
||||
if (s.phase !== "next" || s.points.length === 0) return null;
|
||||
return polyNextFromFields(s.points[s.points.length - 1], locks, cursor);
|
||||
},
|
||||
fieldValues: (state, _locks, cursor): Record<string, number> => {
|
||||
const s = state as PolyState;
|
||||
if (s.phase !== "next" || s.points.length === 0 || !cursor) return {};
|
||||
const last = s.points[s.points.length - 1];
|
||||
return {
|
||||
length: segLen(last, cursor),
|
||||
angle: ((segAngleDeg(last, cursor) % 360) + 360) % 360,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
+332
-51
@@ -1,9 +1,20 @@
|
||||
// Rectangle — zwei Ecken (portiert rectTool). Schritte:
|
||||
// 1) „Erste Ecke:" → Punkt
|
||||
// 2) „Gegenüberliegende Ecke:" → Punkt → commit Drawing2D rect → done
|
||||
// Rectangle — mehrere Konstruktionsmethoden (portiert + erweitert rectTool).
|
||||
// Wählbar über die Option „Methode" (klickbar im Feld-Row, tippbar als „m",
|
||||
// zyklisch): 2-Punkt · 3-Punkt · Zentrum.
|
||||
//
|
||||
// Getippte Maße: die zweite Ecke kann als `r<breite>,<höhe>` relativ zur ersten
|
||||
// eingegeben werden (die Engine löst `r…` relativ zu lastPoint auf).
|
||||
// • 2-Punkt (Default): zwei gegenüberliegende Ecken, achsparallel.
|
||||
// 1) „Erste Ecke:" → Punkt
|
||||
// 2) „Gegenüberliegende Ecke:" → Punkt → commit (shape:"rect")
|
||||
// • 3-Punkt (gedreht): Basiskante + Höhe → beliebig gedrehtes Rechteck.
|
||||
// 1) „Erste Ecke:" → Punkt (Basis-Start)
|
||||
// 2) „Zweite Ecke:" → Punkt (Basis-Ende, definiert Winkel+Breite)
|
||||
// 3) „Höhe:" → Punkt → commit (geschlossene Polylinie)
|
||||
// • Zentrum: Mittelpunkt + Ecke, achsparallel.
|
||||
// 1) „Mittelpunkt:" → Punkt
|
||||
// 2) „Ecke:" → Punkt → commit (shape:"rect")
|
||||
//
|
||||
// Getippte Maße: die zweite/dritte Ecke kann relativ (`r<dx>,<dy>`) eingegeben
|
||||
// werden (die Engine löst `r…` relativ zu lastPoint auf).
|
||||
|
||||
import type { Drawing2D } from "../../model/types";
|
||||
import { uniqueId } from "../../tools/types";
|
||||
@@ -13,19 +24,50 @@ import type {
|
||||
CommandField,
|
||||
CommandResult,
|
||||
CommandState,
|
||||
CmdOption,
|
||||
DraftShape,
|
||||
Project,
|
||||
ToolDraft,
|
||||
Vec2,
|
||||
} from "../types";
|
||||
import type { TranslationKey } from "../../i18n";
|
||||
|
||||
const EPS = 1e-6;
|
||||
|
||||
/** Tab-Felder des zweiten-Ecke-Schritts: Breite, Höhe. */
|
||||
/** Konstruktionsmethode. */
|
||||
type RectMethod = "corner" | "three" | "center";
|
||||
const METHOD_ORDER: RectMethod[] = ["corner", "three", "center"];
|
||||
const nextMethod = (m: RectMethod): RectMethod =>
|
||||
METHOD_ORDER[(METHOD_ORDER.indexOf(m) + 1) % METHOD_ORDER.length];
|
||||
const methodValue = (m: RectMethod): string =>
|
||||
m === "corner" ? "2pt" : m === "three" ? "3pt" : "center";
|
||||
|
||||
/** Umschalt-Option für die Methode (im Feld-Row sichtbar, tippbar als „m"). */
|
||||
const methodOption = (m: RectMethod): CmdOption => ({
|
||||
id: "method",
|
||||
labelKey: "cmd.rect.method",
|
||||
value: methodValue(m),
|
||||
});
|
||||
|
||||
/** Tab-Felder des Breite/Höhe-Schritts (2-Punkt & Zentrum). */
|
||||
const RECT_FIELDS: CommandField[] = [
|
||||
{ id: "width", labelKey: "cmd.field.width" },
|
||||
{ id: "height", labelKey: "cmd.field.height" },
|
||||
];
|
||||
/** Tab-Felder des Basis-Schritts (3-Punkt): Länge + Winkel der Basiskante. */
|
||||
const BASE_FIELDS: CommandField[] = [
|
||||
{ id: "length", labelKey: "cmd.field.length" },
|
||||
{ id: "angle", labelKey: "cmd.field.angle" },
|
||||
];
|
||||
/** Tab-Feld des Höhen-Schritts (3-Punkt): senkrechter Abstand. */
|
||||
const RISE_FIELDS: CommandField[] = [{ id: "height", labelKey: "cmd.field.height" }];
|
||||
|
||||
const DEG = Math.PI / 180;
|
||||
const segLen = (a: Vec2, b: Vec2): number => Math.hypot(b.x - a.x, b.y - a.y);
|
||||
const segAngleDeg = (a: Vec2, b: Vec2): number =>
|
||||
(Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI;
|
||||
|
||||
// ── 2-Punkt / Zentrum: achsparallele Ableitung ───────────────────────────────
|
||||
|
||||
/**
|
||||
* Gegenüberliegende Ecke aus gelockten Feldern (width/height) + Cursor: eine
|
||||
@@ -43,15 +85,107 @@ function rectCornerFromFields(a: Vec2, locks: Record<string, number>, cursor: Ve
|
||||
return { x: a.x + w, y: a.y + h };
|
||||
}
|
||||
|
||||
// ── 3-Punkt: Basiskante + Höhe ───────────────────────────────────────────────
|
||||
|
||||
/** Basis-Endpunkt aus gelockten Feldern (length/angle) + Cursor, relativ zu `a`. */
|
||||
function baseFromFields(a: Vec2, locks: Record<string, number>, cursor: Vec2 | null): Vec2 {
|
||||
const ref = cursor ?? a;
|
||||
const length = "length" in locks ? locks.length : segLen(a, ref);
|
||||
const angleDeg = "angle" in locks ? locks.angle : segAngleDeg(a, ref);
|
||||
const ang = angleDeg * DEG;
|
||||
return { x: a.x + Math.cos(ang) * length, y: a.y + Math.sin(ang) * length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Vier Ecken des gedrehten Rechtecks aus Basiskante (a→b) und einem dritten
|
||||
* Punkt `p`: die Höhe ist der vorzeichenbehaftete senkrechte Abstand von `p` zur
|
||||
* Basislinie; das Rechteck wird zu dieser Seite hin aufgespannt.
|
||||
*/
|
||||
function rotatedCorners(a: Vec2, b: Vec2, p: Vec2): Vec2[] {
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
const len = Math.hypot(dx, dy);
|
||||
if (len < EPS) return [a, b, b, a];
|
||||
// Einheits-Normale (links der Basisrichtung).
|
||||
const nx = -dy / len;
|
||||
const ny = dx / len;
|
||||
const h = (p.x - a.x) * nx + (p.y - a.y) * ny; // signierte Höhe
|
||||
const c3 = { x: b.x + nx * h, y: b.y + ny * h };
|
||||
const c4 = { x: a.x + nx * h, y: a.y + ny * h };
|
||||
return [a, b, c3, c4];
|
||||
}
|
||||
|
||||
/** Senkrechter (signierter) Abstand eines Punktes zur Basislinie a→b. */
|
||||
function riseFrom(a: Vec2, b: Vec2, p: Vec2): number {
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
const len = Math.hypot(dx, dy);
|
||||
if (len < EPS) return 0;
|
||||
return (p.x - a.x) * (-dy / len) + (p.y - a.y) * (dx / len);
|
||||
}
|
||||
|
||||
/** Dritten Punkt aus gelockter Höhe rekonstruieren (auf der Cursor-Seite). */
|
||||
function riseToPoint(a: Vec2, b: Vec2, height: number, cursor: Vec2 | null): Vec2 {
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
const len = Math.hypot(dx, dy);
|
||||
if (len < EPS) return cursor ?? b;
|
||||
const nx = -dy / len;
|
||||
const ny = dx / len;
|
||||
const sign = cursor && riseFrom(a, b, cursor) < 0 ? -1 : 1;
|
||||
const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
||||
const h = sign * Math.abs(height);
|
||||
return { x: mid.x + nx * h, y: mid.y + ny * h };
|
||||
}
|
||||
|
||||
// ── Zentrum: Ecke aus Mittelpunkt ────────────────────────────────────────────
|
||||
|
||||
/** Achsparallele Ecken aus Zentrum + einer Ecke. */
|
||||
function centerGeom(center: Vec2, corner: Vec2): { min: Vec2; max: Vec2 } {
|
||||
const hx = Math.abs(corner.x - center.x);
|
||||
const hy = Math.abs(corner.y - center.y);
|
||||
return {
|
||||
min: { x: center.x - hx, y: center.y - hy },
|
||||
max: { x: center.x + hx, y: center.y + hy },
|
||||
};
|
||||
}
|
||||
|
||||
/** Ecke aus Zentrum + gelockten Halbmaßen (width/height als volle Kantenmaße). */
|
||||
function centerCornerFromFields(
|
||||
center: Vec2,
|
||||
locks: Record<string, number>,
|
||||
cursor: Vec2 | null,
|
||||
): Vec2 {
|
||||
const cur = cursor ?? center;
|
||||
const dx = cur.x - center.x;
|
||||
const dy = cur.y - center.y;
|
||||
const sx = dx < 0 ? -1 : 1;
|
||||
const sy = dy < 0 ? -1 : 1;
|
||||
const hx = "width" in locks ? Math.abs(locks.width) / 2 : Math.abs(dx);
|
||||
const hy = "height" in locks ? Math.abs(locks.height) / 2 : Math.abs(dy);
|
||||
return { x: center.x + sx * hx, y: center.y + sy * hy };
|
||||
}
|
||||
|
||||
// ── Zustand ──────────────────────────────────────────────────────────────────
|
||||
|
||||
interface RectIdle extends CommandState {
|
||||
phase: "corner1";
|
||||
method: RectMethod;
|
||||
}
|
||||
interface RectDrawing extends CommandState {
|
||||
interface RectCorner2 extends CommandState {
|
||||
phase: "corner2";
|
||||
method: RectMethod;
|
||||
a: Vec2;
|
||||
cursor: Vec2 | null;
|
||||
}
|
||||
type RectState = RectIdle | RectDrawing;
|
||||
interface RectRise extends CommandState {
|
||||
phase: "rise";
|
||||
method: "three";
|
||||
a: Vec2;
|
||||
b: Vec2;
|
||||
cursor: Vec2 | null;
|
||||
}
|
||||
type RectState = RectIdle | RectCorner2 | RectRise;
|
||||
|
||||
function rectGeom(a: Vec2, b: Vec2): { min: Vec2; max: Vec2 } {
|
||||
return {
|
||||
@@ -60,84 +194,231 @@ function rectGeom(a: Vec2, b: Vec2): { min: Vec2; max: Vec2 } {
|
||||
};
|
||||
}
|
||||
|
||||
/** Vorschau (geschlossenes Rechteck) + HUD (Breite × Höhe). */
|
||||
function rectDraft(a: Vec2, cursor: Vec2 | null): ToolDraft {
|
||||
if (!cursor) return { preview: [], vertices: [a] };
|
||||
const g = rectGeom(a, cursor);
|
||||
const pts: Vec2[] = [
|
||||
g.min,
|
||||
{ x: g.max.x, y: g.min.y },
|
||||
g.max,
|
||||
{ x: g.min.x, y: g.max.y },
|
||||
];
|
||||
// ── Vorschau ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function corners4Draft(pts: Vec2[], hudAt: Vec2 | null, hudText: string): ToolDraft {
|
||||
const preview: DraftShape[] = [{ kind: "poly", pts, closed: true }];
|
||||
const w = g.max.x - g.min.x;
|
||||
const h = g.max.y - g.min.y;
|
||||
return {
|
||||
preview,
|
||||
vertices: [a],
|
||||
hud: { at: cursor, text: `${w.toFixed(2)} × ${h.toFixed(2)} m` },
|
||||
};
|
||||
const draft: ToolDraft = { preview, vertices: [pts[0]] };
|
||||
if (hudAt) draft.hud = { at: hudAt, text: hudText };
|
||||
return draft;
|
||||
}
|
||||
|
||||
function appendRect(p: Project, a: Vec2, b: Vec2, ctx: CommandContext): Project {
|
||||
const g = rectGeom(a, b);
|
||||
if (g.max.x - g.min.x < EPS || g.max.y - g.min.y < EPS) return p; // Null-Rechteck
|
||||
/** Vorschau des 2-Punkt-Rechtecks (achsparallel). */
|
||||
function cornerDraft(a: Vec2, cursor: Vec2 | null): ToolDraft {
|
||||
if (!cursor) return { preview: [], vertices: [a] };
|
||||
const g = rectGeom(a, cursor);
|
||||
const pts: Vec2[] = [g.min, { x: g.max.x, y: g.min.y }, g.max, { x: g.min.x, y: g.max.y }];
|
||||
const w = g.max.x - g.min.x;
|
||||
const h = g.max.y - g.min.y;
|
||||
return corners4Draft(pts, cursor, `${w.toFixed(2)} × ${h.toFixed(2)} m`);
|
||||
}
|
||||
|
||||
/** Vorschau des Zentrum-Rechtecks (achsparallel). */
|
||||
function centerDraft(center: Vec2, cursor: Vec2 | null): ToolDraft {
|
||||
if (!cursor) return { preview: [], vertices: [center] };
|
||||
const g = centerGeom(center, cursor);
|
||||
const pts: Vec2[] = [g.min, { x: g.max.x, y: g.min.y }, g.max, { x: g.min.x, y: g.max.y }];
|
||||
const w = g.max.x - g.min.x;
|
||||
const h = g.max.y - g.min.y;
|
||||
return corners4Draft(pts, cursor, `${w.toFixed(2)} × ${h.toFixed(2)} m`);
|
||||
}
|
||||
|
||||
/** Vorschau der Basiskante (3-Punkt, erster Teil). */
|
||||
function baseDraft(a: Vec2, cursor: Vec2 | null): ToolDraft {
|
||||
if (!cursor || segLen(a, cursor) < EPS) return { preview: [{ kind: "line", a, b: a }], vertices: [a] };
|
||||
const draft: ToolDraft = { preview: [{ kind: "line", a, b: cursor }], vertices: [a] };
|
||||
draft.hud = {
|
||||
at: cursor,
|
||||
text: `${segLen(a, cursor).toFixed(2)} m · ${Math.abs((segAngleDeg(a, cursor) + 360) % 360).toFixed(0)}°`,
|
||||
};
|
||||
return draft;
|
||||
}
|
||||
|
||||
/** Vorschau des gedrehten Rechtecks (3-Punkt, zweiter Teil). */
|
||||
function riseDraft(a: Vec2, b: Vec2, cursor: Vec2 | null): ToolDraft {
|
||||
if (!cursor) return { preview: [{ kind: "line", a, b }], vertices: [a] };
|
||||
const pts = rotatedCorners(a, b, cursor);
|
||||
const w = segLen(a, b);
|
||||
const h = Math.abs(riseFrom(a, b, cursor));
|
||||
return corners4Draft(pts, cursor, `${w.toFixed(2)} × ${h.toFixed(2)} m`);
|
||||
}
|
||||
|
||||
// ── Commit ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Achsparalleles Rechteck als `shape:"rect"` (geschlossen behandelt). */
|
||||
function appendRect(p: Project, min: Vec2, max: Vec2, ctx: CommandContext): Project {
|
||||
if (max.x - min.x < EPS || max.y - min.y < EPS) return p; // Null-Rechteck
|
||||
const d: Drawing2D = {
|
||||
id: uniqueId("dr2d"),
|
||||
type: "drawing2d",
|
||||
levelId: ctx.level.id,
|
||||
categoryCode: ctx.defaultCategoryCode,
|
||||
geom: { shape: "rect", min: g.min, max: g.max },
|
||||
geom: { shape: "rect", min, max },
|
||||
};
|
||||
return { ...p, drawings2d: [...p.drawings2d, d] };
|
||||
}
|
||||
|
||||
const idle = (): [CommandState, CommandResult] => [
|
||||
{ phase: "corner1", lastPoint: null },
|
||||
/**
|
||||
* Gedrehtes Rechteck als geschlossene Polylinie (`shape:"polyline", closed:true`)
|
||||
* — `shape:"rect"` kann nur achsparallel, deshalb hier ein Ring aus vier Ecken.
|
||||
* Downstream (Füllung/Offset) behandelt einen geschlossenen Polygonzug als Ring.
|
||||
*/
|
||||
function appendRotated(p: Project, pts: Vec2[], ctx: CommandContext): Project {
|
||||
// Fläche prüfen: entartete Rechtecke verwerfen.
|
||||
const w = segLen(pts[0], pts[1]);
|
||||
const h = segLen(pts[1], pts[2]);
|
||||
if (w < EPS || h < EPS) return p;
|
||||
const d: Drawing2D = {
|
||||
id: uniqueId("dr2d"),
|
||||
type: "drawing2d",
|
||||
levelId: ctx.level.id,
|
||||
categoryCode: ctx.defaultCategoryCode,
|
||||
geom: { shape: "polyline", pts, closed: true },
|
||||
};
|
||||
return { ...p, drawings2d: [...p.drawings2d, d] };
|
||||
}
|
||||
|
||||
const idle = (method: RectMethod): [CommandState, CommandResult] => [
|
||||
{ phase: "corner1", lastPoint: null, method } as RectIdle,
|
||||
{ draft: null, done: true },
|
||||
];
|
||||
|
||||
// ── Prompt je Schritt/Methode ────────────────────────────────────────────────
|
||||
|
||||
function promptKey(s: RectState): TranslationKey {
|
||||
if (s.phase === "corner1") return s.method === "center" ? "cmd.rect.center" : "cmd.rect.first";
|
||||
if (s.phase === "rise") return "cmd.rect.rise";
|
||||
// corner2
|
||||
if (s.method === "three") return "cmd.rect.baseEnd";
|
||||
if (s.method === "center") return "cmd.rect.corner";
|
||||
return "cmd.rect.second";
|
||||
}
|
||||
|
||||
export const rectCommand: Command = {
|
||||
name: "rect",
|
||||
labelKey: "cmd.rect.label",
|
||||
prompt: (s) => ((s as RectState).phase === "corner2" ? "cmd.rect.second" : "cmd.rect.first"),
|
||||
accepts: () => ["point", "number"],
|
||||
options: () => [],
|
||||
init: (): RectIdle => ({ phase: "corner1", lastPoint: null }),
|
||||
prompt: (s) => promptKey(s as RectState),
|
||||
accepts: () => ["point", "number", "option"],
|
||||
options: (s) => [methodOption((s as RectState).method)],
|
||||
init: (): RectIdle => ({ phase: "corner1", lastPoint: null, method: "corner" }),
|
||||
|
||||
onInput: (state, input, ctx): [CommandState, CommandResult] => {
|
||||
const s = state as RectState;
|
||||
|
||||
if (input.kind === "option") {
|
||||
if (input.id === "method") {
|
||||
// Methode zyklisch umschalten; laufende Konstruktion verwerfen und den
|
||||
// Befehl AKTIV lassen (kein done) — nur zum ersten Schritt zurück.
|
||||
const m = nextMethod(s.method);
|
||||
const ns: RectIdle = { phase: "corner1", lastPoint: null, method: m };
|
||||
return [ns, { draft: null }];
|
||||
}
|
||||
return [s, { draft: null }];
|
||||
}
|
||||
|
||||
if (input.kind !== "point") {
|
||||
return [s, { draft: s.phase === "corner2" ? rectDraft(s.a, s.cursor) : null }];
|
||||
return [s, { draft: draftFor(s) }];
|
||||
}
|
||||
const pt = input.point;
|
||||
if (s.phase !== "corner2") {
|
||||
const ns: RectDrawing = { phase: "corner2", a: pt, cursor: pt, lastPoint: pt };
|
||||
return [ns, { draft: rectDraft(pt, pt) }];
|
||||
|
||||
if (s.phase === "corner1") {
|
||||
if (s.method === "three") {
|
||||
const ns: RectCorner2 = { phase: "corner2", method: "three", a: pt, cursor: pt, lastPoint: pt };
|
||||
return [ns, { draft: baseDraft(pt, pt) }];
|
||||
}
|
||||
const ns: RectCorner2 = { phase: "corner2", method: s.method, a: pt, cursor: pt, lastPoint: pt };
|
||||
return [ns, { draft: draftFor(ns) }];
|
||||
}
|
||||
const a = s.a;
|
||||
|
||||
if (s.phase === "corner2") {
|
||||
if (s.method === "three") {
|
||||
// Basiskante gesetzt → dritter Punkt bestimmt Höhe.
|
||||
if (segLen(s.a, pt) < EPS) return [s, { draft: baseDraft(s.a, s.cursor) }];
|
||||
const ns: RectRise = { phase: "rise", method: "three", a: s.a, b: pt, cursor: pt, lastPoint: pt };
|
||||
return [ns, { draft: riseDraft(s.a, pt, pt) }];
|
||||
}
|
||||
const a = s.a;
|
||||
const method = s.method;
|
||||
return [
|
||||
{ phase: "corner1", lastPoint: null, method } as RectIdle,
|
||||
{
|
||||
draft: null,
|
||||
done: true,
|
||||
commit: (p) => {
|
||||
if (method === "center") {
|
||||
const g = centerGeom(a, pt);
|
||||
return appendRect(p, g.min, g.max, ctx);
|
||||
}
|
||||
const g = rectGeom(a, pt);
|
||||
return appendRect(p, g.min, g.max, ctx);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// phase "rise" (3-Punkt) → committen als gedrehte, geschlossene Polylinie.
|
||||
const { a, b } = s;
|
||||
return [
|
||||
{ phase: "corner1", lastPoint: null },
|
||||
{ draft: null, done: true, commit: (p) => appendRect(p, a, pt, ctx) },
|
||||
{ phase: "corner1", lastPoint: null, method: "three" } as RectIdle,
|
||||
{ draft: null, done: true, commit: (p) => appendRotated(p, rotatedCorners(a, b, pt), ctx) },
|
||||
];
|
||||
},
|
||||
|
||||
onMove: (state, point): [CommandState, CommandResult] => {
|
||||
const s = state as RectState;
|
||||
if (s.phase !== "corner2") return [s, { draft: null }];
|
||||
const ns: RectDrawing = { ...s, cursor: point };
|
||||
return [ns, { draft: rectDraft(s.a, point) }];
|
||||
if (s.phase === "corner1") return [s, { draft: null }];
|
||||
const ns = { ...s, cursor: point } as RectState;
|
||||
return [ns, { draft: draftFor(ns) }];
|
||||
},
|
||||
|
||||
onConfirm: (): [CommandState, CommandResult] => idle(),
|
||||
onCancel: (): [CommandState, CommandResult] => idle(),
|
||||
onConfirm: (state): [CommandState, CommandResult] => idle((state as RectState).method),
|
||||
onCancel: (state): [CommandState, CommandResult] => idle((state as RectState).method),
|
||||
|
||||
// Tab-Feld-Zyklus nur im „corner2"-Schritt: Breite + Höhe (signiert nach Cursor).
|
||||
fields: (state) => ((state as RectState).phase === "corner2" ? RECT_FIELDS : []),
|
||||
// Tab-Feld-Zyklus je Schritt/Methode.
|
||||
fields: (state) => {
|
||||
const s = state as RectState;
|
||||
if (s.phase === "corner2") return s.method === "three" ? BASE_FIELDS : RECT_FIELDS;
|
||||
if (s.phase === "rise") return RISE_FIELDS;
|
||||
return [];
|
||||
},
|
||||
pointFromFields: (state, locks, cursor) => {
|
||||
const s = state as RectState;
|
||||
if (s.phase !== "corner2") return null;
|
||||
return rectCornerFromFields(s.a, locks, cursor);
|
||||
if (s.phase === "corner2") {
|
||||
if (s.method === "three") return baseFromFields(s.a, locks, cursor);
|
||||
if (s.method === "center") return centerCornerFromFields(s.a, locks, cursor);
|
||||
return rectCornerFromFields(s.a, locks, cursor);
|
||||
}
|
||||
if (s.phase === "rise") {
|
||||
if ("height" in locks) return riseToPoint(s.a, s.b, locks.height, cursor);
|
||||
return cursor;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
fieldValues: (state, _locks, cursor): Record<string, number> => {
|
||||
const s = state as RectState;
|
||||
if (!cursor) return {};
|
||||
if (s.phase === "corner2") {
|
||||
if (s.method === "three") {
|
||||
return {
|
||||
length: segLen(s.a, cursor),
|
||||
angle: ((segAngleDeg(s.a, cursor) % 360) + 360) % 360,
|
||||
};
|
||||
}
|
||||
return { width: Math.abs(cursor.x - s.a.x) * (s.method === "center" ? 2 : 1),
|
||||
height: Math.abs(cursor.y - s.a.y) * (s.method === "center" ? 2 : 1) };
|
||||
}
|
||||
if (s.phase === "rise") return { height: Math.abs(riseFrom(s.a, s.b, cursor)) };
|
||||
return {};
|
||||
},
|
||||
};
|
||||
|
||||
/** Vorschau passend zum Zustand. */
|
||||
function draftFor(s: RectState): ToolDraft {
|
||||
if (s.phase === "corner2") {
|
||||
if (s.method === "three") return baseDraft(s.a, s.cursor);
|
||||
if (s.method === "center") return centerDraft(s.a, s.cursor);
|
||||
return cornerDraft(s.a, s.cursor);
|
||||
}
|
||||
if (s.phase === "rise") return riseDraft(s.a, s.b, s.cursor);
|
||||
return { preview: [], vertices: [] };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
// Raum (Room / SIA-416-Fläche) als Engine-Befehl. Zwei Modi (togglebar über die
|
||||
// Option „Modus"):
|
||||
//
|
||||
// 1) „innen" (Default) — EIN Klick IN einen von Wänden umschlossenen Bereich.
|
||||
// Der Umriss wird über den reinen Rechenkern (geometry/roomBoundary,
|
||||
// `roomFromPointInside`) aus den Wänden des aktiven Geschosses automatisch
|
||||
// als lichte Innenkontur erkannt. Findet der Klick keine geschlossene Zelle,
|
||||
// passiert nichts (der Nutzer kann in den Manuell-Modus wechseln).
|
||||
//
|
||||
// 2) „manuell" — ein GESCHLOSSENER Umriss wird Punkt für Punkt gezeichnet
|
||||
// (wie eine geschlossene Polylinie); Klick auf den Start / Option „Schliessen"
|
||||
// / Enter (≥3 Punkte) committet den Raum.
|
||||
//
|
||||
// Der committete Raum bekommt SIA-Blatt HNF, einen Default-Namen und die
|
||||
// Kategorie „45 Räume" (Fallback: aktive Kategorie). Fläche/Umfang/Schwerpunkt
|
||||
// werden NICHT gespeichert (bei jedem Rendern aus `boundary` abgeleitet).
|
||||
//
|
||||
// Bezeichner englisch, sichtbarer Text via t() (CONVENTIONS.md). Einheit: METER.
|
||||
|
||||
import type { Room } from "../../model/types";
|
||||
import { wallTypeThickness } from "../../model/types";
|
||||
import { roomFromPointInside, type WallSegment } from "../../geometry/roomBoundary";
|
||||
import { polygonArea, centroid } from "../../geometry/roomArea";
|
||||
import { docFromText } from "../../text/richText";
|
||||
import { uniqueId } from "../../tools/types";
|
||||
import type {
|
||||
Command,
|
||||
CommandContext,
|
||||
CommandField,
|
||||
CommandResult,
|
||||
CommandState,
|
||||
CmdOption,
|
||||
DraftShape,
|
||||
Project,
|
||||
ToolDraft,
|
||||
Vec2,
|
||||
} from "../types";
|
||||
|
||||
const EPS = 1e-6;
|
||||
const DEG = Math.PI / 180;
|
||||
const CLOSE_HIT = 0.08; // Modell-Meter: Klick nahe Startpunkt schließt den Umriss
|
||||
/** Default-Ebene (Kategorie) für Räume: „60" Räume (Fallback: aktive). */
|
||||
const ROOM_CATEGORY = "60";
|
||||
/** Default-Raumfarbe (falls Kategorie nicht auflösbar). */
|
||||
const ROOM_COLOR = "#5a7a9a";
|
||||
|
||||
const segLen = (a: Vec2, b: Vec2): number => Math.hypot(b.x - a.x, b.y - a.y);
|
||||
const segAngleDeg = (a: Vec2, b: Vec2): number =>
|
||||
(Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI;
|
||||
|
||||
/** Existiert die Raum-Kategorie („45")? Sonst Fallback auf die aktive Kategorie. */
|
||||
function roomCategory(ctx: CommandContext): string {
|
||||
const flat: { code: string }[] = [];
|
||||
const walk = (list: { code: string; children?: unknown[] }[]) => {
|
||||
for (const c of list) {
|
||||
flat.push({ code: c.code });
|
||||
if (c.children) walk(c.children as { code: string; children?: unknown[] }[]);
|
||||
}
|
||||
};
|
||||
walk(ctx.project.layers as { code: string; children?: unknown[] }[]);
|
||||
return flat.some((c) => c.code === ROOM_CATEGORY)
|
||||
? ROOM_CATEGORY
|
||||
: ctx.defaultCategoryCode;
|
||||
}
|
||||
|
||||
/** Farbe der Raum-Kategorie (Ebene) — sonst Default. */
|
||||
function roomColor(ctx: CommandContext, code: string): string {
|
||||
const flat: { code: string; color?: string; children?: unknown[] }[] = [];
|
||||
const walk = (list: { code: string; color?: string; children?: unknown[] }[]) => {
|
||||
for (const c of list) {
|
||||
flat.push(c);
|
||||
if (c.children)
|
||||
walk(
|
||||
c.children as {
|
||||
code: string;
|
||||
color?: string;
|
||||
children?: unknown[];
|
||||
}[],
|
||||
);
|
||||
}
|
||||
};
|
||||
walk(
|
||||
ctx.project.layers as {
|
||||
code: string;
|
||||
color?: string;
|
||||
children?: unknown[];
|
||||
}[],
|
||||
);
|
||||
return flat.find((c) => c.code === code)?.color ?? ROOM_COLOR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wände des aktiven Geschosses in die vom Rechenkern erwartete Form bringen:
|
||||
* Mittellinie (start→end) + Gesamtdicke des Wandtyps. Grafik-2D-Geometrie und
|
||||
* andere Geschosse bleiben außen vor.
|
||||
*/
|
||||
function wallsOfFloor(ctx: CommandContext): WallSegment[] {
|
||||
const { project, level } = ctx;
|
||||
const out: WallSegment[] = [];
|
||||
for (const w of project.walls) {
|
||||
if (w.floorId !== level.id) continue;
|
||||
const wt = project.wallTypes.find((t) => t.id === w.wallTypeId);
|
||||
const thickness = wt ? wallTypeThickness(wt) : 0.2;
|
||||
out.push({ a: w.start, b: w.end, thickness });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Baut einen Raum aus einem fertigen Umriss (immutabel; keine abgeleiteten Felder). */
|
||||
function buildRoom(ctx: CommandContext, boundary: Vec2[]): Room {
|
||||
const code = roomCategory(ctx);
|
||||
const name = defaultRoomName(ctx);
|
||||
return {
|
||||
id: uniqueId("RM"),
|
||||
type: "room",
|
||||
floorId: ctx.level.id,
|
||||
categoryCode: code,
|
||||
siaCategory: "HNF",
|
||||
name,
|
||||
boundary,
|
||||
color: roomColor(ctx, code),
|
||||
// Stempel-Anker EINMALIG auf den Zentroid setzen (danach frei verschiebbar,
|
||||
// bleibt beim Ändern der Kontur an seiner Stelle).
|
||||
stampAnchor: centroid(boundary),
|
||||
// Frei editierbarer Stempel-Text: ein zentrierter Absatz mit dem Raumnamen.
|
||||
stampDoc: { ...docFromText(name), paragraphs: docFromText(name).paragraphs.map((p) => ({ ...p, align: "center" as const })) },
|
||||
};
|
||||
}
|
||||
|
||||
/** Fortlaufender Default-Name „Raum N" (N = Anzahl vorhandener Räume + 1). */
|
||||
function defaultRoomName(ctx: CommandContext): string {
|
||||
const n = (ctx.project.rooms ?? []).length + 1;
|
||||
return `Raum ${n}`;
|
||||
}
|
||||
|
||||
/** Hängt einen Raum ans Projekt (immutabel); entartete Umrisse werden verworfen. */
|
||||
function appendRoom(p: Project, boundary: Vec2[], ctx: CommandContext): Project {
|
||||
if (boundary.length < 3 || polygonArea(boundary) < 1e-4) return p;
|
||||
const room = buildRoom(ctx, boundary);
|
||||
const rooms = p.rooms ? [...p.rooms, room] : [room];
|
||||
return { ...p, rooms };
|
||||
}
|
||||
|
||||
// ── Zustand ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type RoomMode = "inside" | "manual";
|
||||
|
||||
interface RoomIdle extends CommandState {
|
||||
phase: "start";
|
||||
mode: RoomMode;
|
||||
}
|
||||
interface RoomDrawing extends CommandState {
|
||||
phase: "draw";
|
||||
mode: RoomMode;
|
||||
points: Vec2[];
|
||||
cursor: Vec2 | null;
|
||||
}
|
||||
type RoomCmdState = RoomIdle | RoomDrawing;
|
||||
|
||||
const MODE: CmdOption = { id: "mode", labelKey: "cmd.room.mode", value: "inside" };
|
||||
const CLOSE: CmdOption = { id: "close", labelKey: "cmd.polyline.close" };
|
||||
const UNDO: CmdOption = { id: "undo", labelKey: "cmd.polyline.undo" };
|
||||
|
||||
/** Tab-Felder je weiteren Punkt (Manuell-Modus): Länge + Winkel relativ zum letzten. */
|
||||
const ROOM_FIELDS: CommandField[] = [
|
||||
{ id: "length", labelKey: "cmd.field.length" },
|
||||
{ id: "angle", labelKey: "cmd.field.angle" },
|
||||
];
|
||||
|
||||
function nextFromFields(
|
||||
last: Vec2,
|
||||
locks: Record<string, number>,
|
||||
cursor: Vec2 | null,
|
||||
): Vec2 {
|
||||
const ref = cursor ?? last;
|
||||
const length = "length" in locks ? locks.length : segLen(last, ref);
|
||||
const angleDeg = "angle" in locks ? locks.angle : segAngleDeg(last, ref);
|
||||
const ang = angleDeg * DEG;
|
||||
return { x: last.x + Math.cos(ang) * length, y: last.y + Math.sin(ang) * length };
|
||||
}
|
||||
|
||||
/** Vorschau des manuellen Umrisses (geschlossener Ring ab 3 Punkten) + HUD. */
|
||||
function manualDraft(points: Vec2[], cursor: Vec2 | null): ToolDraft {
|
||||
const pts = cursor ? [...points, cursor] : points;
|
||||
const showClosed = pts.length >= 3;
|
||||
const preview: DraftShape[] = [{ kind: "poly", pts, closed: showClosed }];
|
||||
const draft: ToolDraft = { preview, vertices: points };
|
||||
const last = points[points.length - 1];
|
||||
if (cursor && last && segLen(last, cursor) >= EPS) {
|
||||
draft.hud = {
|
||||
at: cursor,
|
||||
text: `${segLen(last, cursor).toFixed(2)} m · ${Math.abs(
|
||||
(segAngleDeg(last, cursor) + 360) % 360,
|
||||
).toFixed(0)}°`,
|
||||
};
|
||||
}
|
||||
return draft;
|
||||
}
|
||||
|
||||
const idle = (mode: RoomMode): [CommandState, CommandResult] => [
|
||||
{ phase: "start", lastPoint: null, mode } as RoomIdle,
|
||||
{ draft: null, done: true },
|
||||
];
|
||||
|
||||
export const roomCommand: Command = {
|
||||
name: "room",
|
||||
labelKey: "cmd.room.label",
|
||||
// Räume leben auf Geschossen — wie Wand/Decke nur dort aktiv.
|
||||
floorOnly: true,
|
||||
prompt: (s) => {
|
||||
const st = s as RoomCmdState;
|
||||
if (st.phase === "draw") return "cmd.room.next";
|
||||
return st.mode === "inside" ? "cmd.room.inside" : "cmd.room.first";
|
||||
},
|
||||
accepts: (s) =>
|
||||
(s as RoomCmdState).phase === "draw"
|
||||
? ["point", "number", "option"]
|
||||
: ["point", "option"],
|
||||
options: (s): CmdOption[] => {
|
||||
const st = s as RoomCmdState;
|
||||
const modeOpt: CmdOption = { ...MODE, value: st.mode };
|
||||
if (st.phase !== "draw") return [modeOpt];
|
||||
return st.points.length >= 3 ? [modeOpt, CLOSE, UNDO] : [modeOpt, UNDO];
|
||||
},
|
||||
init: (): RoomIdle => ({ phase: "start", lastPoint: null, mode: "inside" }),
|
||||
|
||||
onInput: (state, input, ctx): [CommandState, CommandResult] => {
|
||||
const s = state as RoomCmdState;
|
||||
|
||||
if (input.kind === "option") {
|
||||
if (input.id === "mode") {
|
||||
// Modus umschalten; ein laufender Manuell-Entwurf wird verworfen.
|
||||
const mode: RoomMode = s.mode === "inside" ? "manual" : "inside";
|
||||
return idle(mode);
|
||||
}
|
||||
if (s.phase !== "draw") return [s, { draft: null }];
|
||||
if (input.id === "undo") {
|
||||
const pts = s.points.slice(0, -1);
|
||||
if (pts.length === 0)
|
||||
return [
|
||||
{ phase: "start", lastPoint: null, mode: s.mode } as RoomIdle,
|
||||
{ draft: null },
|
||||
];
|
||||
const ns: RoomDrawing = {
|
||||
phase: "draw",
|
||||
mode: s.mode,
|
||||
points: pts,
|
||||
cursor: s.cursor,
|
||||
lastPoint: pts[pts.length - 1],
|
||||
};
|
||||
return [ns, { draft: manualDraft(pts, s.cursor) }];
|
||||
}
|
||||
if (input.id === "close" && s.points.length >= 3) {
|
||||
const pts = s.points;
|
||||
return [
|
||||
{ phase: "start", lastPoint: null, mode: s.mode } as RoomIdle,
|
||||
{ draft: null, done: true, commit: (p) => appendRoom(p, pts, ctx) },
|
||||
];
|
||||
}
|
||||
return [s, { draft: s.phase === "draw" ? manualDraft(s.points, s.cursor) : null }];
|
||||
}
|
||||
|
||||
if (input.kind !== "point") {
|
||||
return [
|
||||
s,
|
||||
{ draft: s.phase === "draw" ? manualDraft(s.points, s.cursor) : null },
|
||||
];
|
||||
}
|
||||
|
||||
const pt = input.point;
|
||||
|
||||
// ── Modus „innen": ein Klick → Umriss automatisch erkennen + committen ──
|
||||
if (s.mode === "inside" && s.phase !== "draw") {
|
||||
const boundary = roomFromPointInside(pt, wallsOfFloor(ctx));
|
||||
if (boundary && boundary.length >= 3) {
|
||||
return [
|
||||
{ phase: "start", lastPoint: null, mode: "inside" } as RoomIdle,
|
||||
{ draft: null, done: true, commit: (p) => appendRoom(p, boundary, ctx) },
|
||||
];
|
||||
}
|
||||
// Kein umschlossener Bereich getroffen → im Werkzeug bleiben (kein Commit).
|
||||
return [s, { draft: null }];
|
||||
}
|
||||
|
||||
// ── Modus „manuell": Umriss Punkt für Punkt ──
|
||||
if (s.phase !== "draw") {
|
||||
const ns: RoomDrawing = {
|
||||
phase: "draw",
|
||||
mode: "manual",
|
||||
points: [pt],
|
||||
cursor: pt,
|
||||
lastPoint: pt,
|
||||
};
|
||||
return [ns, { draft: manualDraft([pt], pt) }];
|
||||
}
|
||||
// Klick nahe Startpunkt → schließen + committen.
|
||||
if (s.points.length >= 3 && segLen(s.points[0], pt) < CLOSE_HIT) {
|
||||
const pts = s.points;
|
||||
return [
|
||||
{ phase: "start", lastPoint: null, mode: s.mode } as RoomIdle,
|
||||
{ draft: null, done: true, commit: (p) => appendRoom(p, pts, ctx) },
|
||||
];
|
||||
}
|
||||
const last = s.points[s.points.length - 1];
|
||||
if (segLen(last, pt) < EPS) {
|
||||
return [s, { draft: manualDraft(s.points, s.cursor) }];
|
||||
}
|
||||
const points = [...s.points, pt];
|
||||
const ns: RoomDrawing = {
|
||||
phase: "draw",
|
||||
mode: "manual",
|
||||
points,
|
||||
cursor: pt,
|
||||
lastPoint: pt,
|
||||
};
|
||||
return [ns, { draft: manualDraft(points, pt) }];
|
||||
},
|
||||
|
||||
onMove: (state, point): [CommandState, CommandResult] => {
|
||||
const s = state as RoomCmdState;
|
||||
if (s.phase !== "draw") return [s, { draft: null }];
|
||||
const ns: RoomDrawing = { ...s, cursor: point };
|
||||
return [ns, { draft: manualDraft(s.points, point) }];
|
||||
},
|
||||
|
||||
onConfirm: (state, ctx): [CommandState, CommandResult] => {
|
||||
const s = state as RoomCmdState;
|
||||
if (s.phase === "draw" && s.points.length >= 3) {
|
||||
const pts = s.points;
|
||||
return [
|
||||
{ phase: "start", lastPoint: null, mode: s.mode } as RoomIdle,
|
||||
{ draft: null, done: true, commit: (p) => appendRoom(p, pts, ctx) },
|
||||
];
|
||||
}
|
||||
return idle(s.mode);
|
||||
},
|
||||
onCancel: (state): [CommandState, CommandResult] =>
|
||||
idle((state as RoomCmdState).mode),
|
||||
|
||||
fields: (state) => ((state as RoomCmdState).phase === "draw" ? ROOM_FIELDS : []),
|
||||
pointFromFields: (state, locks, cursor) => {
|
||||
const s = state as RoomCmdState;
|
||||
if (s.phase !== "draw" || s.points.length === 0) return null;
|
||||
return nextFromFields(s.points[s.points.length - 1], locks, cursor);
|
||||
},
|
||||
fieldValues: (state, _locks, cursor): Record<string, number> => {
|
||||
const s = state as RoomCmdState;
|
||||
if (s.phase !== "draw" || s.points.length === 0 || !cursor) return {};
|
||||
const last = s.points[s.points.length - 1];
|
||||
return {
|
||||
length: segLen(last, cursor),
|
||||
angle: ((segAngleDeg(last, cursor) % 360) + 360) % 360,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,268 @@
|
||||
// Treppe (Stair) als Engine-Befehl. Ablauf:
|
||||
// 1) „Startpunkt der Treppe:" → Punkt (Antritt, unterste Stufe).
|
||||
// 2) „Laufrichtung / Ende:" → Punkt: definiert Richtung + Lauflänge des ersten
|
||||
// Laufs. Bei L folgt ein zweiter Lauf (Ende des zweiten Laufs); bei Wendel
|
||||
// ist der zweite Punkt der äußere Rand (Radius). Committet die Treppe.
|
||||
//
|
||||
// Optionen (togglebar): Grundform gerade/L/Wendel (Auf-/Ab-Umschalter). Tab-Felder
|
||||
// im Laufschritt: Breite / Stufenanzahl / Steighöhe (totalRise). Live-Vorschau
|
||||
// zeigt die Tritt-Linien + die Lauflinie.
|
||||
//
|
||||
// Herkunft der Treppen-Felder (CommandContext):
|
||||
// • floorId ← ctx.level.id (aktives Geschoss)
|
||||
// • categoryCode← "40 Treppen" (Fallback ctx.defaultCategoryCode)
|
||||
// • totalRise ← Geschosshöhe (ctx.level.floorHeight), damit die Treppe genau
|
||||
// ins nächste Geschoss steigt (geschossübergreifend).
|
||||
//
|
||||
// Bezeichner englisch, sichtbarer Text via t() (CONVENTIONS.md).
|
||||
|
||||
import type { Stair, StairShape, Vec2 as MVec2 } from "../../model/types";
|
||||
import { stairGeometry, defaultStepCount } from "../../geometry/stair";
|
||||
import { uniqueId } from "../../tools/types";
|
||||
import type {
|
||||
Command,
|
||||
CommandContext,
|
||||
CommandField,
|
||||
CommandResult,
|
||||
CommandState,
|
||||
CmdOption,
|
||||
DraftShape,
|
||||
Project,
|
||||
ToolDraft,
|
||||
Vec2,
|
||||
} from "../types";
|
||||
|
||||
/** Default-Ebene (Kategorie) für Treppen: „40" Treppen (Fallback: aktive). */
|
||||
const STAIR_CATEGORY = "40";
|
||||
/** Default-Laufbreite (Meter). */
|
||||
const DEF_WIDTH = 1.0;
|
||||
const EPS = 1e-6;
|
||||
|
||||
const sub = (a: Vec2, b: Vec2): Vec2 => ({ x: a.x - b.x, y: a.y - b.y });
|
||||
const lenOf = (a: Vec2): number => Math.hypot(a.x, a.y);
|
||||
const normv = (a: Vec2): Vec2 => {
|
||||
const l = lenOf(a) || 1e-9;
|
||||
return { x: a.x / l, y: a.y / l };
|
||||
};
|
||||
|
||||
/** Existiert die Treppen-Kategorie („40")? Sonst Fallback auf die aktive. */
|
||||
function stairCategory(ctx: CommandContext): string {
|
||||
const flat: { code: string }[] = [];
|
||||
const walk = (list: { code: string; children?: unknown[] }[]) => {
|
||||
for (const c of list) {
|
||||
flat.push({ code: c.code });
|
||||
if (c.children) walk(c.children as { code: string; children?: unknown[] }[]);
|
||||
}
|
||||
};
|
||||
walk(ctx.project.layers as { code: string; children?: unknown[] }[]);
|
||||
return flat.some((c) => c.code === STAIR_CATEGORY)
|
||||
? STAIR_CATEGORY
|
||||
: ctx.defaultCategoryCode;
|
||||
}
|
||||
|
||||
/** Geschosshöhe als Default-Steighöhe (Fallback 2.6 m). */
|
||||
function floorRise(ctx: CommandContext): number {
|
||||
return ctx.level.floorHeight && ctx.level.floorHeight > 0 ? ctx.level.floorHeight : 2.6;
|
||||
}
|
||||
|
||||
/** Tab-Felder im Laufschritt: Breite / Stufenanzahl / Steighöhe. */
|
||||
const RUN_FIELDS: CommandField[] = [
|
||||
{ id: "width", labelKey: "cmd.field.width" },
|
||||
{ id: "steps", labelKey: "cmd.stair.stepsField" },
|
||||
{ id: "rise", labelKey: "cmd.stair.riseField" },
|
||||
];
|
||||
|
||||
const SHAPE: CmdOption = { id: "shape", labelKey: "cmd.stair.shape", value: "straight" };
|
||||
const UPDOWN: CmdOption = { id: "updown", labelKey: "cmd.stair.updown", value: "up" };
|
||||
|
||||
interface StairIdle extends CommandState {
|
||||
phase: "start";
|
||||
shape: StairShape;
|
||||
up: boolean;
|
||||
width: number;
|
||||
steps: number | null; // null = automatisch
|
||||
rise: number | null; // null = Geschosshöhe
|
||||
}
|
||||
interface StairRun extends CommandState {
|
||||
phase: "run";
|
||||
shape: StairShape;
|
||||
up: boolean;
|
||||
width: number;
|
||||
steps: number | null;
|
||||
rise: number | null;
|
||||
start: Vec2;
|
||||
cursor: Vec2;
|
||||
}
|
||||
type StairCmdState = StairIdle | StairRun;
|
||||
|
||||
function initState(shape: StairShape = "straight"): StairIdle {
|
||||
return { phase: "start", lastPoint: null, shape, up: true, width: DEF_WIDTH, steps: null, rise: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut aus dem Laufzustand ein konkretes `Stair`-Objekt (aufgelöste Felder). Die
|
||||
* Richtung/Länge folgen aus start→cursor; bei L bricht der zweite Lauf um 90°
|
||||
* (links) ab, bei Wendel wird der Cursorabstand zum Radius.
|
||||
*/
|
||||
function buildStair(s: StairRun, ctx: CommandContext): Stair {
|
||||
const rise = s.rise ?? floorRise(ctx);
|
||||
const d = sub(s.cursor, s.start);
|
||||
const dist = Math.max(0.1, lenOf(d));
|
||||
const dir = dist > EPS ? normv(d) : ({ x: 1, y: 0 } as MVec2);
|
||||
const runLength = s.shape === "spiral" ? Math.max(0.5, dist) : dist;
|
||||
const steps = s.steps ?? defaultStepCount(rise, runLength);
|
||||
const base: Stair = {
|
||||
id: uniqueId("ST"),
|
||||
type: "stair",
|
||||
floorId: ctx.level.id,
|
||||
categoryCode: stairCategory(ctx),
|
||||
shape: s.shape,
|
||||
start: s.start,
|
||||
dir,
|
||||
runLength,
|
||||
width: s.width,
|
||||
totalRise: rise,
|
||||
stepCount: steps,
|
||||
up: s.up,
|
||||
};
|
||||
if (s.shape === "L") {
|
||||
base.run2Length = runLength; // symmetrischer zweiter Lauf
|
||||
base.turn = 1;
|
||||
} else if (s.shape === "spiral") {
|
||||
base.center = s.start;
|
||||
base.radius = dist;
|
||||
base.sweep = 270;
|
||||
// Startpunkt am äußeren Rand: Lauflinie beginnt hier.
|
||||
base.start = s.cursor;
|
||||
base.dir = dir;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
/** Vorschau: Tritt-Linien + Lauflinie + HUD (Länge · Stufenanzahl). */
|
||||
function runDraft(s: StairRun, ctx: CommandContext): ToolDraft {
|
||||
const stair = buildStair(s, ctx);
|
||||
const rise = stair.totalRise ?? floorRise(ctx);
|
||||
const geo = stairGeometry(stair, rise);
|
||||
const preview: DraftShape[] = [];
|
||||
for (const tr of geo.treads) preview.push({ kind: "poly", pts: tr.pts, closed: true });
|
||||
if (geo.landing) preview.push({ kind: "poly", pts: geo.landing, closed: true });
|
||||
if (geo.runLine.length >= 2) preview.push({ kind: "poly", pts: geo.runLine, closed: false });
|
||||
const draft: ToolDraft = { preview, vertices: [s.start] };
|
||||
draft.hud = {
|
||||
at: s.cursor,
|
||||
text: `${lenOf(sub(s.cursor, s.start)).toFixed(2)} m · ${stair.stepCount} STG`,
|
||||
};
|
||||
return draft;
|
||||
}
|
||||
|
||||
/** Hängt eine Treppe ans Projekt (immutabel). */
|
||||
function appendStair(p: Project, s: StairRun, ctx: CommandContext): Project {
|
||||
const stair = buildStair(s, ctx);
|
||||
const stairs = p.stairs ? [...p.stairs, stair] : [stair];
|
||||
return { ...p, stairs };
|
||||
}
|
||||
|
||||
const idle = (from?: StairCmdState): [CommandState, CommandResult] => [
|
||||
initState(from?.shape ?? "straight"),
|
||||
{ draft: null, done: true },
|
||||
];
|
||||
|
||||
export const stairCommand: Command = {
|
||||
name: "stair",
|
||||
labelKey: "cmd.stair.label",
|
||||
floorOnly: true,
|
||||
prompt: (s) => ((s as StairCmdState).phase === "run" ? "cmd.stair.run" : "cmd.stair.start"),
|
||||
accepts: (s) =>
|
||||
(s as StairCmdState).phase === "run"
|
||||
? ["point", "number", "option"]
|
||||
: ["point", "option"],
|
||||
options: (s): CmdOption[] => {
|
||||
const st = s as StairCmdState;
|
||||
return [
|
||||
{ ...SHAPE, value: st.shape },
|
||||
{ ...UPDOWN, value: st.up ? "up" : "down" },
|
||||
];
|
||||
},
|
||||
init: (): StairIdle => initState("straight"),
|
||||
|
||||
onInput: (state, input, ctx): [CommandState, CommandResult] => {
|
||||
const s = state as StairCmdState;
|
||||
|
||||
if (input.kind === "option") {
|
||||
const next = { ...s } as StairCmdState;
|
||||
if (input.id === "shape") {
|
||||
next.shape = s.shape === "straight" ? "L" : s.shape === "L" ? "spiral" : "straight";
|
||||
} else if (input.id === "updown") {
|
||||
next.up = !s.up;
|
||||
}
|
||||
return [next, { draft: next.phase === "run" ? runDraft(next as StairRun, ctx) : null }];
|
||||
}
|
||||
|
||||
if (input.kind !== "point") {
|
||||
return [s, { draft: s.phase === "run" ? runDraft(s as StairRun, ctx) : null }];
|
||||
}
|
||||
|
||||
const pt = input.point;
|
||||
if (s.phase !== "run") {
|
||||
const ns: StairRun = {
|
||||
phase: "run",
|
||||
lastPoint: pt,
|
||||
shape: s.shape,
|
||||
up: s.up,
|
||||
width: s.width,
|
||||
steps: s.steps,
|
||||
rise: s.rise,
|
||||
start: pt,
|
||||
cursor: pt,
|
||||
};
|
||||
return [ns, { draft: null }];
|
||||
}
|
||||
// Zweiter Punkt: Lauf definieren + committen (Null-Strecke ignorieren).
|
||||
if (lenOf(sub(pt, s.start)) < 0.05) {
|
||||
return [{ ...s }, { draft: runDraft(s, ctx) }];
|
||||
}
|
||||
const ns: StairRun = { ...s, cursor: pt };
|
||||
return [
|
||||
idle(s)[0],
|
||||
{ draft: null, done: true, commit: (p) => appendStair(p, ns, ctx) },
|
||||
];
|
||||
},
|
||||
|
||||
onMove: (state, point, _snap, ctx): [CommandState, CommandResult] => {
|
||||
const s = state as StairCmdState;
|
||||
if (s.phase !== "run") return [s, { draft: null }];
|
||||
const ns: StairRun = { ...s, cursor: point };
|
||||
return [ns, { draft: runDraft(ns, ctx) }];
|
||||
},
|
||||
|
||||
onConfirm: (state, ctx): [CommandState, CommandResult] => {
|
||||
const s = state as StairCmdState;
|
||||
if (s.phase === "run" && lenOf(sub(s.cursor, s.start)) >= 0.05) {
|
||||
const ns = s;
|
||||
return [
|
||||
idle(s)[0],
|
||||
{ draft: null, done: true, commit: (p) => appendStair(p, ns, ctx) },
|
||||
];
|
||||
}
|
||||
return idle(s);
|
||||
},
|
||||
onCancel: (state): [CommandState, CommandResult] => idle(state as StairCmdState),
|
||||
|
||||
fields: (state) => ((state as StairCmdState).phase === "run" ? RUN_FIELDS : []),
|
||||
// Getippte Tab-Felder sticky in den State übernehmen; kein Koordinatenpunkt.
|
||||
pointFromFields: (state, locks) => {
|
||||
const s = state as StairCmdState;
|
||||
if ("width" in locks && locks.width > 0) s.width = locks.width;
|
||||
if ("steps" in locks && locks.steps >= 2) s.steps = Math.round(locks.steps);
|
||||
if ("rise" in locks && locks.rise > 0) s.rise = locks.rise;
|
||||
return null;
|
||||
},
|
||||
fieldValues: (state, _locks, _cursor): Record<string, number> => {
|
||||
const s = state as StairCmdState;
|
||||
const out: Record<string, number> = { width: s.width };
|
||||
if (s.steps != null) out.steps = s.steps;
|
||||
if (s.rise != null) out.rise = s.rise;
|
||||
return out;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,242 @@
|
||||
// Trim — Quick-Trim („stutzen") für 2D-Kurven (line/polyline/rect), Rhino/
|
||||
// AutoCAD-artig (docs §2.10/§3.4 Tier-1). Modell:
|
||||
// • Cutters sind IMPLIZIT alle ANDEREN Drawing2D-Elemente der aktiven Ebene
|
||||
// (Quick-Trim — kein separater Cutter-Auswahlschritt; üblich + einfacher).
|
||||
// • Ein Schritt: der Nutzer klickt auf den WEGZUSCHNEIDENDEN Teil einer Kurve.
|
||||
// Wir bestimmen das getroffene Element (Nähe), zerlegen es an seinen
|
||||
// Schnittpunkten mit allen Cuttern und ENTFERNEN den angeklickten Abschnitt
|
||||
// (zwischen den beiden nächstgelegenen Schnittpunkten um den Klick — bzw. bis
|
||||
// zum Element-Ende, wenn nur einseitig ein Schnitt liegt).
|
||||
// • Wiederholend, bis Esc/Enter.
|
||||
//
|
||||
// Linie → wird gekürzt/aufgeteilt; Polylinie → der getroffene Abschnitt fällt
|
||||
// raus (kann die Polylinie in Stücke teilen). Reine Geometrie liegt im Kernel
|
||||
// (`trimPolyline`); hier nur Treffer-Bestimmung, Drawing2D ↔ {pts,closed} und der
|
||||
// Commit. Wände sind ausgenommen (nur Drawing2D).
|
||||
//
|
||||
// Bezeichner englisch, Kommentare/UI deutsch (CONVENTIONS.md).
|
||||
|
||||
import type { Drawing2D, Drawing2DGeom } from "../../model/types";
|
||||
import { uniqueId } from "../../tools/types";
|
||||
import {
|
||||
pointSegmentDistance,
|
||||
polylineEdges,
|
||||
trimPolyline,
|
||||
vecEqual,
|
||||
} from "../../geometry/kernel2d";
|
||||
import type {
|
||||
Command,
|
||||
CommandResult,
|
||||
CommandState,
|
||||
DraftShape,
|
||||
Project,
|
||||
Vec2,
|
||||
} from "../types";
|
||||
|
||||
const HIT_TOL = 0.25; // Modell-Meter: Pick-Toleranz für die Kurvenwahl
|
||||
|
||||
// ── Kurven-Abstraktion: line/polyline/rect → Punktliste + closed ──────────────
|
||||
|
||||
interface Curve {
|
||||
pts: Vec2[];
|
||||
closed: boolean;
|
||||
}
|
||||
|
||||
/** Wandelt eine trim-fähige 2D-Form in eine Punktliste (+closed) oder null. */
|
||||
function geomToCurve(g: Drawing2DGeom): Curve | null {
|
||||
if (g.shape === "line") return { pts: [g.a, g.b], closed: false };
|
||||
if (g.shape === "polyline") return { pts: g.pts, closed: g.closed };
|
||||
if (g.shape === "rect") {
|
||||
return {
|
||||
pts: [
|
||||
g.min,
|
||||
{ x: g.max.x, y: g.min.y },
|
||||
g.max,
|
||||
{ x: g.min.x, y: g.max.y },
|
||||
],
|
||||
closed: true,
|
||||
};
|
||||
}
|
||||
return null; // circle/arc/text: hier nicht trim-fähig (offener Punkt)
|
||||
}
|
||||
|
||||
/** Ist diese Form trim-fähig (line/polyline/rect)? */
|
||||
function isTrimmable(d: Drawing2D): boolean {
|
||||
return (
|
||||
d.geom.shape === "line" ||
|
||||
d.geom.shape === "polyline" ||
|
||||
d.geom.shape === "rect"
|
||||
);
|
||||
}
|
||||
|
||||
/** Nächstes trim-fähiges Drawing2D der Ebene zum Klickpunkt (innerhalb Toleranz). */
|
||||
function pickCurveAt(
|
||||
project: Project,
|
||||
levelId: string,
|
||||
pt: Vec2,
|
||||
): Drawing2D | null {
|
||||
let best: Drawing2D | null = null;
|
||||
let bestD = HIT_TOL;
|
||||
for (const d of project.drawings2d) {
|
||||
if (d.levelId !== levelId) continue;
|
||||
if (!isTrimmable(d)) continue;
|
||||
const curve = geomToCurve(d.geom);
|
||||
if (!curve) continue;
|
||||
for (const [a, b] of polylineEdges(curve.pts, curve.closed)) {
|
||||
const dd = pointSegmentDistance(pt, a, b);
|
||||
if (dd < bestD) {
|
||||
bestD = dd;
|
||||
best = d;
|
||||
}
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Alle anderen trim-fähigen Drawing2D der Ebene als Cutter-Kurven. */
|
||||
function cuttersFor(
|
||||
project: Project,
|
||||
levelId: string,
|
||||
exceptId: string,
|
||||
): Curve[] {
|
||||
const out: Curve[] = [];
|
||||
for (const d of project.drawings2d) {
|
||||
if (d.id === exceptId) continue;
|
||||
if (d.levelId !== levelId) continue;
|
||||
const curve = geomToCurve(d.geom);
|
||||
if (curve) out.push(curve);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Sind zwei Ketten geometrisch identisch (gleiches closed + gleiche Punkte)? */
|
||||
function sameChain(a: { pts: Vec2[]; closed: boolean }, b: Curve): boolean {
|
||||
if (a.closed !== b.closed || a.pts.length !== b.pts.length) return false;
|
||||
for (let i = 0; i < a.pts.length; i++) {
|
||||
if (!vecEqual(a.pts[i], b.pts[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Eine getrimmte Rest-Kette als passende Drawing2D-Geometrie. */
|
||||
function chainToGeom(chain: { pts: Vec2[]; closed: boolean }): Drawing2DGeom {
|
||||
if (chain.pts.length === 2 && !chain.closed) {
|
||||
return { shape: "line", a: chain.pts[0], b: chain.pts[1] };
|
||||
}
|
||||
return { shape: "polyline", pts: chain.pts, closed: chain.closed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Wendet einen Trim auf das Projekt an: ersetzt das getroffene Element durch
|
||||
* seine Rest-Ketten (Attribute geerbt). Bleibt nichts übrig, fällt es weg.
|
||||
*/
|
||||
function applyTrim(
|
||||
p: Project,
|
||||
target: Drawing2D,
|
||||
pick: Vec2,
|
||||
): Project {
|
||||
const curve = geomToCurve(target.geom);
|
||||
if (!curve) return p;
|
||||
const cutters = cuttersFor(p, target.levelId, target.id).map((c) => ({
|
||||
pts: c.pts,
|
||||
closed: c.closed,
|
||||
}));
|
||||
const rest = trimPolyline(curve.pts, curve.closed, cutters, pick);
|
||||
// Echtes No-op: genau eine Kette, identisch (gleiche Punkte) zur Quelle →
|
||||
// nichts wurde weggeschnitten (kein Schnitt mit Cuttern).
|
||||
if (rest.length === 1 && sameChain(rest[0], curve)) return p;
|
||||
const replacements: Drawing2D[] = rest
|
||||
.filter((c) => c.pts.length >= 2)
|
||||
.map((c) => ({ ...target, id: uniqueId("dr2d"), geom: chainToGeom(c) }));
|
||||
const others = p.drawings2d.filter((d) => d.id !== target.id);
|
||||
return { ...p, drawings2d: [...others, ...replacements] };
|
||||
}
|
||||
|
||||
/** Vorschau-Formen der NACH dem Trim verbleibenden Ketten (für den Draft). */
|
||||
function trimPreview(
|
||||
project: Project,
|
||||
target: Drawing2D,
|
||||
pick: Vec2,
|
||||
): DraftShape[] {
|
||||
const curve = geomToCurve(target.geom);
|
||||
if (!curve) return [];
|
||||
const cutters = cuttersFor(project, target.levelId, target.id).map((c) => ({
|
||||
pts: c.pts,
|
||||
closed: c.closed,
|
||||
}));
|
||||
const rest = trimPolyline(curve.pts, curve.closed, cutters, pick);
|
||||
const out: DraftShape[] = [];
|
||||
for (const c of rest) {
|
||||
if (c.pts.length < 2) continue;
|
||||
if (c.pts.length === 2 && !c.closed) {
|
||||
out.push({ kind: "line", a: c.pts[0], b: c.pts[1] });
|
||||
} else {
|
||||
out.push({ kind: "poly", pts: c.pts, closed: c.closed });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Zustand ───────────────────────────────────────────────────────────────────
|
||||
// Einziger Schritt „pick": auf den wegzuschneidenden Abschnitt klicken;
|
||||
// wiederholend, bis Esc/Enter. „done": beendet.
|
||||
interface TrimPick extends CommandState {
|
||||
phase: "pick";
|
||||
}
|
||||
interface TrimDone extends CommandState {
|
||||
phase: "done";
|
||||
}
|
||||
type TrimState = TrimPick | TrimDone;
|
||||
|
||||
const finish = (): [CommandState, CommandResult] => [
|
||||
{ phase: "done", lastPoint: null } as TrimDone,
|
||||
{ draft: null, done: true },
|
||||
];
|
||||
|
||||
export const trimCommand: Command = {
|
||||
name: "trim",
|
||||
labelKey: "cmd.trim.label",
|
||||
prompt: () => "cmd.trim.pick",
|
||||
accepts: () => ["point"],
|
||||
options: () => [],
|
||||
|
||||
init: (): TrimPick => ({ phase: "pick", lastPoint: null }),
|
||||
|
||||
onInput: (state, input, ctx): [CommandState, CommandResult] => {
|
||||
const s = state as TrimState;
|
||||
if (s.phase === "done") return finish();
|
||||
if (input.kind !== "point") return [s, { draft: null }];
|
||||
const target = pickCurveAt(ctx.project, ctx.level.id, input.point);
|
||||
if (!target) {
|
||||
// Daneben geklickt → Befehl bleibt aktiv (weiter trimmen).
|
||||
return [s, { draft: null }];
|
||||
}
|
||||
const pick = input.point;
|
||||
// Wiederholend: NICHT done — nach dem Commit bleibt „pick" aktiv.
|
||||
return [
|
||||
s,
|
||||
{ draft: null, commit: (p) => applyTrim(p, target, pick) },
|
||||
];
|
||||
},
|
||||
|
||||
onMove: (state, point, _snap, ctx): [CommandState, CommandResult] => {
|
||||
const s = state as TrimState;
|
||||
if (s.phase !== "pick") return [s, { draft: null }];
|
||||
const target = pickCurveAt(ctx.project, ctx.level.id, point);
|
||||
if (!target) return [s, { draft: null }];
|
||||
const preview = trimPreview(ctx.project, target, point);
|
||||
return [
|
||||
s,
|
||||
{
|
||||
draft: {
|
||||
preview,
|
||||
vertices: [],
|
||||
hud: { at: point, text: "✂" },
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
onConfirm: (): [CommandState, CommandResult] => finish(),
|
||||
onCancel: (): [CommandState, CommandResult] => finish(),
|
||||
};
|
||||
@@ -0,0 +1,350 @@
|
||||
// Wall — Wand als echter Engine-Befehl (analog polyline, aber je Achssegment
|
||||
// entsteht ein eigenständiges `Wall`). Schritte:
|
||||
// 1) „Startpunkt der Wand:" → Punkt
|
||||
// 2) „Nächster Punkt ( Zurück ):" → Punkt … (chainend: jede weitere Wand
|
||||
// beginnt am letzten Endpunkt) bis Esc/Enter den Zug beendet.
|
||||
//
|
||||
// Akzeptiert je Punkt Maus-Picks UND getippte Koordinaten (0,0 · r3,0 · 5<45) —
|
||||
// derselbe Eingabepfad wie Linie/Polylinie. Die fertige Wand rendert über die
|
||||
// bestehende Wand-Darstellung (generatePlan / 3D); der Draft zeigt die Achse +
|
||||
// eine einfache Band-Vorschau in der Dicke des aktiven Wandtyps.
|
||||
//
|
||||
// Herkunft der Wand-Felder (CommandContext):
|
||||
// • wallTypeId ← ctx.activeWallTypeId (Fallback: project.wallTypes[0].id)
|
||||
// • height ← aktives Geschoss (ctx.level.floorHeight, sonst 2.6 m)
|
||||
// • floorId ← ctx.level.id (aktives Geschoss)
|
||||
// • categoryCode← ctx.defaultCategoryCode (aktive Kategorie, i. d. R. „20")
|
||||
|
||||
import type { Wall, WallType } from "../../model/types";
|
||||
import { wallTypeThickness } from "../../model/types";
|
||||
import { wallCorners } from "../../model/geometry";
|
||||
import { uniqueId } from "../../tools/types";
|
||||
import type {
|
||||
Command,
|
||||
CommandContext,
|
||||
CommandField,
|
||||
CommandResult,
|
||||
CommandState,
|
||||
CmdOption,
|
||||
DraftShape,
|
||||
Project,
|
||||
ToolDraft,
|
||||
Vec2,
|
||||
} from "../types";
|
||||
|
||||
const EPS = 1e-6;
|
||||
const DEG = Math.PI / 180;
|
||||
const DEFAULT_HEIGHT = 2.6; // Fallback-Wandhöhe (Meter), wenn das Geschoss keine hat
|
||||
const DEFAULT_THICKNESS = 0.2; // Fallback-Banddicke für die Vorschau (Meter)
|
||||
const segLen = (a: Vec2, b: Vec2): number => Math.hypot(b.x - a.x, b.y - a.y);
|
||||
const segAngleDeg = (a: Vec2, b: Vec2): number =>
|
||||
(Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI;
|
||||
|
||||
const MIN_THICKNESS = 0.01; // kleinste sinnvolle Wanddicke (Meter)
|
||||
|
||||
/** Tab-Felder je weiteren Punkt: Länge + Winkel relativ zum letzten Punkt. */
|
||||
const WALL_FIELDS: CommandField[] = [
|
||||
{ id: "length", labelKey: "cmd.field.length" },
|
||||
{ id: "angle", labelKey: "cmd.field.angle" },
|
||||
];
|
||||
/** Zusätzliches Dicke-Feld (nur bei einschichtigen Wandtypen). */
|
||||
const THICK_FIELD: CommandField = { id: "thick", labelKey: "cmd.field.thickness" };
|
||||
|
||||
/** Nächster Punkt aus gelockten Feldern (length/angle) + Cursor, relativ zu `last`. */
|
||||
function wallNextFromFields(last: Vec2, locks: Record<string, number>, cursor: Vec2 | null): Vec2 {
|
||||
const ref = cursor ?? last;
|
||||
const length = "length" in locks ? locks.length : segLen(last, ref);
|
||||
const angleDeg = "angle" in locks ? locks.angle : segAngleDeg(last, ref);
|
||||
const ang = angleDeg * DEG;
|
||||
return { x: last.x + Math.cos(ang) * length, y: last.y + Math.sin(ang) * length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Dicke des aktiven Wandtyps (für die Band-Vorschau). Liest `ctx.activeWallTypeId`;
|
||||
* fehlt der Typ, Fallback auf den ersten Projekt-Wandtyp bzw. DEFAULT_THICKNESS.
|
||||
*/
|
||||
function activeWallThickness(ctx: CommandContext): number {
|
||||
const wt =
|
||||
ctx.project.wallTypes.find((t) => t.id === ctx.activeWallTypeId) ??
|
||||
ctx.project.wallTypes[0];
|
||||
return wt ? wallTypeThickness(wt) : DEFAULT_THICKNESS;
|
||||
}
|
||||
|
||||
/** Aktives Wandtyp-Objekt (Fallback: erster Projekt-Wandtyp). */
|
||||
function activeWallType(ctx: CommandContext): WallType | undefined {
|
||||
return (
|
||||
ctx.project.wallTypes.find((t) => t.id === ctx.activeWallTypeId) ??
|
||||
ctx.project.wallTypes[0]
|
||||
);
|
||||
}
|
||||
|
||||
/** Aktiver Wandtyp-ID; Fallback auf den ersten Projekt-Wandtyp. */
|
||||
function activeWallTypeId(ctx: CommandContext): string {
|
||||
const wt = activeWallType(ctx);
|
||||
return wt ? wt.id : ctx.activeWallTypeId;
|
||||
}
|
||||
|
||||
/** Einschichtig? Nur dann ist das Dicke-Feld sinnvoll (überschreibt die Schicht). */
|
||||
function isSingleLayer(ctx: CommandContext): boolean {
|
||||
const wt = activeWallType(ctx);
|
||||
return !!wt && wt.layers.length === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert die effektive Vorschau-/Commit-Dicke: eine gelockte `thick`-Eingabe
|
||||
* (nur bei einschichtigen Typen) überschreibt die Wandtyp-Dicke.
|
||||
*/
|
||||
function effectiveThickness(ctx: CommandContext, override: number | null): number {
|
||||
if (override != null && override >= MIN_THICKNESS && isSingleLayer(ctx)) return override;
|
||||
return activeWallThickness(ctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* Findet einen einschichtigen Wandtyp mit gegebener Dicke (gleiches Bauteil wie
|
||||
* `base`) oder erzeugt ihn. Gibt das (evtl. erweiterte) Projekt + die Typ-ID
|
||||
* zurück — so bleibt die Plan-/3D-Darstellung unverändert (sie liest die Dicke
|
||||
* aus den Schichten des Wandtyps).
|
||||
*/
|
||||
function wallTypeForThickness(
|
||||
p: Project,
|
||||
base: WallType,
|
||||
thickness: number,
|
||||
): { project: Project; wallTypeId: string } {
|
||||
const componentId = base.layers[0].componentId;
|
||||
const near = (a: number, b: number) => Math.abs(a - b) < 1e-4;
|
||||
const existing = p.wallTypes.find(
|
||||
(t) =>
|
||||
t.layers.length === 1 &&
|
||||
t.layers[0].componentId === componentId &&
|
||||
near(t.layers[0].thickness, thickness),
|
||||
);
|
||||
if (existing) return { project: p, wallTypeId: existing.id };
|
||||
const nt: WallType = {
|
||||
id: uniqueId("WT"),
|
||||
name: `${base.name} ${(thickness * 100).toFixed(0)} cm`,
|
||||
layers: [{ componentId, thickness }],
|
||||
};
|
||||
return { project: { ...p, wallTypes: [...p.wallTypes, nt] }, wallTypeId: nt.id };
|
||||
}
|
||||
|
||||
interface WallIdle extends CommandState {
|
||||
phase: "start";
|
||||
/** Sticky Dicke-Übersteuerung (nur einschichtig); null = Wandtyp-Dicke. */
|
||||
thickOverride: number | null;
|
||||
/** Ist der aktive Wandtyp einschichtig? (aus ctx in onMove/onInput gesetzt) */
|
||||
single: boolean;
|
||||
}
|
||||
interface WallDrawing extends CommandState {
|
||||
phase: "next";
|
||||
points: Vec2[];
|
||||
cursor: Vec2 | null;
|
||||
/** Sticky Dicke-Übersteuerung (nur einschichtig); null = Wandtyp-Dicke. */
|
||||
thickOverride: number | null;
|
||||
/** Ist der aktive Wandtyp einschichtig? (aus ctx in onMove/onInput gesetzt) */
|
||||
single: boolean;
|
||||
}
|
||||
type WallCmdState = WallIdle | WallDrawing;
|
||||
|
||||
const UNDO: CmdOption = { id: "undo", labelKey: "cmd.polyline.undo" };
|
||||
|
||||
/**
|
||||
* Vorschau: Achs-Polylinie + Band-Vorschau je Segment (Dicke = aktiver Wandtyp) +
|
||||
* HUD (Länge·Winkel des lebenden Segments). Die FERTIGE Wand rendert über die
|
||||
* bestehende Wand-Darstellung — hier ist nur der Rubber-Band-Entwurf.
|
||||
*/
|
||||
function wallDraft(points: Vec2[], cursor: Vec2 | null, thickness: number): ToolDraft {
|
||||
const all = cursor ? [...points, cursor] : points;
|
||||
const preview: DraftShape[] = [];
|
||||
for (let i = 0; i < all.length - 1; i++) {
|
||||
const a = all[i];
|
||||
const b = all[i + 1];
|
||||
if (segLen(a, b) < EPS) continue;
|
||||
preview.push({ kind: "poly", pts: wallCorners(a, b, thickness), closed: true });
|
||||
preview.push({ kind: "line", a, b }); // Achse
|
||||
}
|
||||
const draft: ToolDraft = { preview, vertices: points };
|
||||
const last = points[points.length - 1];
|
||||
if (cursor && last && segLen(last, cursor) >= EPS) {
|
||||
draft.hud = {
|
||||
at: cursor,
|
||||
text: `${segLen(last, cursor).toFixed(2)} m · ${Math.abs(
|
||||
(segAngleDeg(last, cursor) + 360) % 360,
|
||||
).toFixed(0)}°`,
|
||||
};
|
||||
}
|
||||
return draft;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hängt je Achssegment ein `Wall` des aktiven Typs ans Projekt (immutabel). Höhe
|
||||
* aus dem aktiven Geschoss (floorHeight), Geschoss + Kategorie + Wandtyp aus dem
|
||||
* Context. Null-Strecken werden übersprungen. Gehrung an Ecken entsteht später
|
||||
* automatisch aus computeJoins (generatePlan).
|
||||
*/
|
||||
function appendWalls(
|
||||
p: Project,
|
||||
pts: Vec2[],
|
||||
ctx: CommandContext,
|
||||
thickOverride: number | null,
|
||||
): Project {
|
||||
const height =
|
||||
ctx.level.floorHeight && ctx.level.floorHeight > 0 ? ctx.level.floorHeight : DEFAULT_HEIGHT;
|
||||
// Dicke-Übersteuerung (nur einschichtig): passenden Wandtyp finden/erzeugen,
|
||||
// damit Plan/3D — die die Dicke aus dem Wandtyp lesen — korrekt darstellen.
|
||||
let project = p;
|
||||
let wallTypeId = activeWallTypeId(ctx);
|
||||
const base = activeWallType(ctx);
|
||||
if (
|
||||
base &&
|
||||
base.layers.length === 1 &&
|
||||
thickOverride != null &&
|
||||
thickOverride >= MIN_THICKNESS &&
|
||||
Math.abs(thickOverride - wallTypeThickness(base)) > 1e-4
|
||||
) {
|
||||
const r = wallTypeForThickness(project, base, thickOverride);
|
||||
project = r.project;
|
||||
wallTypeId = r.wallTypeId;
|
||||
}
|
||||
const newWalls: Wall[] = [];
|
||||
for (let i = 0; i < pts.length - 1; i++) {
|
||||
const a = pts[i];
|
||||
const b = pts[i + 1];
|
||||
if (segLen(a, b) < EPS) continue;
|
||||
newWalls.push({
|
||||
id: uniqueId("W"),
|
||||
type: "wall",
|
||||
floorId: ctx.level.id,
|
||||
categoryCode: ctx.defaultCategoryCode,
|
||||
start: a,
|
||||
end: b,
|
||||
wallTypeId,
|
||||
height,
|
||||
});
|
||||
}
|
||||
if (newWalls.length === 0) return p;
|
||||
return { ...project, walls: [...project.walls, ...newWalls] };
|
||||
}
|
||||
|
||||
const idle = (
|
||||
thickOverride: number | null = null,
|
||||
single = false,
|
||||
): [CommandState, CommandResult] => [
|
||||
{ phase: "start", lastPoint: null, thickOverride, single } as WallIdle,
|
||||
{ draft: null, done: true },
|
||||
];
|
||||
|
||||
export const wallCommand: Command = {
|
||||
name: "wall",
|
||||
labelKey: "cmd.wall.label",
|
||||
// Wände leben auf Geschossen — wie das Legacy-Werkzeug nur dort aktiv.
|
||||
floorOnly: true,
|
||||
prompt: (s) => ((s as WallCmdState).phase === "next" ? "cmd.wall.next" : "cmd.wall.start"),
|
||||
accepts: (s) =>
|
||||
(s as WallCmdState).phase === "next" ? ["point", "number", "option"] : ["point", "number"],
|
||||
options: (s) => {
|
||||
const ps = s as WallCmdState;
|
||||
return ps.phase === "next" ? [UNDO] : [];
|
||||
},
|
||||
init: (): WallIdle => ({ phase: "start", lastPoint: null, thickOverride: null, single: false }),
|
||||
|
||||
onInput: (state, input, ctx): [CommandState, CommandResult] => {
|
||||
const s = state as WallCmdState;
|
||||
const thickness = effectiveThickness(ctx, s.thickOverride);
|
||||
const to = s.thickOverride;
|
||||
const single = isSingleLayer(ctx);
|
||||
|
||||
if (input.kind === "option") {
|
||||
if (s.phase !== "next") return [s, { draft: null }];
|
||||
if (input.id === "undo") {
|
||||
const pts = s.points.slice(0, -1);
|
||||
if (pts.length === 0)
|
||||
return [{ phase: "start", lastPoint: null, thickOverride: to, single } as WallIdle, { draft: null }];
|
||||
const ns: WallDrawing = {
|
||||
phase: "next",
|
||||
points: pts,
|
||||
cursor: s.cursor,
|
||||
lastPoint: pts[pts.length - 1],
|
||||
thickOverride: to,
|
||||
single,
|
||||
};
|
||||
return [ns, { draft: wallDraft(pts, s.cursor, thickness) }];
|
||||
}
|
||||
return [{ ...s, single } as WallCmdState, { draft: wallDraft(s.points, s.cursor, thickness) }];
|
||||
}
|
||||
|
||||
if (input.kind !== "point") {
|
||||
return [{ ...s, single } as WallCmdState, { draft: s.phase === "next" ? wallDraft(s.points, s.cursor, thickness) : null }];
|
||||
}
|
||||
const pt = input.point;
|
||||
if (s.phase !== "next") {
|
||||
const ns: WallDrawing = { phase: "next", points: [pt], cursor: pt, lastPoint: pt, thickOverride: to, single };
|
||||
return [ns, { draft: wallDraft([pt], pt, thickness) }];
|
||||
}
|
||||
// Chaining: jede weitere Wand beginnt am letzten Endpunkt. Null-Strecke
|
||||
// (Klick auf denselben Punkt) ignorieren — nicht abbrechen.
|
||||
const last = s.points[s.points.length - 1];
|
||||
if (segLen(last, pt) < EPS) {
|
||||
return [{ ...s, single } as WallCmdState, { draft: wallDraft(s.points, s.cursor, thickness) }];
|
||||
}
|
||||
const points = [...s.points, pt];
|
||||
const ns: WallDrawing = { phase: "next", points, cursor: pt, lastPoint: pt, thickOverride: to, single };
|
||||
return [ns, { draft: wallDraft(points, pt, thickness) }];
|
||||
},
|
||||
|
||||
onMove: (state, point, _snap, ctx): [CommandState, CommandResult] => {
|
||||
const s = state as WallCmdState;
|
||||
if (s.phase !== "next") return [s, { draft: null }];
|
||||
const ns: WallDrawing = { ...s, cursor: point, single: isSingleLayer(ctx) };
|
||||
return [ns, { draft: wallDraft(s.points, point, effectiveThickness(ctx, s.thickOverride)) }];
|
||||
},
|
||||
|
||||
// Enter/Space/Rechtsklick: offenen Zug beenden (≥2 Punkte) und committen.
|
||||
onConfirm: (state, ctx): [CommandState, CommandResult] => {
|
||||
const s = state as WallCmdState;
|
||||
if (s.phase === "next" && s.points.length >= 2) {
|
||||
const pts = s.points;
|
||||
const to = s.thickOverride;
|
||||
return [
|
||||
{ phase: "start", lastPoint: null, thickOverride: to, single: s.single } as WallIdle,
|
||||
{ draft: null, done: true, commit: (p) => appendWalls(p, pts, ctx, to) },
|
||||
];
|
||||
}
|
||||
return idle(s.thickOverride, s.single);
|
||||
},
|
||||
onCancel: (state): [CommandState, CommandResult] => {
|
||||
const s = state as WallCmdState;
|
||||
return idle(s.thickOverride, s.single);
|
||||
},
|
||||
|
||||
// Tab-Feld-Zyklus im „next"-Schritt: Länge + Winkel relativ zum letzten Punkt.
|
||||
// Bei EINSCHICHTIGEN Wandtypen zusätzlich ein Dicke-Feld (überschreibt die
|
||||
// Bandbreite live + auf Commit). Mehrschichtige Typen: kein Dicke-Feld
|
||||
// (der aktive Wandtyp wird in onMove/onInput aus dem Context ermittelt).
|
||||
fields: (state) => {
|
||||
const s = state as WallCmdState;
|
||||
if (s.phase !== "next") return [];
|
||||
return s.single ? [...WALL_FIELDS, THICK_FIELD] : WALL_FIELDS;
|
||||
},
|
||||
pointFromFields: (state, locks, cursor) => {
|
||||
const s = state as WallCmdState;
|
||||
// Gelockte Dicke sticky in den State übernehmen (kein Koordinatenfeld):
|
||||
// die Engine ruft diese Funktion bei jeder Maus-/Pick-/Enter-Aktion, bevor
|
||||
// sie den Punkt an onInput reicht — so kennt der Commit die Dicke.
|
||||
if ("thick" in locks && locks.thick >= MIN_THICKNESS) s.thickOverride = locks.thick;
|
||||
if (s.phase !== "next" || s.points.length === 0) return null;
|
||||
return wallNextFromFields(s.points[s.points.length - 1], locks, cursor);
|
||||
},
|
||||
fieldValues: (state, locks, cursor): Record<string, number> => {
|
||||
const s = state as WallCmdState;
|
||||
if (s.phase !== "next") return {};
|
||||
const out: Record<string, number> = {};
|
||||
// Dicke-Feld: gelockt → Lock, sonst die aktuelle (sticky) Übersteuerung.
|
||||
if ("thick" in locks) out.thick = locks.thick;
|
||||
else if (s.thickOverride != null) out.thick = s.thickOverride;
|
||||
if (s.points.length === 0 || !cursor) return out;
|
||||
const last = s.points[s.points.length - 1];
|
||||
out.length = segLen(last, cursor);
|
||||
out.angle = ((segAngleDeg(last, cursor) % 360) + 360) % 360;
|
||||
return out;
|
||||
},
|
||||
};
|
||||
+65
-21
@@ -29,11 +29,21 @@ import type {
|
||||
* Brücke zur App: liefert den Live-Kontext + Snapping und wendet die Ergebnisse
|
||||
* an (Vorschau zeigen, committen). So bleibt die Engine frei von React/Store.
|
||||
*/
|
||||
/** Modifikatoren (Shift=Ortho, Ctrl=Snap aus) beim Snap eines Befehlsschritts. */
|
||||
export interface EngineMods {
|
||||
shift: boolean;
|
||||
ctrl: boolean;
|
||||
}
|
||||
|
||||
export interface EngineHost {
|
||||
/** Aktueller Befehls-Kontext (Projekt, aktive Ebene/Kategorie, lastPoint). */
|
||||
context(lastPoint: Vec2 | null): CommandContext;
|
||||
/** Snap für einen rohen Cursor-Punkt (oder null), inkl. der Draft-Punkte. */
|
||||
snap(raw: Vec2, draftPoints: Vec2[]): SnapResult | null;
|
||||
/**
|
||||
* Snap für einen rohen Cursor-Punkt (oder null), inkl. der Draft-Punkte. `mods`
|
||||
* trägt Shift (Ortho) / Ctrl (Snap aus) durch — auch der ERSTE Punkt (ohne
|
||||
* Draft) wird so gesnappt bzw. per Ctrl unterdrückt.
|
||||
*/
|
||||
snap(raw: Vec2, draftPoints: Vec2[], mods?: EngineMods): SnapResult | null;
|
||||
/** Aktuelle Pixel/Meter-Skala (für Snap-Toleranz). */
|
||||
pxPerMeter(): number;
|
||||
/** Vorschau setzen (oder löschen). */
|
||||
@@ -57,10 +67,17 @@ export interface EngineView {
|
||||
/**
|
||||
* Geordnete Zahlenfelder des aktuellen Schritts (Tab-Feld-Zyklus §2.7). Leer,
|
||||
* wenn der aktive Befehl/Schritt keine Felder anbietet. `value` ist der
|
||||
* gelockte Wert (oder null = folgt der Maus), `active` markiert das aktuelle
|
||||
* Tab-Ziel.
|
||||
* gelockte Wert ODER — wenn ungelockt — der LIVE-Wert unter der Maus (oder null,
|
||||
* wenn (noch) keiner bestimmbar ist). `locked` unterscheidet beide für die UI;
|
||||
* `active` markiert das aktuelle Tab-Ziel.
|
||||
*/
|
||||
fields: { id: string; labelKey: string; value: number | null; active: boolean }[];
|
||||
fields: {
|
||||
id: string;
|
||||
labelKey: string;
|
||||
value: number | null;
|
||||
locked: boolean;
|
||||
active: boolean;
|
||||
}[];
|
||||
}
|
||||
|
||||
export class CommandEngine {
|
||||
@@ -104,12 +121,26 @@ export class CommandEngine {
|
||||
}
|
||||
this.syncFields();
|
||||
const defs = this.currentFields();
|
||||
const fields = defs.map((f, i) => ({
|
||||
id: f.id,
|
||||
labelKey: f.labelKey as string,
|
||||
value: f.id in this.locks ? this.locks[f.id] : null,
|
||||
active: i === this.activeFieldIndex,
|
||||
}));
|
||||
// Live-Werte (Maus-Vorschau) für ungelockte Felder, falls der Befehl sie
|
||||
// liefert — so zeigt die Befehlszeile beim Zeichnen die folgende Länge/Winkel.
|
||||
const live = this.command.fieldValues
|
||||
? this.command.fieldValues(this.state, this.locks, this.lastCursor)
|
||||
: {};
|
||||
const fields = defs.map((f, i) => {
|
||||
const locked = f.id in this.locks;
|
||||
const value = locked
|
||||
? this.locks[f.id]
|
||||
: f.id in live
|
||||
? live[f.id]
|
||||
: null;
|
||||
return {
|
||||
id: f.id,
|
||||
labelKey: f.labelKey as string,
|
||||
value,
|
||||
locked,
|
||||
active: i === this.activeFieldIndex,
|
||||
};
|
||||
});
|
||||
return {
|
||||
active: true,
|
||||
promptKey: this.command.prompt(this.state),
|
||||
@@ -224,6 +255,13 @@ export class CommandEngine {
|
||||
this.lastCursor = null;
|
||||
this.resetFields();
|
||||
this.host.setDraft(null);
|
||||
// Sofort-Befehl (Join/Split u. Ä.): direkt ausführen, ohne auf einen Pick zu
|
||||
// warten. So sind Tastenkürzel und getippter Befehl EIN Pfad (§Task C).
|
||||
if (cmd.autoRun) {
|
||||
const res = cmd.autoRun(this.context());
|
||||
this.applyResult(res);
|
||||
return true;
|
||||
}
|
||||
this.emit();
|
||||
return true;
|
||||
}
|
||||
@@ -324,10 +362,10 @@ export class CommandEngine {
|
||||
}
|
||||
|
||||
/** Maus-Pick aus PlanView (linker Klick) — gesnappter Modellpunkt. */
|
||||
pick(raw: Vec2): void {
|
||||
pick(raw: Vec2, mods?: EngineMods): void {
|
||||
if (!this.command || !this.state) return;
|
||||
if (!this.command.accepts(this.state).includes("point")) return;
|
||||
const snap = this.host.snap(raw, this.draftPoints());
|
||||
const snap = this.host.snap(raw, this.draftPoints(), mods);
|
||||
const point = snap?.point ?? raw;
|
||||
this.lastCursor = point;
|
||||
// Tab-Feld-Modus: gelockte Felder überschreiben den Klick (z. B. Länge fix,
|
||||
@@ -341,9 +379,9 @@ export class CommandEngine {
|
||||
}
|
||||
|
||||
/** Hover/Drag aus PlanView — nur Vorschau (kein Commit). */
|
||||
move(raw: Vec2): void {
|
||||
move(raw: Vec2, mods?: EngineMods): void {
|
||||
if (!this.command || !this.state) return;
|
||||
const snap = this.host.snap(raw, this.draftPoints());
|
||||
const snap = this.host.snap(raw, this.draftPoints(), mods);
|
||||
const point = snap?.point ?? raw;
|
||||
this.lastCursor = point;
|
||||
const ctx = this.context();
|
||||
@@ -379,12 +417,18 @@ export class CommandEngine {
|
||||
this.repeatLast();
|
||||
return;
|
||||
}
|
||||
// Tab-Feld-Modus (§2.7): existieren Felder, committet Enter den Schritt mit
|
||||
// dem aus (Locks + Cursor) gebildeten Punkt — statt den Befehl abzubrechen.
|
||||
const fieldPt = this.fieldResultPoint(this.lastCursor);
|
||||
if (fieldPt) {
|
||||
this.feed({ kind: "point", point: fieldPt });
|
||||
return;
|
||||
// Tab-Feld-Modus (§2.7): ist ein Feld GELOCKT (getippte Länge/Winkel/Dicke),
|
||||
// committet Enter den Schritt mit dem aus (Locks + Cursor) gebildeten Punkt —
|
||||
// statt den Befehl zu beenden. OHNE Lock ist Enter/Rechtsklick das normale
|
||||
// Bestätigen: mehrteilige Befehle (Wand/Polylinie) beenden ihren Zug über
|
||||
// onConfirm (sonst feuerte die Feld-Auflösung einen Cursor-Punkt und der Zug
|
||||
// liesse sich nie abschliessen).
|
||||
if (Object.keys(this.locks).length > 0) {
|
||||
const fieldPt = this.fieldResultPoint(this.lastCursor);
|
||||
if (fieldPt) {
|
||||
this.feed({ kind: "point", point: fieldPt });
|
||||
return;
|
||||
}
|
||||
}
|
||||
const ctx = this.context();
|
||||
const [st, res] = this.command.onConfirm(this.state, ctx);
|
||||
|
||||
@@ -6,23 +6,41 @@
|
||||
import type { Command } from "./types";
|
||||
import { lineCommand } from "./cmds/line";
|
||||
import { polylineCommand } from "./cmds/polyline";
|
||||
import { wallCommand } from "./cmds/wall";
|
||||
import { ceilingCommand } from "./cmds/ceiling";
|
||||
import { openingCommand, fensterCommand, tuerCommand } from "./cmds/opening";
|
||||
import { stairCommand } from "./cmds/stair";
|
||||
import { roomCommand } from "./cmds/room";
|
||||
import { rectCommand } from "./cmds/rect";
|
||||
import { circleCommand } from "./cmds/circle";
|
||||
import { moveCommand } from "./cmds/move";
|
||||
import { mirrorCommand } from "./cmds/mirror";
|
||||
import { joinCommand } from "./cmds/join";
|
||||
import { copyCommand } from "./cmds/copy";
|
||||
import { offsetCommand } from "./cmds/offset";
|
||||
import { trimCommand } from "./cmds/trim";
|
||||
import { importCommand } from "./cmds/import";
|
||||
import { terrainCommand } from "./cmds/terrain";
|
||||
|
||||
/** Alle bekannten Befehle, Schlüssel = Befehlsname (lowercase). */
|
||||
export const COMMANDS: Record<string, Command> = {
|
||||
wall: wallCommand,
|
||||
ceiling: ceilingCommand,
|
||||
opening: openingCommand,
|
||||
fenster: fensterCommand,
|
||||
tuer: tuerCommand,
|
||||
stair: stairCommand,
|
||||
room: roomCommand,
|
||||
line: lineCommand,
|
||||
polyline: polylineCommand,
|
||||
rect: rectCommand,
|
||||
circle: circleCommand,
|
||||
move: moveCommand,
|
||||
mirror: mirrorCommand,
|
||||
join: joinCommand,
|
||||
copy: copyCommand,
|
||||
offset: offsetCommand,
|
||||
trim: trimCommand,
|
||||
import: importCommand,
|
||||
terrain: terrainCommand,
|
||||
};
|
||||
@@ -33,13 +51,30 @@ export const COMMANDS: Record<string, Command> = {
|
||||
* relativ-Koordinaten-Präfix `r5,3`).
|
||||
*/
|
||||
export const ALIASES: Record<string, string> = {
|
||||
w: "wall",
|
||||
wand: "wall",
|
||||
d: "ceiling",
|
||||
decke: "ceiling",
|
||||
f: "fenster",
|
||||
t: "tuer",
|
||||
tür: "tuer",
|
||||
oe: "opening",
|
||||
treppe: "stair",
|
||||
tp: "stair",
|
||||
raum: "room",
|
||||
rm: "room",
|
||||
l: "line",
|
||||
pl: "polyline",
|
||||
rec: "rect",
|
||||
c: "circle",
|
||||
m: "move",
|
||||
s: "mirror",
|
||||
spiegeln: "mirror",
|
||||
j: "join",
|
||||
verbinden: "join",
|
||||
cp: "copy",
|
||||
o: "offset",
|
||||
tr: "trim",
|
||||
imp: "import",
|
||||
ter: "terrain",
|
||||
};
|
||||
|
||||
@@ -60,6 +60,13 @@ export interface CmdOption {
|
||||
export interface CommandSelection {
|
||||
wallIds: string[];
|
||||
drawingId: string | null;
|
||||
/**
|
||||
* ALLE gewählten 2D-Elemente (Mehrfachauswahl). `drawingId` bleibt das einzeln
|
||||
* selektierte Element (Move/Copy/Mirror operieren darauf); Mengen-Aktionen wie
|
||||
* Join/Split lesen `drawingIds`. Optional/abwärtskompatibel: fehlt es, gilt
|
||||
* `drawingId` (falls gesetzt) als einziges Element.
|
||||
*/
|
||||
drawingIds?: string[];
|
||||
}
|
||||
|
||||
/** Live-Kontext, den ein Befehl bei jedem Schritt erhält (analog ToolContext). */
|
||||
@@ -71,6 +78,11 @@ export interface CommandContext {
|
||||
defaultCategoryCode: string;
|
||||
/** Aktiver Linienstil-Code für 2D-Primitive. */
|
||||
activeLineStyleId: string;
|
||||
/**
|
||||
* Aktiver Wandtyp (für den `wall`-Befehl). Liefert Schichtaufbau + Dicke neuer
|
||||
* Wände. Aus dem Store gefüllt (analog ToolContext.activeWallTypeId).
|
||||
*/
|
||||
activeWallTypeId: string;
|
||||
/**
|
||||
* Letzter gesetzter Punkt im laufenden Befehl (für `r`/polar-Auflösung); null
|
||||
* am ersten Punkt.
|
||||
@@ -158,6 +170,17 @@ export interface Command {
|
||||
/** Esc: Entwurf verwerfen, Befehl beenden. */
|
||||
onCancel(state: CommandState): [CommandState, CommandResult];
|
||||
|
||||
/**
|
||||
* Sofort-Befehl (optional): läuft direkt beim Start OHNE Punkt-Picken und liefert
|
||||
* sein Ergebnis (i. d. R. commit + done). Für Aktionen auf der bestehenden
|
||||
* Auswahl (z. B. Join/Split), die keine interaktive Geste brauchen. Ist diese
|
||||
* Methode gesetzt, ruft die Engine sie unmittelbar nach `init()` auf und der
|
||||
* Befehl kehrt (bei done) sofort in den Ruhezustand zurück. So ist „Ctrl+J" und
|
||||
* getipptes „join" derselbe Pfad. Fehlt sie, verhält sich der Befehl wie gehabt
|
||||
* (interaktiv, Punkt-getrieben).
|
||||
*/
|
||||
autoRun?(ctx: CommandContext): CommandResult;
|
||||
|
||||
// ── Optionaler Tab-Feld-Zyklus (additiv; Befehle ohne diese Methoden
|
||||
// verhalten sich exakt wie bisher) ──────────────────────────────────────
|
||||
|
||||
@@ -177,6 +200,19 @@ export interface Command {
|
||||
locks: Record<string, number>,
|
||||
cursor: Vec2 | null,
|
||||
): Vec2 | null;
|
||||
|
||||
/**
|
||||
* LIVE-Zahlenwerte je Feld für die aktuelle Cursor-Position (Maus-Vorschau in
|
||||
* der Befehlszeile). Gelockte Felder behalten ihren Lock; ungelockte Felder
|
||||
* folgen der Maus — dieser Wert zeigt, was das Feld GERADE annimmt. Liefert die
|
||||
* Werte je Feld-ID; fehlt ein Feld (oder kein Cursor), zeigt die UI „—".
|
||||
* Additiv: Befehle ohne diese Methode zeigen ungelockte Felder als „—".
|
||||
*/
|
||||
fieldValues?(
|
||||
state: CommandState,
|
||||
locks: Record<string, number>,
|
||||
cursor: Vec2 | null,
|
||||
): Record<string, number>;
|
||||
}
|
||||
|
||||
/** Convenience-Re-Export für Befehls-Module. */
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
// Compute-Grenze: der EINE Einstiegspunkt für rechenintensive Operationen.
|
||||
// Läuft die App unter Tauri, werden Ops an einen Rust-`#[tauri::command]`
|
||||
// geroutet; sonst greift die bestehende TS-Implementierung. In dieser PoC ist
|
||||
// nur `computeJoins` real an Rust angebunden — die übrigen Funktionen sind
|
||||
// dünne Pass-throughs auf ihre TS-Impls (Grenze etabliert, Migration
|
||||
// inkrementell).
|
||||
|
||||
import type { Project, Wall } from "../model/types";
|
||||
import { getWallType, wallTypeThickness } from "../model/types";
|
||||
import { wallReferenceOffset } from "../model/wall";
|
||||
import { computeJoins as computeJoinsTS } from "../model/joins";
|
||||
import type { WallCuts } from "../model/joins";
|
||||
import type { Line } from "../model/geometry";
|
||||
import { detectRooms as detectRoomsTS } from "../geometry/roomBoundary";
|
||||
import type { WallSegment, DetectRoomsOptions } from "../geometry/roomBoundary";
|
||||
import { parseDwg as parseDwgTS } from "../io/dwgParser";
|
||||
import type { DxfImportResult } from "../io/dxfParser";
|
||||
import type { Vec2 } from "../model/types";
|
||||
|
||||
// Tauri-invoke, wenn vorhanden — sonst null (Browser/kein Tauri). Dynamischer,
|
||||
// GEGUARDETER Import, damit der Build ohne @tauri-apps/api grün bleibt.
|
||||
async function tauriInvoke<T>(cmd: string, args: Record<string, unknown>): Promise<T | null> {
|
||||
try {
|
||||
// @ts-ignore optionales Paket
|
||||
const mod = await import(/* @vite-ignore */ "@tauri-apps/api/core").catch(() => null);
|
||||
if (!mod || typeof (mod as any).invoke !== "function") return null;
|
||||
return await (mod as any).invoke(cmd, args) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Serialisierbare Wand-Repräsentation für den Rust-Kern. */
|
||||
interface FlatWall {
|
||||
id: string;
|
||||
start: Vec2;
|
||||
end: Vec2;
|
||||
thickness: number;
|
||||
referenceOffset: number;
|
||||
}
|
||||
|
||||
/** Rohantwort des Rust-Befehls `compute_joins`. */
|
||||
interface RustWallCut {
|
||||
wallId: string;
|
||||
startCut: Line | null;
|
||||
endCut: Line | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gehrungs-Schnittlinien pro Wand. Unter Tauri via Rust-Kern, sonst TS-Fallback.
|
||||
* Die Signatur ist synchron-kompatibel zur TS-Impl, aber async (Rust-Aufruf).
|
||||
*/
|
||||
export async function computeJoins(
|
||||
project: Project,
|
||||
walls: Wall[],
|
||||
): Promise<Map<string, WallCuts>> {
|
||||
try {
|
||||
const flattened: FlatWall[] = walls.map((w) => {
|
||||
const thickness = wallTypeThickness(getWallType(project, w));
|
||||
return {
|
||||
id: w.id,
|
||||
start: w.start,
|
||||
end: w.end,
|
||||
thickness,
|
||||
referenceOffset: wallReferenceOffset(w, thickness),
|
||||
};
|
||||
});
|
||||
|
||||
const res = await tauriInvoke<RustWallCut[]>("compute_joins", {
|
||||
input: { walls: flattened },
|
||||
});
|
||||
if (res != null) {
|
||||
const map = new Map<string, WallCuts>();
|
||||
for (const c of res) {
|
||||
map.set(c.wallId, { startCut: c.startCut, endCut: c.endCut });
|
||||
}
|
||||
return map;
|
||||
}
|
||||
} catch {
|
||||
// fällt unten in den TS-Pfad
|
||||
}
|
||||
console.warn("Rust compute_joins nicht verfügbar, TS-Fallback");
|
||||
return computeJoinsTS(project, walls);
|
||||
}
|
||||
|
||||
// ── Pass-throughs (noch reines TS; bereit für spätere Rust-Migration) ─────────
|
||||
|
||||
/** Raumerkennung aus Wandsegmenten. Aktuell TS; Grenze für spätere Migration. */
|
||||
export async function detectRooms(
|
||||
walls: WallSegment[],
|
||||
options: DetectRoomsOptions = {},
|
||||
): Promise<Vec2[][]> {
|
||||
return detectRoomsTS(walls, options);
|
||||
}
|
||||
|
||||
/** DWG-Import (LibreDWG-WASM). Aktuell TS; Grenze für spätere Migration. */
|
||||
export async function parseShapeFromDwg(
|
||||
data: ArrayBuffer,
|
||||
): Promise<DxfImportResult> {
|
||||
return parseDwgTS(data);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// TS<->Rust-Paritaet fuer compute_joins: dieselben Sample-Waende, flach gemacht
|
||||
// wie in der Compute-Boundary, einmal durch die TS-Implementierung und einmal
|
||||
// durch das Rust-geometry-Crate (examples/parity) — die Gehrungs-Schnittlinien
|
||||
// muessen numerisch identisch sein. Belegt, dass der Rust-Port die Semantik
|
||||
// erhaelt (Kernbeweis der Migration).
|
||||
|
||||
// @ts-ignore -- node:child_process ohne globales @types/node; im Vitest-Node-Lauf vorhanden
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { sampleProject } from "../model/sampleProject";
|
||||
import { computeJoins as computeJoinsTS } from "../model/joins";
|
||||
import type { WallCuts } from "../model/joins";
|
||||
import type { Line } from "../model/geometry";
|
||||
import { getWallType, wallTypeThickness } from "../model/types";
|
||||
import { wallReferenceOffset } from "../model/wall";
|
||||
|
||||
const EPS = 1e-9;
|
||||
|
||||
/** Flach wie in src/compute/index.ts (Boundary) — Eingang fuer das Rust-Crate. */
|
||||
function flatten() {
|
||||
return sampleProject.walls.map((w) => {
|
||||
const thickness = wallTypeThickness(getWallType(sampleProject, w));
|
||||
return {
|
||||
id: w.id,
|
||||
start: w.start,
|
||||
end: w.end,
|
||||
thickness,
|
||||
referenceOffset: wallReferenceOffset(w, thickness),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Rust-Ausgabe ueber das geometry-Beispiel (liest stdin-JSON, druckt stdout-JSON). */
|
||||
function runRust(input: unknown): Map<string, WallCuts> {
|
||||
const out = execFileSync(
|
||||
"cargo",
|
||||
[
|
||||
"run",
|
||||
"-q",
|
||||
"--manifest-path",
|
||||
"src-tauri/geometry/Cargo.toml",
|
||||
"--example",
|
||||
"parity",
|
||||
],
|
||||
{ input: JSON.stringify(input), encoding: "utf8", timeout: 180000 },
|
||||
);
|
||||
const arr = JSON.parse(out) as Array<{
|
||||
wallId: string;
|
||||
startCut: Line | null;
|
||||
endCut: Line | null;
|
||||
}>;
|
||||
const map = new Map<string, WallCuts>();
|
||||
for (const c of arr) map.set(c.wallId, { startCut: c.startCut, endCut: c.endCut });
|
||||
return map;
|
||||
}
|
||||
|
||||
function lineClose(a: Line | null, b: Line | null): boolean {
|
||||
if (a === null || b === null) return a === b;
|
||||
return (
|
||||
Math.abs(a.point.x - b.point.x) < EPS &&
|
||||
Math.abs(a.point.y - b.point.y) < EPS &&
|
||||
Math.abs(a.dir.x - b.dir.x) < EPS &&
|
||||
Math.abs(a.dir.y - b.dir.y) < EPS
|
||||
);
|
||||
}
|
||||
|
||||
describe("compute_joins TS<->Rust Paritaet", () => {
|
||||
it("liefert fuer alle Sample-Waende identische Gehrungslinien", () => {
|
||||
const walls = sampleProject.walls;
|
||||
const ts = computeJoinsTS(sampleProject, walls);
|
||||
const rust = runRust({ walls: flatten() });
|
||||
|
||||
expect(rust.size).toBe(ts.size);
|
||||
|
||||
// Mindestens eine Wand muss eine echte Gehrung haben (sonst prueft der Test nichts).
|
||||
let cutsSeen = 0;
|
||||
for (const w of walls) {
|
||||
const t = ts.get(w.id)!;
|
||||
const r = rust.get(w.id);
|
||||
expect(r, `Wand ${w.id} fehlt in Rust-Ausgabe`).toBeDefined();
|
||||
expect(lineClose(t.startCut, r!.startCut), `startCut ${w.id}`).toBe(true);
|
||||
expect(lineClose(t.endCut, r!.endCut), `endCut ${w.id}`).toBe(true);
|
||||
if (t.startCut) cutsSeen++;
|
||||
if (t.endCut) cutsSeen++;
|
||||
}
|
||||
expect(cutsSeen, "keine einzige Gehrung im Sample — Test waere aussagelos").toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
// 2D-Boolean-Operationen (Union / Subtract / Intersect) für GESCHLOSSENE
|
||||
// 2D-Zeichenelemente (Drawing2D). Ergänzt Split/Join (`splitJoin.ts`). Wandelt
|
||||
// Drawing2D ↔ polygon-clipping-Format (Martinez-Algorithmus, robust) und liefert
|
||||
// neue Drawing2D-Listen. Stil-, Kategorie- und Level-Attribute werden geerbt
|
||||
// (vom ersten bzw. Basis-Element).
|
||||
//
|
||||
// Loch-Handling: Das Drawing2D-Modell kennt KEINE Innenringe (Löcher). Liefert
|
||||
// polygon-clipping ein Polygon mit Innenringen, wird der Außenring als gefüllte
|
||||
// Form emittiert und JEDER Innenring als SEPARATE, ungefüllte geschlossene
|
||||
// Polylinie (Loch-Umriss). So geht keine Geometrie verloren und nichts crasht;
|
||||
// der Lochrand bleibt sichtbar, ohne dass eine gefüllte Insel entsteht.
|
||||
//
|
||||
// Nur geschlossene Elemente (rect / polyline closed) zählen; offene Linien/
|
||||
// Polylinien und Kreise/Bögen/Text werden ignoriert. Wände sind kein Drawing2D
|
||||
// und damit ohnehin ausgenommen (No-op).
|
||||
//
|
||||
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||||
|
||||
import polygonClipping from "polygon-clipping";
|
||||
import type { MultiPolygon, Polygon, Ring } from "polygon-clipping";
|
||||
import type { Drawing2D, Drawing2DGeom, Vec2 } from "../model/types";
|
||||
import { toPolyForm } from "./splitJoin";
|
||||
import type { PolyForm } from "./splitJoin";
|
||||
|
||||
/** Ist die Geometrie eines Drawing2D eine GESCHLOSSENE Fläche (für Boolean)? */
|
||||
export function isClosedArea(geom: Drawing2DGeom): boolean {
|
||||
if (geom.shape === "rect") return true;
|
||||
if (geom.shape === "polyline") return geom.closed && geom.pts.length >= 3;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wandelt ein geschlossenes Drawing2D in ein polygon-clipping-`Polygon`
|
||||
* (Ring-Liste). Reuse: `toPolyForm` aus splitJoin liefert die Stützpunkte. Der
|
||||
* Ring wird als `[x,y]`-Paare gebildet; polygon-clipping schließt selbst
|
||||
* (erster==letzter ist nicht erforderlich, aber unschädlich). Liefert `null`,
|
||||
* wenn die Form nicht geschlossen ist oder zu wenige Punkte hat.
|
||||
*/
|
||||
export function drawingToPolygon(geom: Drawing2DGeom): Polygon | null {
|
||||
if (!isClosedArea(geom)) return null;
|
||||
const form = toPolyForm(geom);
|
||||
if (!form) return null;
|
||||
const ring = formToRing(form);
|
||||
if (ring.length < 3) return null;
|
||||
return [ring];
|
||||
}
|
||||
|
||||
/** PolyForm → polygon-clipping-Ring (`[x,y]`-Paare). */
|
||||
function formToRing(form: PolyForm): Ring {
|
||||
return form.pts.map((p): [number, number] => [p.x, p.y]);
|
||||
}
|
||||
|
||||
/** Ein polygon-clipping-Ring → Vec2-Punktliste (für fromPolyForm/Drawing2D). */
|
||||
function ringToPts(ring: Ring): Vec2[] {
|
||||
// polygon-clipping liefert geschlossene Ringe (letzter == erster Punkt). Den
|
||||
// doppelten Schlusspunkt entfernen, damit unsere Polyline-Form sauber ist.
|
||||
const pts = ring.map((p): Vec2 => ({ x: p[0], y: p[1] }));
|
||||
if (
|
||||
pts.length > 1 &&
|
||||
pts[0].x === pts[pts.length - 1].x &&
|
||||
pts[0].y === pts[pts.length - 1].y
|
||||
) {
|
||||
pts.pop();
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
let counter = 0;
|
||||
/** Eindeutige Drawing2D-ID (zeitbasiert + Zähler). */
|
||||
function newId(prefix: string): string {
|
||||
counter += 1;
|
||||
return `${prefix}-${Date.now()}-${counter}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt aus einem Punktring ein neues geschlossenes Drawing2D, das Stil/
|
||||
* Kategorie/Level der Quelle erbt. `filled` steuert, ob Füllung/Schraffur
|
||||
* mitwandern (Außenring = ja, Loch-Umriss = nein).
|
||||
*/
|
||||
function ringDrawing(src: Drawing2D, pts: Vec2[], filled: boolean): Drawing2D {
|
||||
const out: Drawing2D = {
|
||||
id: newId("bool"),
|
||||
type: "drawing2d",
|
||||
levelId: src.levelId,
|
||||
categoryCode: src.categoryCode,
|
||||
geom: { shape: "polyline", pts, closed: true },
|
||||
};
|
||||
if (src.lineStyleId !== undefined) out.lineStyleId = src.lineStyleId;
|
||||
if (src.color !== undefined) out.color = src.color;
|
||||
if (src.weightMm !== undefined) out.weightMm = src.weightMm;
|
||||
if (filled) {
|
||||
if (src.hatchId !== undefined) out.hatchId = src.hatchId;
|
||||
if (src.fillColor !== undefined) out.fillColor = src.fillColor;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wandelt eine polygon-clipping-`MultiPolygon` in Drawing2D[] zurück. Je Polygon
|
||||
* wird der Außenring (filled) emittiert; jeder Innenring (Loch) als separater
|
||||
* ungefüllter geschlossener Umriss. Quelle liefert Stil/Kategorie/Level.
|
||||
*/
|
||||
function multiPolygonToDrawings(
|
||||
src: Drawing2D,
|
||||
mp: MultiPolygon,
|
||||
): Drawing2D[] {
|
||||
const out: Drawing2D[] = [];
|
||||
for (const poly of mp) {
|
||||
poly.forEach((ring, i) => {
|
||||
const pts = ringToPts(ring);
|
||||
if (pts.length < 3) return;
|
||||
// Erster Ring = Außenkontur (gefüllt), weitere = Löcher (nur Umriss).
|
||||
out.push(ringDrawing(src, pts, i === 0));
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Geschlossene Quellen → polygon-clipping-Polygone (mit Drawing-Zuordnung). */
|
||||
function collectPolygons(
|
||||
srcs: Drawing2D[],
|
||||
): { poly: Polygon; src: Drawing2D }[] {
|
||||
const out: { poly: Polygon; src: Drawing2D }[] = [];
|
||||
for (const d of srcs) {
|
||||
const poly = drawingToPolygon(d.geom);
|
||||
if (poly) out.push({ poly, src: d });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vereinigung (Union) der gegebenen geschlossenen Drawing2D. Liefert die neuen
|
||||
* Drawing2D (≥1 — MultiPolygon → mehrere) und die IDs der ERSETZTEN Quellen.
|
||||
* Stil/Kategorie/Level werden vom ersten geschlossenen Element geerbt. Weniger
|
||||
* als 2 geschlossene Quellen → No-op (`{ created: [], replacedIds: [] }`).
|
||||
*/
|
||||
export function unionDrawings(srcs: Drawing2D[]): BooleanResult {
|
||||
const polys = collectPolygons(srcs);
|
||||
if (polys.length < 2) return EMPTY_RESULT;
|
||||
const first = polys[0];
|
||||
const rest = polys.slice(1).map((p) => p.poly);
|
||||
const mp = polygonClipping.union(first.poly, ...rest);
|
||||
return {
|
||||
created: multiPolygonToDrawings(first.src, mp),
|
||||
replacedIds: polys.map((p) => p.src.id),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Differenz (Subtract): das ERSTE geschlossene Element ist die Basis, alle
|
||||
* übrigen werden abgezogen. Liefert neue Drawing2D + ersetzte IDs (nur die
|
||||
* tatsächlich beteiligten geschlossenen Quellen). Stil/Kategorie/Level von der
|
||||
* Basis. Weniger als 2 geschlossene Quellen → No-op.
|
||||
*/
|
||||
export function differenceDrawings(srcs: Drawing2D[]): BooleanResult {
|
||||
const polys = collectPolygons(srcs);
|
||||
if (polys.length < 2) return EMPTY_RESULT;
|
||||
const base = polys[0];
|
||||
const clips = polys.slice(1).map((p) => p.poly);
|
||||
const mp = polygonClipping.difference(base.poly, ...clips);
|
||||
return {
|
||||
created: multiPolygonToDrawings(base.src, mp),
|
||||
replacedIds: polys.map((p) => p.src.id),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Schnittmenge (Intersection): nur die gemeinsame Überlappung aller geschlossenen
|
||||
* Elemente bleibt. Liefert neue Drawing2D + ersetzte IDs. Stil/Kategorie/Level
|
||||
* vom ersten Element. Weniger als 2 geschlossene Quellen → No-op. Ohne
|
||||
* Überlappung ist `created` leer (die Quellen werden trotzdem ersetzt = entfernt,
|
||||
* weil der leere Schnitt das gewünschte Ergebnis ist).
|
||||
*/
|
||||
export function intersectionDrawings(srcs: Drawing2D[]): BooleanResult {
|
||||
const polys = collectPolygons(srcs);
|
||||
if (polys.length < 2) return EMPTY_RESULT;
|
||||
const first = polys[0];
|
||||
const rest = polys.slice(1).map((p) => p.poly);
|
||||
const mp = polygonClipping.intersection(first.poly, ...rest);
|
||||
return {
|
||||
created: multiPolygonToDrawings(first.src, mp),
|
||||
replacedIds: polys.map((p) => p.src.id),
|
||||
};
|
||||
}
|
||||
|
||||
/** Ergebnis einer Boolean-Operation: neue Elemente + zu ersetzende Quell-IDs. */
|
||||
export interface BooleanResult {
|
||||
/** Neu erzeugte Drawing2D (ersetzen die Quellen). */
|
||||
created: Drawing2D[];
|
||||
/** IDs der geschlossenen Quellen, die durch das Ergebnis ersetzt werden. */
|
||||
replacedIds: string[];
|
||||
}
|
||||
|
||||
const EMPTY_RESULT: BooleanResult = { created: [], replacedIds: [] };
|
||||
|
||||
/** Zählt geschlossene Flächen in einer Auswahl (für Menü-Aktivierung). */
|
||||
export function countClosedAreas(srcs: Drawing2D[]): number {
|
||||
let n = 0;
|
||||
for (const d of srcs) if (isClosedArea(d.geom)) n++;
|
||||
return n;
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
// Kernel (`../geometry/kernel2d`) und liefern neue Drawing2D-Listen. Stil-,
|
||||
// Kategorie- und Level-Attribute der Quelle werden geerbt.
|
||||
//
|
||||
// Bezeichner englisch, Kommentare deutsch (CLAUDE.md).
|
||||
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||||
|
||||
import type { Drawing2D, Drawing2DGeom, Vec2 } from "../model/types";
|
||||
import {
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
// Schlanker, handgeschriebener DXF-Writer (ASCII, AutoCAD R2000/AC1015-kompatibel).
|
||||
// Erzeugt eine gültige Datei mit HEADER-, TABLES- (LAYER) und ENTITIES-Sektion und
|
||||
// korrektem EOF. Geometrie wird in MODELL-SPACE in ECHTEN Metern geschrieben
|
||||
// ($INSUNITS = 6 = Meter); nachgelagerte Nutzer skalieren beim Plot.
|
||||
//
|
||||
// Bewusst minimaler Funktionsumfang (LINE, LWPOLYLINE, CIRCLE, ARC, TEXT), aber mit
|
||||
// korrekten Gruppen-Codes, damit AutoCAD/LibreCAD/andere Viewer die Datei sauber
|
||||
// öffnen. Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||||
|
||||
/** Ein Layer im DXF (Kategorie): Name, ACI-Farbindex, Linienstärke (mm × 100). */
|
||||
export interface DxfLayer {
|
||||
/** Layer-Name (keine Leerzeichen/Sonderzeichen → werden bereinigt). */
|
||||
name: string;
|
||||
/** AutoCAD Color Index (1..255). */
|
||||
color: number;
|
||||
/** True-Color (0xRRGGBB) für Viewer mit 24-Bit-Farbe; optional. */
|
||||
trueColor?: number;
|
||||
/** Linienstärke in 1/100 mm (DXF-LWEIGHT-Konvention), z. B. 25 = 0.25 mm. */
|
||||
lineWeight: number;
|
||||
}
|
||||
|
||||
/** 2D-Punkt (Meter, Modell-Space). */
|
||||
export interface DxfPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sammelt Entities und Layer und serialisiert sie zu einem gültigen DXF-String.
|
||||
* Alle Koordinaten sind Meter (Modell-Space). Der Y-Wert wird 1:1 übernommen
|
||||
* (DXF-Y zeigt wie das Weltmodell nach oben — keine Spiegelung wie beim SVG).
|
||||
*/
|
||||
export class DxfWriter {
|
||||
private layers = new Map<string, DxfLayer>();
|
||||
private entities: string[] = [];
|
||||
private handle = 0x100;
|
||||
|
||||
/** Nächstes eindeutiges Entity-Handle (Hex), fortlaufend. */
|
||||
private nextHandle(): string {
|
||||
return (this.handle++).toString(16).toUpperCase();
|
||||
}
|
||||
|
||||
/** Registriert einen Layer (idempotent über den Namen). */
|
||||
addLayer(layer: DxfLayer): void {
|
||||
const name = sanitizeLayerName(layer.name);
|
||||
if (!this.layers.has(name)) {
|
||||
this.layers.set(name, { ...layer, name });
|
||||
}
|
||||
}
|
||||
|
||||
/** Stellt sicher, dass ein Layer existiert; liefert den bereinigten Namen. */
|
||||
private ensureLayer(name: string): string {
|
||||
const clean = sanitizeLayerName(name);
|
||||
if (!this.layers.has(clean)) {
|
||||
this.layers.set(clean, { name: clean, color: 7, lineWeight: 25 });
|
||||
}
|
||||
return clean;
|
||||
}
|
||||
|
||||
addLine(layer: string, a: DxfPoint, b: DxfPoint): void {
|
||||
const l = this.ensureLayer(layer);
|
||||
this.entities.push(
|
||||
tag(0, "LINE"),
|
||||
tag(5, this.nextHandle()),
|
||||
tag(100, "AcDbEntity"),
|
||||
tag(8, l),
|
||||
tag(100, "AcDbLine"),
|
||||
num(10, a.x),
|
||||
num(20, a.y),
|
||||
num(30, 0),
|
||||
num(11, b.x),
|
||||
num(21, b.y),
|
||||
num(31, 0),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* LWPOLYLINE (leichte 2D-Polylinie). `closed` setzt das Ringflag (Bit 1 in
|
||||
* Code 70), sodass die letzte Kante zurück zum ersten Punkt geschlossen wird.
|
||||
*/
|
||||
addPolyline(layer: string, pts: DxfPoint[], closed: boolean): void {
|
||||
if (pts.length < 2) return;
|
||||
const l = this.ensureLayer(layer);
|
||||
this.entities.push(
|
||||
tag(0, "LWPOLYLINE"),
|
||||
tag(5, this.nextHandle()),
|
||||
tag(100, "AcDbEntity"),
|
||||
tag(8, l),
|
||||
tag(100, "AcDbPolyline"),
|
||||
tag(90, String(pts.length)),
|
||||
tag(70, closed ? "1" : "0"),
|
||||
);
|
||||
for (const p of pts) {
|
||||
this.entities.push(num(10, p.x), num(20, p.y));
|
||||
}
|
||||
}
|
||||
|
||||
addCircle(layer: string, center: DxfPoint, r: number): void {
|
||||
const l = this.ensureLayer(layer);
|
||||
this.entities.push(
|
||||
tag(0, "CIRCLE"),
|
||||
tag(5, this.nextHandle()),
|
||||
tag(100, "AcDbEntity"),
|
||||
tag(8, l),
|
||||
tag(100, "AcDbCircle"),
|
||||
num(10, center.x),
|
||||
num(20, center.y),
|
||||
num(30, 0),
|
||||
num(40, r),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ARC — Bogen um `center` mit Radius `r`, von `startAngle` bis `endAngle`
|
||||
* (Grad, gegen den Uhrzeigersinn, DXF-Konvention).
|
||||
*/
|
||||
addArc(
|
||||
layer: string,
|
||||
center: DxfPoint,
|
||||
r: number,
|
||||
startAngle: number,
|
||||
endAngle: number,
|
||||
): void {
|
||||
const l = this.ensureLayer(layer);
|
||||
this.entities.push(
|
||||
tag(0, "ARC"),
|
||||
tag(5, this.nextHandle()),
|
||||
tag(100, "AcDbEntity"),
|
||||
tag(8, l),
|
||||
tag(100, "AcDbCircle"),
|
||||
num(10, center.x),
|
||||
num(20, center.y),
|
||||
num(30, 0),
|
||||
num(40, r),
|
||||
tag(100, "AcDbArc"),
|
||||
num(50, startAngle),
|
||||
num(51, endAngle),
|
||||
);
|
||||
}
|
||||
|
||||
/** TEXT — einzeiliger Text, Höhe in Metern, an `at` (unten links). */
|
||||
addText(layer: string, at: DxfPoint, height: number, value: string): void {
|
||||
const l = this.ensureLayer(layer);
|
||||
this.entities.push(
|
||||
tag(0, "TEXT"),
|
||||
tag(5, this.nextHandle()),
|
||||
tag(100, "AcDbEntity"),
|
||||
tag(8, l),
|
||||
tag(100, "AcDbText"),
|
||||
num(10, at.x),
|
||||
num(20, at.y),
|
||||
num(30, 0),
|
||||
num(40, height),
|
||||
tag(1, sanitizeText(value)),
|
||||
tag(100, "AcDbText"),
|
||||
);
|
||||
}
|
||||
|
||||
/** Anzahl bislang gesammelter Entities (für Tests/Validierung). */
|
||||
get entityCount(): number {
|
||||
// Jede Entity beginnt mit einem "0"-Gruppencode + Typ-Zeile.
|
||||
let n = 0;
|
||||
for (let i = 0; i < this.entities.length; i += 2) {
|
||||
if (this.entities[i] === " 0") n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Serialisiert die vollständige DXF-Datei als String (mit EOF). */
|
||||
build(): string {
|
||||
const out: string[] = [];
|
||||
out.push(...this.headerSection());
|
||||
out.push(...this.tablesSection());
|
||||
out.push(...this.entitiesSection());
|
||||
out.push(tag(0, "EOF"));
|
||||
return out.join("\r\n") + "\r\n";
|
||||
}
|
||||
|
||||
private headerSection(): string[] {
|
||||
return [
|
||||
tag(0, "SECTION"),
|
||||
tag(2, "HEADER"),
|
||||
tag(9, "$ACADVER"),
|
||||
tag(1, "AC1015"), // AutoCAD 2000
|
||||
tag(9, "$INSUNITS"),
|
||||
tag(70, "6"), // 6 = Meter
|
||||
tag(9, "$MEASUREMENT"),
|
||||
tag(70, "1"), // 1 = metrisch
|
||||
tag(0, "ENDSEC"),
|
||||
];
|
||||
}
|
||||
|
||||
private tablesSection(): string[] {
|
||||
const out: string[] = [tag(0, "SECTION"), tag(2, "TABLES")];
|
||||
|
||||
// LTYPE-Tabelle mit dem obligatorischen Continuous-Linetype (Viewer erwarten ihn).
|
||||
out.push(
|
||||
tag(0, "TABLE"),
|
||||
tag(2, "LTYPE"),
|
||||
tag(70, "1"),
|
||||
tag(0, "LTYPE"),
|
||||
tag(2, "CONTINUOUS"),
|
||||
tag(70, "0"),
|
||||
tag(3, "Solid line"),
|
||||
tag(72, "65"),
|
||||
tag(73, "0"),
|
||||
num(40, 0),
|
||||
tag(0, "ENDTAB"),
|
||||
);
|
||||
|
||||
// LAYER-Tabelle: Layer 0 (Pflicht) + alle Kategorie-Layer.
|
||||
const layers = [...this.layers.values()];
|
||||
out.push(tag(0, "TABLE"), tag(2, "LAYER"), tag(70, String(layers.length + 1)));
|
||||
out.push(...this.layerRecord({ name: "0", color: 7, lineWeight: 25 }));
|
||||
for (const layer of layers) out.push(...this.layerRecord(layer));
|
||||
out.push(tag(0, "ENDTAB"));
|
||||
|
||||
out.push(tag(0, "ENDSEC"));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Ein LAYER-Datensatz (Name/Farbe/Linetype/Linienstärke, ggf. True-Color). */
|
||||
private layerRecord(layer: DxfLayer): string[] {
|
||||
const rec = [
|
||||
tag(0, "LAYER"),
|
||||
tag(5, this.nextHandle()),
|
||||
tag(100, "AcDbSymbolTableRecord"),
|
||||
tag(100, "AcDbLayerTableRecord"),
|
||||
tag(2, layer.name),
|
||||
tag(70, "0"), // Flags: nicht gefroren/gesperrt
|
||||
tag(62, String(layer.color)), // ACI-Farbindex
|
||||
tag(6, "CONTINUOUS"),
|
||||
tag(370, String(layer.lineWeight)), // Linienstärke in 1/100 mm
|
||||
];
|
||||
// True-Color (Code 420, 24-Bit) für Viewer mit voller Farbtiefe.
|
||||
if (layer.trueColor != null) {
|
||||
rec.push(tag(420, String(layer.trueColor & 0xffffff)));
|
||||
}
|
||||
return rec;
|
||||
}
|
||||
|
||||
private entitiesSection(): string[] {
|
||||
return [tag(0, "SECTION"), tag(2, "ENTITIES"), ...this.entities, tag(0, "ENDSEC")];
|
||||
}
|
||||
}
|
||||
|
||||
/** Gruppen-Code-Zeile mit String-Wert (Code rechtsbündig auf 3 Stellen). */
|
||||
function tag(code: number, value: string): string {
|
||||
return `${String(code).padStart(3, " ")}\n${value}`;
|
||||
}
|
||||
|
||||
/** Gruppen-Code-Zeile mit Zahl-Wert (feste, kompakte Dezimaldarstellung). */
|
||||
function num(code: number, value: number): string {
|
||||
return `${String(code).padStart(3, " ")}\n${fmt(value)}`;
|
||||
}
|
||||
|
||||
/** Kompakte, verlustarme Zahlformatierung (bis 6 Nachkommastellen, ohne Nullen). */
|
||||
function fmt(v: number): string {
|
||||
if (!Number.isFinite(v)) return "0.0";
|
||||
const s = v.toFixed(6);
|
||||
// Nachlaufende Nullen entfernen, aber mindestens eine Nachkommastelle behalten.
|
||||
return s.replace(/(\.\d*?)0+$/, "$1").replace(/\.$/, ".0");
|
||||
}
|
||||
|
||||
/**
|
||||
* Bereinigt einen Layer-Namen: DXF-verbotene Zeichen (< > / \ " : ; ? * | = `)
|
||||
* und Leerzeichen werden zu „_". Leerer Name → "0".
|
||||
*/
|
||||
export function sanitizeLayerName(name: string): string {
|
||||
const clean = name
|
||||
.trim()
|
||||
.replace(/[<>/\\":;?*|=`]/g, "_")
|
||||
.replace(/\s+/g, "_");
|
||||
return clean.length ? clean : "0";
|
||||
}
|
||||
|
||||
/** Entfernt Zeilenumbrüche aus TEXT-Werten (einzeilige TEXT-Entity). */
|
||||
function sanitizeText(value: string): string {
|
||||
return value.replace(/[\r\n]+/g, " ");
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
// DXF-Export des Grundrisses. Nutzt DIESELBE Quelle wie der PDF-Export — den
|
||||
// Vektor-Plan (generatePlan → Primitive) — schreibt aber MODELL-SPACE-Geometrie
|
||||
// in ECHTEN Metern (kein Papier-mm, keine Y-Spiegelung). So können nachgelagerte
|
||||
// CAD-Nutzer beim Plot beliebig skalieren; die Datei ist masshaltig 1:1 in Metern.
|
||||
//
|
||||
// Layer entstehen aus den Kategorie-Ebenen des Projekts (Code + Name, Farbe,
|
||||
// Linienstärke). Jede Entity liegt auf dem Layer ihrer Kategorie; Primitive ohne
|
||||
// direkten Kategorie-Bezug (Tür-Symbole, Kontext, Schraffuren) landen auf
|
||||
// sprechenden Sammel-Layern. Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||||
|
||||
import type { Plan, Primitive } from "../plan/generatePlan";
|
||||
import type { LayerCategory, Project, Vec2 } from "../model/types";
|
||||
import { flattenCategories } from "../model/types";
|
||||
import { DxfWriter } from "./dxfWriter";
|
||||
|
||||
export interface ExportDxfOptions {
|
||||
/** Titel/Geschossname (nur als Kommentar-freundlicher Dateiname genutzt). */
|
||||
title?: string;
|
||||
/** Schraffurlinien mit ausgeben (true) oder nur Umrisse/Flächen (false). */
|
||||
includeHatches?: boolean;
|
||||
/** Dateiname des Downloads (mit/ohne .dxf). */
|
||||
fileName?: string;
|
||||
}
|
||||
|
||||
/** Sammel-Layer-Namen für Primitive ohne eigene Kategorie. */
|
||||
const LAYER_HATCH = "HATCH";
|
||||
const LAYER_SYMBOLS = "SYMBOLS";
|
||||
const LAYER_CONTEXT = "CONTEXT";
|
||||
const LAYER_DEFAULT = "PLAN";
|
||||
|
||||
/**
|
||||
* Baut aus einem Plan + Projekt einen vollständigen DXF-String (Meter-Modell-Space).
|
||||
* Reihenfolge der Primitive bleibt erhalten. Rückgabe ist reiner ASCII-Text.
|
||||
*/
|
||||
export function buildPlanDxf(
|
||||
plan: Plan,
|
||||
project: Project,
|
||||
opts: ExportDxfOptions = {},
|
||||
): string {
|
||||
const includeHatches = opts.includeHatches ?? true;
|
||||
const dxf = new DxfWriter();
|
||||
|
||||
// 1) Layer aus den Kategorie-Ebenen anlegen (Code+Name als Layer-Name, Farbe,
|
||||
// Linienstärke). So trägt die DXF echte, benannte Layer statt eines Flachs.
|
||||
const cats = flattenCategories(project.layers);
|
||||
const layerNameByCode = new Map<string, string>();
|
||||
for (const c of cats) {
|
||||
const name = layerName(c);
|
||||
layerNameByCode.set(c.code, name);
|
||||
dxf.addLayer({
|
||||
name,
|
||||
color: aciFromHex(c.color),
|
||||
trueColor: rgbFromHex(c.color),
|
||||
lineWeight: mmToLwe(c.lw),
|
||||
});
|
||||
}
|
||||
// Sammel-Layer für kategorielose Primitive.
|
||||
dxf.addLayer({ name: LAYER_SYMBOLS, color: 7, trueColor: 0x111111, lineWeight: 18 });
|
||||
dxf.addLayer({ name: LAYER_HATCH, color: 8, trueColor: 0x888888, lineWeight: 13 });
|
||||
dxf.addLayer({ name: LAYER_CONTEXT, color: 9, trueColor: 0x9aa3ad, lineWeight: 10 });
|
||||
dxf.addLayer({ name: LAYER_DEFAULT, color: 7, trueColor: 0x111111, lineWeight: 18 });
|
||||
|
||||
// 2) Rückverweise Primitiv → Kategorie: Wände/2D-Elemente tragen nur ihre ID im
|
||||
// Plan; die Kategorie liegt am Projekt-Objekt. Damit landet jede Wand-/Drawing-
|
||||
// Entity auf dem richtigen Layer.
|
||||
const codeByWall = new Map<string, string>();
|
||||
for (const w of project.walls) codeByWall.set(w.id, w.categoryCode);
|
||||
const codeByDrawing = new Map<string, string>();
|
||||
for (const d of project.drawings2d) codeByDrawing.set(d.id, d.categoryCode);
|
||||
|
||||
const layerFor = (p: Primitive): string => {
|
||||
if (p.kind === "polygon") {
|
||||
if (p.wallId) return layerNameByCode.get(codeByWall.get(p.wallId) ?? "") ?? LAYER_DEFAULT;
|
||||
if (p.drawingId) return layerNameByCode.get(codeByDrawing.get(p.drawingId) ?? "") ?? LAYER_DEFAULT;
|
||||
return LAYER_DEFAULT;
|
||||
}
|
||||
if (p.kind === "line") {
|
||||
if (p.cls === "context-line") return LAYER_CONTEXT;
|
||||
if (p.drawingId) return layerNameByCode.get(codeByDrawing.get(p.drawingId) ?? "") ?? LAYER_DEFAULT;
|
||||
// Tür-Blatt/-Rahmen und Wand-Achsen: Symbol-Sammellayer.
|
||||
return LAYER_SYMBOLS;
|
||||
}
|
||||
// Bögen: Tür-Schwenkbogen → Symbole.
|
||||
return LAYER_SYMBOLS;
|
||||
};
|
||||
|
||||
// 3) Jedes Primitiv in DXF-Entities übersetzen.
|
||||
for (const p of plan.primitives) {
|
||||
emitPrimitive(dxf, p, layerFor(p), includeHatches);
|
||||
}
|
||||
|
||||
return dxf.build();
|
||||
}
|
||||
|
||||
/** Layer-Name aus einer Kategorie: "Code_Name" (bereinigt der Writer selbst). */
|
||||
function layerName(c: LayerCategory): string {
|
||||
return `${c.code}_${c.name}`;
|
||||
}
|
||||
|
||||
/** Schreibt ein Plan-Primitiv als passende DXF-Entity(en). */
|
||||
function emitPrimitive(
|
||||
dxf: DxfWriter,
|
||||
p: Primitive,
|
||||
layer: string,
|
||||
includeHatches: boolean,
|
||||
): void {
|
||||
switch (p.kind) {
|
||||
case "line":
|
||||
dxf.addLine(layer, p.a, p.b);
|
||||
break;
|
||||
|
||||
case "arc":
|
||||
emitArc(dxf, p, layer);
|
||||
break;
|
||||
|
||||
case "polygon": {
|
||||
const pts = p.pts;
|
||||
if (pts.length < 2) break;
|
||||
// Kreis-Approximationen (viele Segmente) bleiben als geschlossene Polylinie —
|
||||
// masshaltig und robust. Geschlossene Ringe bekommen das Ring-Flag.
|
||||
dxf.addPolyline(layer, pts, true);
|
||||
// Schraffur als geklippte Linien (optional). Vollfüllung/„none" tragen keine
|
||||
// sichtbaren Musterlinien → wir geben nur echte Muster (nicht solid/none) aus.
|
||||
if (
|
||||
includeHatches &&
|
||||
p.hatch.pattern !== "none" &&
|
||||
p.hatch.pattern !== "solid"
|
||||
) {
|
||||
for (const seg of hatchSegments(pts, p.hatch)) {
|
||||
dxf.addLine(LAYER_HATCH, seg.a, seg.b);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bogen-Primitiv → DXF-ARC. DXF-Bögen laufen gegen den Uhrzeigersinn von
|
||||
* startAngle nach endAngle. Der Plan-Bogen ist über from/to definiert; die
|
||||
* Drehrichtung folgt dem Kreuzprodukt (positiv = CCW). Bei CW werden Start/Ende
|
||||
* getauscht, damit der kurze Bogen in der richtigen Richtung entsteht.
|
||||
*/
|
||||
function emitArc(
|
||||
dxf: DxfWriter,
|
||||
p: Extract<Primitive, { kind: "arc" }>,
|
||||
layer: string,
|
||||
): void {
|
||||
const a1 = angleDeg(p.center, p.from);
|
||||
const a2 = angleDeg(p.center, p.to);
|
||||
const v1 = { x: p.from.x - p.center.x, y: p.from.y - p.center.y };
|
||||
const v2 = { x: p.to.x - p.center.x, y: p.to.y - p.center.y };
|
||||
const cross = v1.x * v2.y - v1.y * v2.x;
|
||||
if (cross >= 0) {
|
||||
dxf.addArc(layer, p.center, p.r, a1, a2);
|
||||
} else {
|
||||
dxf.addArc(layer, p.center, p.r, a2, a1);
|
||||
}
|
||||
}
|
||||
|
||||
/** Winkel (Grad, 0..360) von `c` nach `p`, gegen den Uhrzeigersinn. */
|
||||
function angleDeg(c: Vec2, p: Vec2): number {
|
||||
const d = (Math.atan2(p.y - c.y, p.x - c.x) * 180) / Math.PI;
|
||||
return d < 0 ? d + 360 : d;
|
||||
}
|
||||
|
||||
// ── Schraffur als geklippte Vektorlinien (Meter) ────────────────────────────
|
||||
// Wie planToPrintSvg, aber in WELT-Metern statt Papier-mm. Die Kachelgrösse der
|
||||
// Bildschirm-PlanView (8 bzw. 10 Einheiten × scale, 1 Einheit = 1/90 m) wird
|
||||
// direkt in einen Meter-Linienabstand übersetzt.
|
||||
|
||||
interface Seg {
|
||||
a: Vec2;
|
||||
b: Vec2;
|
||||
}
|
||||
|
||||
function hatchSegments(
|
||||
poly: Vec2[],
|
||||
hatch: Extract<Primitive, { kind: "polygon" }>["hatch"],
|
||||
): Seg[] {
|
||||
const unitM = 1 / 90;
|
||||
const tileM = (hatch.pattern === "insulation" ? 10 : 8) * hatch.scale * unitM;
|
||||
const spacing = Math.max(0.02, tileM); // Mindestabstand in Metern
|
||||
const angle = (hatch.angle * Math.PI) / 180;
|
||||
const segs: Seg[] = [];
|
||||
fillParallel(poly, angle, spacing, segs);
|
||||
if (hatch.pattern === "crosshatch") fillParallel(poly, angle + Math.PI / 2, spacing, segs);
|
||||
if (hatch.pattern === "insulation") fillParallel(poly, angle - Math.PI / 4, spacing, segs);
|
||||
return segs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Füllt ein Polygon mit parallelen Linien (Winkel/Abstand) und klippt jede Linie
|
||||
* per Scanline auf das Polygon. Robust für konvexe wie einfache konkave Polygone.
|
||||
*/
|
||||
function fillParallel(poly: Vec2[], angle: number, spacing: number, out: Seg[]): void {
|
||||
if (poly.length < 3 || spacing <= 0) return;
|
||||
const dir = { x: Math.cos(angle), y: Math.sin(angle) };
|
||||
const nrm = { x: -dir.y, y: dir.x };
|
||||
let tMin = Infinity;
|
||||
let tMax = -Infinity;
|
||||
for (const p of poly) {
|
||||
const t = p.x * nrm.x + p.y * nrm.y;
|
||||
if (t < tMin) tMin = t;
|
||||
if (t > tMax) tMax = t;
|
||||
}
|
||||
const start = Math.ceil(tMin / spacing) * spacing;
|
||||
for (let t = start; t <= tMax; t += spacing) {
|
||||
const xs: number[] = [];
|
||||
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
||||
const a = poly[j];
|
||||
const b = poly[i];
|
||||
const ta = a.x * nrm.x + a.y * nrm.y;
|
||||
const tb = b.x * nrm.x + b.y * nrm.y;
|
||||
if (ta === tb) continue;
|
||||
const f = (t - ta) / (tb - ta);
|
||||
if (f < 0 || f > 1) continue;
|
||||
const px = a.x + (b.x - a.x) * f;
|
||||
const py = a.y + (b.y - a.y) * f;
|
||||
xs.push(px * dir.x + py * dir.y);
|
||||
}
|
||||
if (xs.length < 2) continue;
|
||||
xs.sort((u, v) => u - v);
|
||||
for (let k = 0; k + 1 < xs.length; k += 2) {
|
||||
const u0 = xs[k];
|
||||
const u1 = xs[k + 1];
|
||||
if (u1 - u0 < 1e-6) continue;
|
||||
out.push({
|
||||
a: { x: nrm.x * t + dir.x * u0, y: nrm.y * t + dir.y * u0 },
|
||||
b: { x: nrm.x * t + dir.x * u1, y: nrm.y * t + dir.y * u1 },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Farb-Umrechnung ─────────────────────────────────────────────────────────
|
||||
|
||||
/** "#rrggbb" → 0xRRGGBB (24-Bit True-Color). Fallback dunkelgrau. */
|
||||
export function rgbFromHex(hex: string): number {
|
||||
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
|
||||
if (!m) return 0x111111;
|
||||
return parseInt(m[1], 16);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mappt eine Hex-Farbe auf den nächsten AutoCAD-Color-Index (ACI 1..255). Nutzt
|
||||
* die feste ACI-Standardpalette (die ersten 9 Indizes sind die Grundfarben) und
|
||||
* wählt per euklidischem RGB-Abstand den nächstliegenden. Robust genug, damit
|
||||
* Viewer ohne True-Color-Support trotzdem sinnvolle Farben zeigen.
|
||||
*/
|
||||
export function aciFromHex(hex: string): number {
|
||||
const rgb = rgbFromHex(hex);
|
||||
const r = (rgb >> 16) & 0xff;
|
||||
const g = (rgb >> 8) & 0xff;
|
||||
const b = rgb & 0xff;
|
||||
let best = 7;
|
||||
let bestDist = Infinity;
|
||||
for (const [idx, cr, cg, cb] of ACI_PALETTE) {
|
||||
const d = (r - cr) ** 2 + (g - cg) ** 2 + (b - cb) ** 2;
|
||||
if (d < bestDist) {
|
||||
bestDist = d;
|
||||
best = idx;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Millimeter → DXF-LWEIGHT (1/100 mm), gerundet auf gültige Stufen ≥ 0. */
|
||||
export function mmToLwe(mm: number): number {
|
||||
return Math.max(0, Math.round(mm * 100));
|
||||
}
|
||||
|
||||
/**
|
||||
* Verkürzte ACI-Standardpalette [index, r, g, b]. Deckt die geläufigen Töne ab;
|
||||
* für die Layer-Farben (meist gedämpfte Grau-/Bautöne) genügt das Nächste-Farbe-
|
||||
* Matching. Index 7 (weiss/schwarz) und die Grautöne 250..255 sind enthalten.
|
||||
*/
|
||||
const ACI_PALETTE: ReadonlyArray<readonly [number, number, number, number]> = [
|
||||
[1, 255, 0, 0],
|
||||
[2, 255, 255, 0],
|
||||
[3, 0, 255, 0],
|
||||
[4, 0, 255, 255],
|
||||
[5, 0, 0, 255],
|
||||
[6, 255, 0, 255],
|
||||
[7, 255, 255, 255],
|
||||
[8, 128, 128, 128],
|
||||
[9, 192, 192, 192],
|
||||
[30, 255, 128, 0],
|
||||
[40, 255, 191, 127],
|
||||
[250, 51, 51, 51],
|
||||
[251, 91, 91, 91],
|
||||
[252, 132, 132, 132],
|
||||
[253, 173, 173, 173],
|
||||
[254, 214, 214, 214],
|
||||
[255, 255, 255, 255],
|
||||
];
|
||||
|
||||
/** Baut das DXF und löst den Download aus (Browser). */
|
||||
export function savePlanDxf(
|
||||
plan: Plan,
|
||||
project: Project,
|
||||
opts: ExportDxfOptions = {},
|
||||
): void {
|
||||
const text = buildPlanDxf(plan, project, opts);
|
||||
const name = (opts.fileName ?? "grundriss").replace(/\.dxf$/i, "");
|
||||
const blob = new Blob([text], { type: "application/dxf" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${name}.dxf`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// Vektor-PDF-Export des Grundrisses. Baut aus einem Plan (generatePlan →
|
||||
// Primitive) ein massstäbliches Druck-SVG (planToPrintSvg) und rendert dieses
|
||||
// per jsPDF + svg2pdf.js als ECHTES Vektor-PDF (Pfad-/Text-Operatoren, KEINE
|
||||
// Rasterung). Papierformat (A4/A3) + Ausrichtung (Hoch/Quer) wählbar; der Plan
|
||||
// wird zentriert aufs Blatt gesetzt, ein schlichtes Schriftfeld (Titel/Massstab)
|
||||
// optional unten rechts.
|
||||
//
|
||||
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||||
|
||||
import { jsPDF } from "jspdf";
|
||||
import "svg2pdf.js";
|
||||
import type { Plan } from "../plan/generatePlan";
|
||||
import { planToPrintSvg } from "./planToPrintSvg";
|
||||
|
||||
/** Unterstützte Papierformate (Blattmasse in mm, Hochformat-Basis). */
|
||||
export type PaperFormat = "a4" | "a3";
|
||||
|
||||
/** Ausrichtung des Blatts. */
|
||||
export type Orientation = "portrait" | "landscape";
|
||||
|
||||
/** Blattmasse je Format im Hochformat (mm). */
|
||||
const PAGE_MM: Record<PaperFormat, { w: number; h: number }> = {
|
||||
a4: { w: 210, h: 297 },
|
||||
a3: { w: 297, h: 420 },
|
||||
};
|
||||
|
||||
export interface ExportPdfOptions {
|
||||
/** Massstab-Nenner N (1:N). */
|
||||
scaleDenominator: number;
|
||||
paper: PaperFormat;
|
||||
orientation: Orientation;
|
||||
/** Plantitel/Geschossname fürs Schriftfeld. */
|
||||
title: string;
|
||||
/** Optionaler Untertitel (z. B. Projektname). */
|
||||
subtitle?: string;
|
||||
/** Schriftfeld zeichnen (Titel/Massstab). Default true. */
|
||||
titleBlock?: boolean;
|
||||
/** Dateiname des Downloads (ohne/inkl. .pdf). */
|
||||
fileName?: string;
|
||||
}
|
||||
|
||||
/** Blattmasse (mm) nach Format + Ausrichtung. */
|
||||
export function pageSizeMm(
|
||||
paper: PaperFormat,
|
||||
orientation: Orientation,
|
||||
): { w: number; h: number } {
|
||||
const base = PAGE_MM[paper];
|
||||
return orientation === "landscape" ? { w: base.h, h: base.w } : base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt das Vektor-PDF des Plans und liefert das jsPDF-Dokument zurück (der
|
||||
* Aufrufer speichert via {@link savePlanPdf} oder nutzt `doc.output(...)` zum
|
||||
* Testen). Das Schriftfeld nutzt die eingebaute Helvetica (Vektor-Text).
|
||||
*/
|
||||
export async function buildPlanPdf(
|
||||
plan: Plan,
|
||||
opts: ExportPdfOptions,
|
||||
): Promise<jsPDF> {
|
||||
const { w: pageW, h: pageH } = pageSizeMm(opts.paper, opts.orientation);
|
||||
|
||||
// Druck-SVG (mm-Koordinaten) aufbauen. Etwas mehr unterer Rand, falls ein
|
||||
// Schriftfeld gezeichnet wird (damit es nicht in den Plan ragt).
|
||||
const margin = 10;
|
||||
const print = planToPrintSvg(plan, {
|
||||
scaleDenominator: opts.scaleDenominator,
|
||||
pageWidthMm: pageW,
|
||||
pageHeightMm: pageH,
|
||||
marginMm: margin,
|
||||
});
|
||||
|
||||
const doc = new jsPDF({
|
||||
unit: "mm",
|
||||
format: [pageW, pageH],
|
||||
orientation: opts.orientation,
|
||||
compress: true,
|
||||
});
|
||||
|
||||
// SVG → PDF (Vektor). Die viewBox des SVG ist in mm (Papier-Benutzereinheiten)
|
||||
// und deckungsgleich mit der Blattgröße; svg2pdf bildet sie 1:1 auf das mm-Ziel
|
||||
// (Blattbreite/-höhe) ab.
|
||||
await doc.svg(print.svg, { x: 0, y: 0, width: pageW, height: pageH });
|
||||
|
||||
if (opts.titleBlock ?? true) {
|
||||
drawTitleBlock(doc, pageW, pageH, opts);
|
||||
}
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
/** Schlichtes Schriftfeld unten rechts: Titel, Untertitel, Massstab. */
|
||||
function drawTitleBlock(
|
||||
doc: jsPDF,
|
||||
pageW: number,
|
||||
pageH: number,
|
||||
opts: ExportPdfOptions,
|
||||
): void {
|
||||
const boxW = 70;
|
||||
const boxH = 22;
|
||||
const pad = 6;
|
||||
const x = pageW - boxW - pad;
|
||||
const y = pageH - boxH - pad;
|
||||
|
||||
doc.setDrawColor(17, 17, 17);
|
||||
doc.setLineWidth(0.25);
|
||||
doc.rect(x, y, boxW, boxH);
|
||||
// Trennlinie zwischen Titel-Block und Massstab-Zeile.
|
||||
doc.line(x, y + boxH - 7, x + boxW, y + boxH - 7);
|
||||
|
||||
doc.setTextColor(17, 17, 17);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setFontSize(10);
|
||||
doc.text(clip(opts.title, 30), x + 3, y + 6);
|
||||
|
||||
if (opts.subtitle) {
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.setFontSize(7);
|
||||
doc.text(clip(opts.subtitle, 40), x + 3, y + 11);
|
||||
}
|
||||
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.setFontSize(9);
|
||||
doc.text(`1:${opts.scaleDenominator}`, x + 3, y + boxH - 2.2);
|
||||
const fmt = `${opts.paper.toUpperCase()} ${
|
||||
opts.orientation === "landscape" ? "quer" : "hoch"
|
||||
}`;
|
||||
doc.text(fmt, x + boxW - 3, y + boxH - 2.2, { align: "right" });
|
||||
}
|
||||
|
||||
/** Kürzt einen String auf maximal `max` Zeichen (mit Auslassung). */
|
||||
function clip(s: string, max: number): string {
|
||||
return s.length > max ? s.slice(0, max - 1) + "…" : s;
|
||||
}
|
||||
|
||||
/** Baut das PDF und löst den Download aus (Browser). */
|
||||
export async function savePlanPdf(
|
||||
plan: Plan,
|
||||
opts: ExportPdfOptions,
|
||||
): Promise<void> {
|
||||
const doc = await buildPlanPdf(plan, opts);
|
||||
const name = (opts.fileName ?? "grundriss").replace(/\.pdf$/i, "");
|
||||
doc.save(`${name}.pdf`);
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
// Druck-SVG aus einem Plan (generatePlan → Primitive). Eigenständiges,
|
||||
// massstäbliches SVG für den VEKTOR-PDF-Export — NICHT vom Bildschirm-DOM
|
||||
// abgeleitet, sondern direkt aus den Plan-Primitiven (Polygon/Linie/Bogen).
|
||||
//
|
||||
// Kern-Idee (vgl. CONVENTIONS.md, docs/design/plans-output.md §3): Der Plan IST
|
||||
// Papier-Space. Welt-Meter werden im gewählten Massstab 1:N nach Papier-
|
||||
// Millimeter abgebildet (1 m = 1000/N mm). Das SVG-Benutzerkoordinatensystem ist
|
||||
// damit „mm": die viewBox ist in mm, Strichstärken (`stroke-width`) sind echte
|
||||
// physische mm — KEIN non-scaling-stroke (anders als die Bildschirm-PlanView).
|
||||
//
|
||||
// Schraffuren werden NICHT als <pattern> geschrieben (PDF-Konverter übernehmen
|
||||
// Pattern oft nicht sauber), sondern als echte, auf das Polygon geklippte
|
||||
// Vektorlinien direkt ins SVG. So bleibt das PDF rein vektoriell und scharf.
|
||||
//
|
||||
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||||
|
||||
import type { HatchRender, Plan, Primitive } from "../plan/generatePlan";
|
||||
import type { Vec2 } from "../model/types";
|
||||
|
||||
/** SVG-Namespace für die programmatisch erzeugten Knoten. */
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
/**
|
||||
* Standard-Stift-Stufen (mm). Beliebige Kategorie-Strichstärken werden auf die
|
||||
* NÄCHSTHÖHERE dieser ISO-nahen Stufen gequantelt, damit der Plan saubere,
|
||||
* unterscheidbare Linienstärken trägt (dünne Hilfslinien ≠ dicke Umrisse).
|
||||
*/
|
||||
const PEN_STEPS = [0.13, 0.18, 0.25, 0.35, 0.5, 0.7, 1.0] as const;
|
||||
|
||||
/** Untergrenze einer sichtbaren Druck-Strichstärke (mm) — vermeidet 0-Linien. */
|
||||
const MIN_PEN_MM = 0.13;
|
||||
|
||||
/** Quantelt eine mm-Strichstärke auf die nächste Stift-Stufe (≥ MIN_PEN_MM). */
|
||||
function quantizePen(mm: number): number {
|
||||
const w = Math.max(MIN_PEN_MM, mm);
|
||||
for (const step of PEN_STEPS) if (w <= step + 1e-6) return step;
|
||||
return PEN_STEPS[PEN_STEPS.length - 1];
|
||||
}
|
||||
|
||||
/** Ergebnis des Print-SVG-Aufbaus: das Element + Blattmasse (mm). */
|
||||
export interface PrintSvg {
|
||||
/** Das fertige, in mm bemaßte <svg>-Element (für svg2pdf). */
|
||||
svg: SVGSVGElement;
|
||||
/** Blattbreite in mm (= viewBox-Breite). */
|
||||
widthMm: number;
|
||||
/** Blatthöhe in mm (= viewBox-Höhe). */
|
||||
heightMm: number;
|
||||
/** Massstäblicher Inhalts-Rahmen (mm) innerhalb des Blattes (Plan-Bounds). */
|
||||
content: { x: number; y: number; w: number; h: number };
|
||||
}
|
||||
|
||||
export interface PrintSvgOptions {
|
||||
/** Massstab-Nenner N (1:N). 1 m = 1000/N mm auf dem Papier. */
|
||||
scaleDenominator: number;
|
||||
/** Blattbreite in mm (Papierformat, schon Hoch/Quer berücksichtigt). */
|
||||
pageWidthMm: number;
|
||||
/** Blatthöhe in mm. */
|
||||
pageHeightMm: number;
|
||||
/** Rand um den Plan-Inhalt in mm (weisser Blattrand). */
|
||||
marginMm?: number;
|
||||
}
|
||||
|
||||
/** Modell-Meter → Papier-Millimeter im Massstab 1:N. */
|
||||
function mmPerMeter(n: number): number {
|
||||
return 1000 / n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bildet einen Welt-Punkt (Meter) auf Papier-Millimeter ab. Der Inhalt wird so
|
||||
* verschoben, dass die Plan-Bounds (in mm) zentriert auf dem Blatt liegen.
|
||||
* Plan-Y zeigt nach oben → SVG-Y nach unten (Spiegelung), daher das Minus.
|
||||
*/
|
||||
interface Mapper {
|
||||
(p: Vec2): Vec2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt das Druck-SVG aus einem Plan. Reihenfolge der Primitive bleibt
|
||||
* erhalten (Zeichenreihenfolge = Stapelung); Schraffuren werden als geklippte
|
||||
* Linien zwischen Grundfüllung und Umriss eingefügt.
|
||||
*/
|
||||
export function planToPrintSvg(plan: Plan, opts: PrintSvgOptions): PrintSvg {
|
||||
const n = opts.scaleDenominator;
|
||||
const k = mmPerMeter(n); // mm je Meter
|
||||
// `marginMm` ist Teil des Vertrags (Aufrufer kann einen Rand wünschen); da der
|
||||
// Inhalt zentriert wird, liegt er ohnehin innerhalb des Blattes. Wir lesen ihn
|
||||
// bewusst, ohne ihn aktuell für einen Versatz zu nutzen (zentrierte Lage).
|
||||
void (opts.marginMm ?? 10);
|
||||
|
||||
const b = plan.bounds;
|
||||
// Inhalts-Maße in mm (massstäblich), unverschoben.
|
||||
const contentWmm = (b.maxX - b.minX) * k;
|
||||
const contentHmm = (b.maxY - b.minY) * k;
|
||||
|
||||
// Inhalt mittig aufs Blatt setzen. Der nutzbare Bereich ist Blatt minus Rand;
|
||||
// wir zentrieren den Plan-Block darin (clippen aber NICHT — bei zu grossem
|
||||
// Inhalt läuft er über das Blatt hinaus, was der Aufrufer per Format/Massstab
|
||||
// vermeidet; ein Warnhinweis liegt in der UI).
|
||||
const offsetXmm = (opts.pageWidthMm - contentWmm) / 2;
|
||||
// SVG-Y zeigt nach unten: der oberste Welt-Punkt (maxY) liegt oben auf dem
|
||||
// Blatt. Wir mappen maxY → offsetYmm.
|
||||
const offsetYmm = (opts.pageHeightMm - contentHmm) / 2;
|
||||
|
||||
const map: Mapper = (p) => ({
|
||||
x: offsetXmm + (p.x - b.minX) * k,
|
||||
y: offsetYmm + (b.maxY - p.y) * k,
|
||||
});
|
||||
|
||||
const svg = document.createElementNS(SVG_NS, "svg") as SVGSVGElement;
|
||||
svg.setAttribute("xmlns", SVG_NS);
|
||||
// Breite/Höhe als REINE Zahlen (= viewBox-Einheiten), KEINE „mm"-Einheit: so
|
||||
// bildet svg2pdf die viewBox (in mm-Benutzereinheiten) 1:1 auf das mm-Ziel
|
||||
// (doc.svg {width,height}) ab. Eine „297mm"-Einheit würde svg2pdf als
|
||||
// physische Größe lesen und zusätzlich pt-skalieren (Faktor 25.4/72) → falsch.
|
||||
svg.setAttribute("width", String(opts.pageWidthMm));
|
||||
svg.setAttribute("height", String(opts.pageHeightMm));
|
||||
svg.setAttribute("viewBox", `0 0 ${opts.pageWidthMm} ${opts.pageHeightMm}`);
|
||||
|
||||
// Weisses Blatt (deckt die volle Seite).
|
||||
const bg = document.createElementNS(SVG_NS, "rect");
|
||||
bg.setAttribute("x", "0");
|
||||
bg.setAttribute("y", "0");
|
||||
bg.setAttribute("width", String(opts.pageWidthMm));
|
||||
bg.setAttribute("height", String(opts.pageHeightMm));
|
||||
bg.setAttribute("fill", "#ffffff");
|
||||
svg.appendChild(bg);
|
||||
|
||||
// Jedes Primitiv in mm-Knoten übersetzen.
|
||||
for (const p of plan.primitives) {
|
||||
appendPrimitive(svg, p, map, k);
|
||||
}
|
||||
|
||||
return {
|
||||
svg,
|
||||
widthMm: opts.pageWidthMm,
|
||||
heightMm: opts.pageHeightMm,
|
||||
content: { x: offsetXmm, y: offsetYmm, w: contentWmm, h: contentHmm },
|
||||
};
|
||||
}
|
||||
|
||||
/** Schreibt ein einzelnes Primitiv (mit ggf. Schraffur) in das SVG. */
|
||||
function appendPrimitive(
|
||||
svg: SVGSVGElement,
|
||||
p: Primitive,
|
||||
map: Mapper,
|
||||
mmPerM: number,
|
||||
): void {
|
||||
const opacity = p.greyed ? "0.3" : undefined;
|
||||
switch (p.kind) {
|
||||
case "polygon":
|
||||
appendPolygon(svg, p, map, mmPerM, opacity);
|
||||
break;
|
||||
case "line": {
|
||||
const a = map(p.a);
|
||||
const b = map(p.b);
|
||||
const ln = lineNode(a, b, p.color ?? "#111111", quantizePen(p.weightMm), p.dash, mmPerM);
|
||||
if (opacity) ln.setAttribute("opacity", opacity);
|
||||
svg.appendChild(ln);
|
||||
break;
|
||||
}
|
||||
case "arc": {
|
||||
const node = arcNode(p, map, mmPerM);
|
||||
if (opacity) node.setAttribute("opacity", opacity);
|
||||
svg.appendChild(node);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Polygon mit Füllung/Umriss/Schraffur. Reihenfolge wie in der PlanView:
|
||||
* • pattern "none" → reine Füllung (oder „none") + Umriss.
|
||||
* • pattern "solid" → Vollfüllung in der Hatch-Farbe + Umriss.
|
||||
* • Linien/Kreuz/Dämmung → Grundfüllung, dann geklippte Schraffurlinien, dann Umriss.
|
||||
*/
|
||||
function appendPolygon(
|
||||
svg: SVGSVGElement,
|
||||
p: Extract<Primitive, { kind: "polygon" }>,
|
||||
map: Mapper,
|
||||
mmPerM: number,
|
||||
opacity: string | undefined,
|
||||
): void {
|
||||
const ptsMm = p.pts.map(map);
|
||||
const ptsAttr = ptsMm.map((s) => `${round(s.x)},${round(s.y)}`).join(" ");
|
||||
const strokeMm = p.strokeWidthMm > 0 ? quantizePen(p.strokeWidthMm) : 0;
|
||||
|
||||
const g = document.createElementNS(SVG_NS, "g");
|
||||
if (opacity) g.setAttribute("opacity", opacity);
|
||||
|
||||
const pattern = p.hatch.pattern;
|
||||
if (pattern === "none") {
|
||||
g.appendChild(polyNode(ptsAttr, p.fill, p.stroke, strokeMm));
|
||||
} else if (pattern === "solid") {
|
||||
g.appendChild(polyNode(ptsAttr, p.hatch.color, p.stroke, strokeMm));
|
||||
} else {
|
||||
// Grundfüllung (ohne Umriss), dann die geklippten Schraffurlinien, dann der
|
||||
// Umriss obenauf (separat, damit er die Schraffur-Enden überdeckt).
|
||||
if (p.fill !== "none") {
|
||||
g.appendChild(polyNode(ptsAttr, p.fill, "none", 0));
|
||||
}
|
||||
for (const seg of hatchLines(ptsMm, p.hatch, mmPerM)) {
|
||||
g.appendChild(
|
||||
lineNode(
|
||||
seg.a,
|
||||
seg.b,
|
||||
p.hatch.color,
|
||||
quantizePen(p.hatch.lineWeight),
|
||||
p.hatch.dash,
|
||||
mmPerM,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (strokeMm > 0) {
|
||||
g.appendChild(polyNode(ptsAttr, "none", p.stroke, strokeMm));
|
||||
}
|
||||
}
|
||||
|
||||
svg.appendChild(g);
|
||||
}
|
||||
|
||||
/** Erzeugt einen <polygon>-Knoten (mm-Koordinaten als Attribut-String). */
|
||||
function polyNode(
|
||||
ptsAttr: string,
|
||||
fill: string,
|
||||
stroke: string,
|
||||
strokeMm: number,
|
||||
): SVGElement {
|
||||
const el = document.createElementNS(SVG_NS, "polygon");
|
||||
el.setAttribute("points", ptsAttr);
|
||||
el.setAttribute("fill", fill);
|
||||
if (stroke !== "none" && strokeMm > 0) {
|
||||
el.setAttribute("stroke", stroke);
|
||||
el.setAttribute("stroke-width", String(strokeMm));
|
||||
el.setAttribute("stroke-linejoin", "round");
|
||||
} else {
|
||||
el.setAttribute("stroke", "none");
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
/** Erzeugt einen <line>-Knoten in mm; Strichmuster (mm Welt → mm Papier). */
|
||||
function lineNode(
|
||||
a: Vec2,
|
||||
b: Vec2,
|
||||
color: string,
|
||||
strokeMm: number,
|
||||
dash: number[] | null | undefined,
|
||||
_mmPerM: number,
|
||||
): SVGElement {
|
||||
const el = document.createElementNS(SVG_NS, "line");
|
||||
el.setAttribute("x1", String(round(a.x)));
|
||||
el.setAttribute("y1", String(round(a.y)));
|
||||
el.setAttribute("x2", String(round(b.x)));
|
||||
el.setAttribute("y2", String(round(b.y)));
|
||||
el.setAttribute("stroke", color);
|
||||
el.setAttribute("stroke-width", String(strokeMm));
|
||||
el.setAttribute("stroke-linecap", "round");
|
||||
if (dash && dash.length) {
|
||||
// Strichmuster ist in mm Papier definiert (wie weightMm), daher direkt.
|
||||
el.setAttribute("stroke-dasharray", dash.map((d) => round(d)).join(" "));
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt einen Bogen als <path> (A-Kommando). Der Bogen-Radius ist in Metern →
|
||||
* Papier-mm. Drehrichtung aus dem Kreuzprodukt im Papier-Raum (Y nach unten).
|
||||
*/
|
||||
function arcNode(
|
||||
p: Extract<Primitive, { kind: "arc" }>,
|
||||
map: Mapper,
|
||||
mmPerM: number,
|
||||
): SVGElement {
|
||||
const c = map(p.center);
|
||||
const from = map(p.from);
|
||||
const to = map(p.to);
|
||||
const r = p.r * mmPerM;
|
||||
const v1 = { x: from.x - c.x, y: from.y - c.y };
|
||||
const v2 = { x: to.x - c.x, y: to.y - c.y };
|
||||
const cross = v1.x * v2.y - v1.y * v2.x;
|
||||
const sweep = cross > 0 ? 1 : 0;
|
||||
const d = `M ${round(from.x)} ${round(from.y)} A ${round(r)} ${round(r)} 0 0 ${sweep} ${round(to.x)} ${round(to.y)}`;
|
||||
const el = document.createElementNS(SVG_NS, "path");
|
||||
el.setAttribute("d", d);
|
||||
el.setAttribute("fill", "none");
|
||||
el.setAttribute("stroke", "#111111");
|
||||
el.setAttribute("stroke-width", String(quantizePen(p.weightMm)));
|
||||
if (p.dash && p.dash.length) {
|
||||
el.setAttribute("stroke-dasharray", p.dash.map((x) => round(x)).join(" "));
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
/** Auf 3 Nachkommastellen runden (kompaktes, exaktes SVG). */
|
||||
function round(v: number): number {
|
||||
return Math.round(v * 1000) / 1000;
|
||||
}
|
||||
|
||||
// ── Schraffur als geklippte Vektorlinien ─────────────────────────────────────
|
||||
|
||||
interface Seg {
|
||||
a: Vec2;
|
||||
b: Vec2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt die Schraffurlinien eines Polygons (in Papier-mm), auf das Polygon
|
||||
* geklippt. Die Kachelgrösse der Bildschirm-PlanView (8 px bzw. 14 px bei
|
||||
* „insulation", × Maßstab) wird hier in einen mm-Linienabstand übersetzt, damit
|
||||
* der Druck der Bildschirm-Dichte nahekommt:
|
||||
* • diagonal/crosshatch: parallele Linienscharen im Winkel `angle`.
|
||||
* • crosshatch: zusätzlich die um 90° gedrehte Schar.
|
||||
* • insulation: vereinfacht als eine dichtere diagonale Schar (echte Wellen-
|
||||
* linie wäre als Pfad möglich; eine Linienschar liest sich im Druck sauber
|
||||
* und bleibt rein vektoriell — offener Punkt im Report).
|
||||
*/
|
||||
function hatchLines(polyMm: Vec2[], hatch: HatchRender, mmPerM: number): Seg[] {
|
||||
// Kachel der PlanView: 8 viewBox-Einheiten (= px bei dpi-Bezug) × scale. In der
|
||||
// PlanView ist 1 viewBox-Einheit = 1/PX_PER_M m = 1/90 m. Auf Papier sind das
|
||||
// (1/90)·mmPerM mm. So bleibt die Strich-Dichte massstäblich konsistent.
|
||||
const unitMm = (1 / 90) * mmPerM;
|
||||
const tileMm = (hatch.pattern === "insulation" ? 10 : 8) * hatch.scale * unitMm;
|
||||
const spacing = Math.max(0.5, tileMm); // Mindestabstand, sonst zu dicht
|
||||
|
||||
const angle = (hatch.angle * Math.PI) / 180;
|
||||
const segs: Seg[] = [];
|
||||
fillParallel(polyMm, angle, spacing, segs);
|
||||
if (hatch.pattern === "crosshatch") {
|
||||
fillParallel(polyMm, angle + Math.PI / 2, spacing, segs);
|
||||
}
|
||||
if (hatch.pattern === "insulation") {
|
||||
// Zweite, gegenläufige Schar deutet die Dämmungs-Textur an (dichter Filz).
|
||||
fillParallel(polyMm, angle - Math.PI / 4, spacing, segs);
|
||||
}
|
||||
return segs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Füllt das Polygon mit parallelen Linien im gegebenen Winkel/Abstand und klippt
|
||||
* jede Linie auf das Polygon (Scanline über die Polygon-Schnittpunkte). Robust
|
||||
* für konvexe wie konkave (einfache) Polygone.
|
||||
*/
|
||||
function fillParallel(
|
||||
poly: Vec2[],
|
||||
angle: number,
|
||||
spacing: number,
|
||||
out: Seg[],
|
||||
): void {
|
||||
if (poly.length < 3 || spacing <= 0) return;
|
||||
// Linienrichtung d und Normale nrm (Projektionsachse).
|
||||
const dir = { x: Math.cos(angle), y: Math.sin(angle) };
|
||||
const nrm = { x: -dir.y, y: dir.x };
|
||||
|
||||
// Projektion der Polygonpunkte auf die Normale → [tMin, tMax]-Band der Linien.
|
||||
let tMin = Infinity;
|
||||
let tMax = -Infinity;
|
||||
for (const p of poly) {
|
||||
const t = p.x * nrm.x + p.y * nrm.y;
|
||||
if (t < tMin) tMin = t;
|
||||
if (t > tMax) tMax = t;
|
||||
}
|
||||
// Linien an t = tMin + i·spacing; für jede die Schnitt-Intervalle ermitteln.
|
||||
const start = Math.ceil(tMin / spacing) * spacing;
|
||||
for (let t = start; t <= tMax; t += spacing) {
|
||||
const xs: number[] = []; // Parameter entlang dir der Schnittpunkte
|
||||
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
||||
const a = poly[j];
|
||||
const b = poly[i];
|
||||
const ta = a.x * nrm.x + a.y * nrm.y;
|
||||
const tb = b.x * nrm.x + b.y * nrm.y;
|
||||
// Schneidet die Kante das Niveau t?
|
||||
if (ta === tb) continue;
|
||||
const f = (t - ta) / (tb - ta);
|
||||
if (f < 0 || f > 1) continue;
|
||||
const px = a.x + (b.x - a.x) * f;
|
||||
const py = a.y + (b.y - a.y) * f;
|
||||
xs.push(px * dir.x + py * dir.y); // Position entlang dir
|
||||
}
|
||||
if (xs.length < 2) continue;
|
||||
xs.sort((u, v) => u - v);
|
||||
// Paarweise Intervalle = Polygon-Inneres entlang der Linie.
|
||||
for (let k = 0; k + 1 < xs.length; k += 2) {
|
||||
const u0 = xs[k];
|
||||
const u1 = xs[k + 1];
|
||||
if (u1 - u0 < 1e-6) continue;
|
||||
// Punkt = t·nrm + u·dir.
|
||||
out.push({
|
||||
a: { x: nrm.x * t + dir.x * u0, y: nrm.y * t + dir.y * u0 },
|
||||
b: { x: nrm.x * t + dir.x * u1, y: nrm.y * t + dir.y * u1 },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// Geometrie-Helfer für Decken (Slabs) — reine Funktionen über `Vec2`, kein
|
||||
// externer Kernel. Eine Decke ist ein GESCHLOSSENER Umriss (Polygon) plus eine
|
||||
// Dicke; diese Helfer normalisieren den Umriss, berechnen Fläche/Umriss-Prüfung
|
||||
// und liefern eine extrude-fertige Form. Baut auf kernel2d/model/geometry auf.
|
||||
//
|
||||
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||||
|
||||
import type { Vec2 } from "../model/types";
|
||||
import { signedArea, isCCW, vecEqual } from "./kernel2d";
|
||||
|
||||
/** Kleinste sinnvolle Deckenfläche (m²) — darunter gilt der Umriss als entartet. */
|
||||
export const MIN_CEILING_AREA = 1e-4;
|
||||
|
||||
/**
|
||||
* Normalisiert einen Deckenumriss:
|
||||
* • entfernt aufeinanderfolgende (nahezu) gleiche Punkte,
|
||||
* • entfernt einen schließenden Duplikat-Endpunkt (pts[last] == pts[0]),
|
||||
* • erzwingt CCW-Wicklung (positive Fläche) — konsistent für Plan-Füllung und
|
||||
* 3D-Extrusion.
|
||||
* Liefert `null`, wenn nach der Bereinigung < 3 Punkte übrig bleiben (kein
|
||||
* gültiges Polygon).
|
||||
*/
|
||||
export function normalizeOutline(pts: Vec2[]): Vec2[] | null {
|
||||
const clean: Vec2[] = [];
|
||||
for (const p of pts) {
|
||||
if (clean.length === 0 || !vecEqual(clean[clean.length - 1], p)) clean.push(p);
|
||||
}
|
||||
// Schließenden Duplikat-Endpunkt verwerfen.
|
||||
if (clean.length > 1 && vecEqual(clean[0], clean[clean.length - 1])) clean.pop();
|
||||
if (clean.length < 3) return null;
|
||||
// CCW erzwingen.
|
||||
return isCCW(clean) ? clean : clean.slice().reverse();
|
||||
}
|
||||
|
||||
/** Ob ein Umriss ein gültiges (nicht entartetes) Deckenpolygon bildet. */
|
||||
export function isValidOutline(pts: Vec2[]): boolean {
|
||||
const norm = normalizeOutline(pts);
|
||||
return norm != null && ceilingArea(norm) >= MIN_CEILING_AREA;
|
||||
}
|
||||
|
||||
/** Fläche eines Deckenumrisses in m² (immer positiv). */
|
||||
export function ceilingArea(pts: Vec2[]): number {
|
||||
return Math.abs(signedArea(pts));
|
||||
}
|
||||
|
||||
/**
|
||||
* Achsparallele Bounding-Box eines Umrisses (Modell-Meter). Leerer/entarteter
|
||||
* Umriss → Null-Box.
|
||||
*/
|
||||
export function outlineBBox(pts: Vec2[]): {
|
||||
minX: number;
|
||||
minY: number;
|
||||
maxX: number;
|
||||
maxY: number;
|
||||
} {
|
||||
let minX = Infinity,
|
||||
minY = Infinity,
|
||||
maxX = -Infinity,
|
||||
maxY = -Infinity;
|
||||
for (const p of pts) {
|
||||
minX = Math.min(minX, p.x);
|
||||
minY = Math.min(minY, p.y);
|
||||
maxX = Math.max(maxX, p.x);
|
||||
maxY = Math.max(maxY, p.y);
|
||||
}
|
||||
if (!isFinite(minX)) return { minX: 0, minY: 0, maxX: 0, maxY: 0 };
|
||||
return { minX, minY, maxX, maxY };
|
||||
}
|
||||
|
||||
/** Schwerpunkt (Zentroid) des Umriss-Polygons (für HUD/Beschriftung). */
|
||||
export function outlineCentroid(pts: Vec2[]): Vec2 {
|
||||
const a = signedArea(pts);
|
||||
if (Math.abs(a) < 1e-12) {
|
||||
// Entartet: einfacher Mittelwert der Punkte.
|
||||
let sx = 0,
|
||||
sy = 0;
|
||||
for (const p of pts) {
|
||||
sx += p.x;
|
||||
sy += p.y;
|
||||
}
|
||||
const n = pts.length || 1;
|
||||
return { x: sx / n, y: sy / n };
|
||||
}
|
||||
let cx = 0,
|
||||
cy = 0;
|
||||
for (let i = 0; i < pts.length; i++) {
|
||||
const p = pts[i];
|
||||
const q = pts[(i + 1) % pts.length];
|
||||
const cr = p.x * q.y - q.x * p.y;
|
||||
cx += (p.x + q.x) * cr;
|
||||
cy += (p.y + q.y) * cr;
|
||||
}
|
||||
const f = 1 / (6 * a);
|
||||
return { x: cx * f, y: cy * f };
|
||||
}
|
||||
|
||||
/**
|
||||
* Punkt-in-Polygon-Test (Ray-Casting) für einen geschlossenen Umriss — dient dem
|
||||
* Body-Select einer Decke im Grundriss/3D. Randpunkte gelten als innen genug.
|
||||
*/
|
||||
export function pointInOutline(p: Vec2, outline: Vec2[]): boolean {
|
||||
let inside = false;
|
||||
const n = outline.length;
|
||||
for (let i = 0, j = n - 1; i < n; j = i++) {
|
||||
const a = outline[i];
|
||||
const b = outline[j];
|
||||
const intersect =
|
||||
a.y > p.y !== b.y > p.y &&
|
||||
p.x < ((b.x - a.x) * (p.y - a.y)) / (b.y - a.y || 1e-12) + a.x;
|
||||
if (intersect) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
@@ -259,6 +259,157 @@ export function trimSegment(
|
||||
return pieces.filter((_, i) => i !== best);
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick-Trim einer ganzen Kurve (offen ODER geschlossen) an einem Klickpunkt.
|
||||
* Die Kurve `pts`/`closed` wird an ALLEN Schnittpunkten mit den Cuttern in
|
||||
* Bögen zerlegt; der Bogen, der `pick` am nächsten liegt (das angeklickte „weg-
|
||||
* zuschneidende" Stück), wird ENTFERNT. Liefert die verbleibenden Ketten als
|
||||
* `{pts, closed}`-Liste (0..n Stücke):
|
||||
* • OFFEN: der getroffene Bogen fällt weg → 0, 1 oder 2 offene Reststücke.
|
||||
* • GESCHLOSSEN: der Ring wird am Schnitt aufgetrennt; das angeklickte Stück
|
||||
* entfällt → genau ein offenes Reststück (oder leer, wenn alles entfällt).
|
||||
* Ohne Schnitt (oder nur randständige) bleibt die Kurve unverändert (eine Kette).
|
||||
*/
|
||||
export function trimPolyline(
|
||||
pts: Vec2[],
|
||||
closed: boolean,
|
||||
cutters: { pts: Vec2[]; closed: boolean }[],
|
||||
pick: Vec2,
|
||||
): { pts: Vec2[]; closed: boolean }[] {
|
||||
const edges = polylineEdges(pts, closed);
|
||||
if (edges.length === 0) return [{ pts: pts.slice(), closed }];
|
||||
|
||||
// Schnittpunkte je Kante (innen, t ∈ (0,1)) als globale „Knoten" sammeln —
|
||||
// beschrieben durch (edgeIndex, t) und in Kettenreihenfolge sortiert.
|
||||
interface Cut {
|
||||
edge: number;
|
||||
t: number;
|
||||
point: Vec2;
|
||||
}
|
||||
const cuts: Cut[] = [];
|
||||
for (let ei = 0; ei < edges.length; ei++) {
|
||||
const [a1, a2] = edges[ei];
|
||||
for (const c of cutters) {
|
||||
for (const h of segmentPolylineHits(a1, a2, c.pts, c.closed)) {
|
||||
if (h.t > EPS && h.t < 1 - EPS) {
|
||||
cuts.push({ edge: ei, t: h.t, point: h.point });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cuts.sort((p, q) => (p.edge - q.edge) || (p.t - q.t));
|
||||
// Dedup koinzidenter Schnitte (gleiche Kante+t).
|
||||
const cut: Cut[] = [];
|
||||
for (const h of cuts) {
|
||||
const prev = cut[cut.length - 1];
|
||||
if (prev && prev.edge === h.edge && Math.abs(prev.t - h.t) < 1e-6) continue;
|
||||
cut.push(h);
|
||||
}
|
||||
if (cut.length === 0) return [{ pts: pts.slice(), closed }];
|
||||
|
||||
// Hilfsfunktion: Kette von Knoten X (auf Kante eX, tX) zu Knoten Y entlang der
|
||||
// Kurve (vorwärts in Kantenrichtung) als Punktliste materialisieren.
|
||||
const span = (eX: number, pX: Vec2, eY: number, pY: Vec2): Vec2[] => {
|
||||
const out: Vec2[] = [pX];
|
||||
let e = eX;
|
||||
// Über die Vertices NACH dem Startknoten bis zum Endknoten laufen.
|
||||
// Anzahl Kanten (modular über die Kettenlänge), damit auch der Wrap bei
|
||||
// geschlossenen Kurven funktioniert.
|
||||
const total = edges.length;
|
||||
let steps = 0;
|
||||
while (steps <= total) {
|
||||
if (e === eY) break;
|
||||
// Endpunkt der aktuellen Kante hinzufügen, dann zur nächsten Kante.
|
||||
out.push(edges[e][1]);
|
||||
e = (e + 1) % total;
|
||||
steps++;
|
||||
}
|
||||
out.push(pY);
|
||||
return dedupeConsecutive(out);
|
||||
};
|
||||
|
||||
if (!closed) {
|
||||
// Offen: der Pick liegt zwischen zwei Knoten ODER zwischen einem Knoten und
|
||||
// einem Element-Ende. Wir finden das Intervall [lo, hi] der Knoten, die den
|
||||
// Pick einklammern (lo = letzter Knoten vor dem Pick, hi = erster danach),
|
||||
// bzw. das Element-Ende, wenn einseitig kein Schnitt liegt.
|
||||
const pickPos = nearestParamOnChain(edges, pick); // globaler Lauf-Param
|
||||
const cutPos = cut.map((c) => c.edge + c.t);
|
||||
let loIdx = -1;
|
||||
let hiIdx = cut.length;
|
||||
for (let i = 0; i < cut.length; i++) {
|
||||
if (cutPos[i] <= pickPos) loIdx = i;
|
||||
else { hiIdx = i; break; }
|
||||
}
|
||||
// Reststück A: Anfang … loIdx-Knoten. Reststück B: hiIdx-Knoten … Ende.
|
||||
const result: { pts: Vec2[]; closed: boolean }[] = [];
|
||||
if (loIdx >= 0) {
|
||||
const c = cut[loIdx];
|
||||
const head = pts.slice(0, c.edge + 1);
|
||||
head.push(c.point);
|
||||
const d = dedupeConsecutive(head);
|
||||
if (d.length >= 2) result.push({ pts: d, closed: false });
|
||||
}
|
||||
if (hiIdx < cut.length) {
|
||||
const c = cut[hiIdx];
|
||||
const tail: Vec2[] = [c.point];
|
||||
for (let k = c.edge + 1; k < pts.length; k++) tail.push(pts[k]);
|
||||
const d = dedupeConsecutive(tail);
|
||||
if (d.length >= 2) result.push({ pts: d, closed: false });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Geschlossen: der Pick liegt in einem Bogen zwischen zwei aufeinanderfolgenden
|
||||
// Knoten (zyklisch). Diesen Bogen entfernen → der Rest ist EINE offene Kette,
|
||||
// die vom Knoten NACH dem Pick einmal herum bis zum Knoten VOR dem Pick läuft.
|
||||
if (cut.length === 1) {
|
||||
// Nur ein Schnitt am Ring: am Schnitt auftrennen, Pick liegt auf der ganzen
|
||||
// (nun offenen) Kette → nichts Sinnvolles zu entfernen; Ring bleibt.
|
||||
return [{ pts: pts.slice(), closed: true }];
|
||||
}
|
||||
const pickPos = nearestParamOnChain(edges, pick);
|
||||
const cutPos = cut.map((c) => c.edge + c.t);
|
||||
// Bogenindex i: zwischen cut[i] und cut[i+1] (mod), der den Pick enthält.
|
||||
let seg = -1;
|
||||
for (let i = 0; i < cut.length; i++) {
|
||||
const a = cutPos[i];
|
||||
const b = cutPos[(i + 1) % cut.length];
|
||||
const inside =
|
||||
i === cut.length - 1
|
||||
? pickPos >= a || pickPos <= b // Wrap-Bogen über das Ketten-Ende
|
||||
: pickPos >= a && pickPos <= b;
|
||||
if (inside) { seg = i; break; }
|
||||
}
|
||||
if (seg < 0) seg = 0;
|
||||
const from = cut[(seg + 1) % cut.length]; // Knoten NACH dem entfernten Bogen
|
||||
const to = cut[seg]; // Knoten VOR dem entfernten Bogen
|
||||
const chain = span(from.edge, from.point, to.edge, to.point);
|
||||
if (chain.length < 2) return [];
|
||||
return [{ pts: chain, closed: false }];
|
||||
}
|
||||
|
||||
/**
|
||||
* Globaler Lauf-Parameter (edgeIndex + t) des dem Punkt `p` nächstgelegenen
|
||||
* Punktes auf der Kantenfolge — für Quick-Trim, um die Klick-Position entlang der
|
||||
* Kurve zu lokalisieren.
|
||||
*/
|
||||
function nearestParamOnChain(edges: [Vec2, Vec2][], p: Vec2): number {
|
||||
let best = 0;
|
||||
let bestD = Infinity;
|
||||
for (let ei = 0; ei < edges.length; ei++) {
|
||||
const [a, b] = edges[ei];
|
||||
const t = Math.max(0, Math.min(1, projectParam(p, a, b)));
|
||||
const q = add(a, scale(sub(b, a), t));
|
||||
const d = dist(p, q);
|
||||
if (d < bestD) {
|
||||
bestD = d;
|
||||
best = ei + t;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// ── Extend ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
// Geometrie-Helfer für Öffnungen (Fenster/Türen) — reine Funktionen, die eine
|
||||
// `Opening` relativ zu ihrer Wirts-Wand in konkrete Welt-Geometrie auflösen:
|
||||
// • die Öffnungs-Lücke entlang der Wandachse (from/to in Metern),
|
||||
// • das Türblatt (Scharnier → offenes/geschlossenes Ende) + Schwenkbogen,
|
||||
// • die Fenster-Rahmen-/Glaslinien quer über die Lücke,
|
||||
// • die vertikale Lage (UK/OK der Öffnung, absolut in Metern).
|
||||
//
|
||||
// Da JEDE Größe aus der aktuellen Wandachse (`wall.start`/`wall.end`) abgeleitet
|
||||
// wird, folgt eine Öffnung ihrer Wand automatisch, sobald die Wand bewegt wird —
|
||||
// es gibt keine eingebackenen Weltkoordinaten.
|
||||
//
|
||||
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||||
|
||||
import type { Opening, Project, Vec2, Wall } from "../model/types";
|
||||
import { getWallType, wallTypeThickness } from "../model/types";
|
||||
import { add, along, leftNormal, normalize, scale, sub } from "../model/geometry";
|
||||
import { wallReferenceOffset, wallVerticalExtent } from "../model/wall";
|
||||
|
||||
const DEG = Math.PI / 180;
|
||||
|
||||
/** Achslänge einer Wand (Meter). */
|
||||
export function wallAxisLength(wall: Wall): number {
|
||||
return Math.hypot(wall.end.x - wall.start.x, wall.end.y - wall.start.y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Öffnungs-Intervall entlang der Wandachse (from..to in Metern), auf die
|
||||
* Achslänge geklemmt. Liefert null, wenn das Intervall entartet (Breite ≤ 0 oder
|
||||
* vollständig außerhalb der Achse).
|
||||
*/
|
||||
export function openingInterval(
|
||||
wall: Wall,
|
||||
o: Opening,
|
||||
): { from: number; to: number } | null {
|
||||
const axis = wallAxisLength(wall);
|
||||
const from = Math.max(0, Math.min(o.position, axis));
|
||||
const to = Math.max(from, Math.min(o.position + o.width, axis));
|
||||
if (to - from < 1e-4) return null;
|
||||
return { from, to };
|
||||
}
|
||||
|
||||
/**
|
||||
* Vertikale Lage einer Öffnung (absolute Z-Werte in Metern) relativ zur Wand-UK.
|
||||
* Türen sitzen am Boden (sillHeight 0); Fenster ab Brüstung. Die OK wird auf die
|
||||
* Wand-OK geklemmt, damit die Öffnung nie über den Wandkopf hinausragt.
|
||||
*/
|
||||
export function openingVerticalExtent(
|
||||
project: Project,
|
||||
wall: Wall,
|
||||
o: Opening,
|
||||
): { zBottom: number; zTop: number } {
|
||||
const { zBottom, zTop } = wallVerticalExtent(project, wall);
|
||||
const sill = Math.max(0, o.sillHeight);
|
||||
const oBottom = zBottom + sill;
|
||||
const oTop = Math.min(zTop, oBottom + o.height);
|
||||
return { zBottom: oBottom, zTop: oTop };
|
||||
}
|
||||
|
||||
/** Einheits-Laufrichtung (u) und linke Normale (n) der Wandachse. */
|
||||
export function wallAxisFrame(wall: Wall): { u: Vec2; n: Vec2 } {
|
||||
const u = normalize(sub(wall.end, wall.start));
|
||||
return { u, n: leftNormal(u) };
|
||||
}
|
||||
|
||||
/** Die beiden Pfosten-Punkte (auf der Achse) einer Öffnung im Grundriss. */
|
||||
export function openingJambs(
|
||||
wall: Wall,
|
||||
o: Opening,
|
||||
): { jambStart: Vec2; jambEnd: Vec2 } | null {
|
||||
const iv = openingInterval(wall, o);
|
||||
if (!iv) return null;
|
||||
return {
|
||||
jambStart: along(wall.start, wall.end, iv.from),
|
||||
jambEnd: along(wall.start, wall.end, iv.to),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Lücken-Rechteck-Ecken (über die volle Wanddicke), z. B. um im Plan die
|
||||
* Öffnung als Aussparung zu markieren (Auswahl-Highlight). Reihenfolge:
|
||||
* jambStart(+n/2) → jambEnd(+n/2) → jambEnd(−n/2) → jambStart(−n/2).
|
||||
*/
|
||||
export function openingGapQuad(project: Project, wall: Wall, o: Opening): Vec2[] | null {
|
||||
const jambs = openingJambs(wall, o);
|
||||
if (!jambs) return null;
|
||||
const { n } = wallAxisFrame(wall);
|
||||
const total = wallTypeThickness(getWallType(project, wall));
|
||||
const refOff = wallReferenceOffset(wall, total);
|
||||
const outer = refOff + total / 2;
|
||||
const inner = refOff - total / 2;
|
||||
const { jambStart, jambEnd } = jambs;
|
||||
return [
|
||||
add(jambStart, scale(n, outer)),
|
||||
add(jambEnd, scale(n, outer)),
|
||||
add(jambEnd, scale(n, inner)),
|
||||
add(jambStart, scale(n, inner)),
|
||||
];
|
||||
}
|
||||
|
||||
/** Mittelpunkt einer Öffnung im Grundriss (Achse) — für HUD/Auswahl-Anker. */
|
||||
export function openingCenter(wall: Wall, o: Opening): Vec2 | null {
|
||||
const iv = openingInterval(wall, o);
|
||||
if (!iv) return null;
|
||||
return along(wall.start, wall.end, (iv.from + iv.to) / 2);
|
||||
}
|
||||
|
||||
// ── Tür-Symbol (Plan) ────────────────────────────────────────────────────────
|
||||
|
||||
/** Aufgelöste Tür-Symbol-Geometrie im Grundriss (Weltkoordinaten). */
|
||||
export interface DoorSymbol {
|
||||
/** Scharnierpunkt (auf der Achse). */
|
||||
hinge: Vec2;
|
||||
/** Ende des Blatts in GEÖFFNETER Stellung. */
|
||||
openEnd: Vec2;
|
||||
/** Ende des Blatts in GESCHLOSSENER Stellung (Bogen-Startpunkt). */
|
||||
closedEnd: Vec2;
|
||||
/** Radius (= Türbreite) des Schwenkbogens. */
|
||||
radius: number;
|
||||
/** Beide Pfosten-Punkte (für Rahmen-/Anschlagstriche). */
|
||||
jambStart: Vec2;
|
||||
jambEnd: Vec2;
|
||||
/** Achs-Normale (für kurze Anschlagstriche quer zur Wand). */
|
||||
normal: Vec2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Löst die Tür-Symbolik auf. `swing` wählt die Aufschlagseite (linke/rechte
|
||||
* Achs-Normale), `openingDir` ("out") spiegelt sie auf die andere Seite,
|
||||
* `hinge` wählt den Scharnier-Pfosten, `swingAngle` (Grad, Default 90) bestimmt,
|
||||
* wie weit das Blatt in der offenen Stellung aufsteht.
|
||||
*/
|
||||
export function doorSymbol(wall: Wall, o: Opening): DoorSymbol | null {
|
||||
const jambs = openingJambs(wall, o);
|
||||
if (!jambs) return null;
|
||||
const { u, n } = wallAxisFrame(wall);
|
||||
const { jambStart, jambEnd } = jambs;
|
||||
const width = Math.hypot(jambEnd.x - jambStart.x, jambEnd.y - jambStart.y);
|
||||
|
||||
const swingSign = (o.swing ?? "left") === "left" ? 1 : -1;
|
||||
const dirSign = (o.openingDir ?? "in") === "in" ? 1 : -1;
|
||||
const swingDir = scale(n, swingSign * dirSign);
|
||||
|
||||
const hinge = (o.hinge ?? "start") === "start" ? jambStart : jambEnd;
|
||||
// Geschlossene Richtung zeigt vom Scharnier zum GEGENüberliegenden Pfosten.
|
||||
const closedDir = (o.hinge ?? "start") === "start" ? u : scale(u, -1);
|
||||
|
||||
const angle = (o.swingAngle ?? 90) * DEG;
|
||||
// Offenes Blatt-Ende: um `angle` von der geschlossenen Richtung zur
|
||||
// Schwenkrichtung gedreht (Interpolation über die Basis closedDir/swingDir).
|
||||
const openDir = {
|
||||
x: closedDir.x * Math.cos(angle) + swingDir.x * Math.sin(angle),
|
||||
y: closedDir.y * Math.cos(angle) + swingDir.y * Math.sin(angle),
|
||||
};
|
||||
return {
|
||||
hinge,
|
||||
openEnd: add(hinge, scale(openDir, width)),
|
||||
closedEnd: add(hinge, scale(closedDir, width)),
|
||||
radius: width,
|
||||
jambStart,
|
||||
jambEnd,
|
||||
normal: n,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Fenster-Symbol (Plan) ────────────────────────────────────────────────────
|
||||
|
||||
/** Aufgelöste Fenster-Linien im Grundriss (Weltkoordinaten). */
|
||||
export interface WindowSymbol {
|
||||
/** Rahmen-Rechteck (volle Dicke) als Ecken. */
|
||||
frame: Vec2[];
|
||||
/** Glaslinien-Segmente quer über die Lücke (1–2 parallele Linien). */
|
||||
glassLines: [Vec2, Vec2][];
|
||||
}
|
||||
|
||||
/**
|
||||
* Löst die Fenster-Symbolik auf: ein Rahmen-Rechteck über die Lücke (volle
|
||||
* Wanddicke) plus 1–2 Glaslinien (dünne parallele Linien in der Wandmitte,
|
||||
* `count` steuert die Anzahl je Detailgrad).
|
||||
*/
|
||||
export function windowSymbol(
|
||||
project: Project,
|
||||
wall: Wall,
|
||||
o: Opening,
|
||||
glassCount: number,
|
||||
): WindowSymbol | null {
|
||||
const jambs = openingJambs(wall, o);
|
||||
if (!jambs) return null;
|
||||
const { n } = wallAxisFrame(wall);
|
||||
const total = wallTypeThickness(getWallType(project, wall));
|
||||
const refOff = wallReferenceOffset(wall, total);
|
||||
const outer = refOff + total / 2;
|
||||
const inner = refOff - total / 2;
|
||||
const { jambStart, jambEnd } = jambs;
|
||||
const frame = [
|
||||
add(jambStart, scale(n, outer)),
|
||||
add(jambEnd, scale(n, outer)),
|
||||
add(jambEnd, scale(n, inner)),
|
||||
add(jambStart, scale(n, inner)),
|
||||
];
|
||||
const glassLines: [Vec2, Vec2][] = [];
|
||||
const count = Math.max(1, glassCount);
|
||||
for (let i = 0; i < count; i++) {
|
||||
// Glaslinien symmetrisch um die Wandmitte verteilt (Abstand = Dicke/8).
|
||||
const spread = total / 8;
|
||||
const off = refOff + (count === 1 ? 0 : (i - (count - 1) / 2) * spread * 2);
|
||||
glassLines.push([add(jambStart, scale(n, off)), add(jambEnd, scale(n, off))]);
|
||||
}
|
||||
return { frame, glassLines };
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
// Räume (Flächen) — Flächenberechnung und SIA-416-Klassifikation.
|
||||
//
|
||||
// Reiner Rechenkern für die Raum-Flächenbuchhaltung nach SIA 416 (Schweizer
|
||||
// Norm „Flächen und Volumen von Gebäuden"). Keine UI, kein Datei-IO — nur
|
||||
// Polygon-Geometrie, Kategorie-Hierarchie und Aggregation für die Bilanz.
|
||||
//
|
||||
// Bezeichner englisch, Kommentare/Labels deutsch (CONVENTIONS.md). Einheit: METER.
|
||||
|
||||
import type { Vec2 } from "../model/types";
|
||||
|
||||
// ── Polygon-Kennzahlen ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Vorzeichenbehaftete Polygonfläche (Gauss'sche Trapezformel / Shoelace).
|
||||
* >0 = gegen den Uhrzeigersinn (CCW), <0 = im Uhrzeigersinn (CW). Der letzte
|
||||
* Punkt darf NICHT dupliziert sein (die Schlusskante n-1 → 0 wird ergänzt).
|
||||
*/
|
||||
export function signedArea(pts: Vec2[]): number {
|
||||
const n = pts.length;
|
||||
if (n < 3) return 0;
|
||||
let s = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = pts[i];
|
||||
const b = pts[(i + 1) % n];
|
||||
s += a.x * b.y - b.x * a.y;
|
||||
}
|
||||
return s / 2;
|
||||
}
|
||||
|
||||
/** Absolute (immer positive) Polygonfläche in m². */
|
||||
export function polygonArea(pts: Vec2[]): number {
|
||||
return Math.abs(signedArea(pts));
|
||||
}
|
||||
|
||||
/** Umfang eines geschlossenen Polygons (Summe aller Kantenlängen). */
|
||||
export function perimeter(pts: Vec2[]): number {
|
||||
const n = pts.length;
|
||||
if (n < 2) return 0;
|
||||
let p = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = pts[i];
|
||||
const b = pts[(i + 1) % n];
|
||||
p += Math.hypot(b.x - a.x, b.y - a.y);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flächenschwerpunkt (Centroid) eines geschlossenen Polygons — Ankerpunkt für
|
||||
* das Raum-Label. Nutzt die momenten-gewichtete Formel; degeneriert (Fläche ≈ 0)
|
||||
* auf den Durchschnitt der Vertices.
|
||||
*/
|
||||
export function centroid(pts: Vec2[]): Vec2 {
|
||||
const n = pts.length;
|
||||
if (n === 0) return { x: 0, y: 0 };
|
||||
if (n < 3) {
|
||||
let sx = 0;
|
||||
let sy = 0;
|
||||
for (const p of pts) {
|
||||
sx += p.x;
|
||||
sy += p.y;
|
||||
}
|
||||
return { x: sx / n, y: sy / n };
|
||||
}
|
||||
let a = 0;
|
||||
let cx = 0;
|
||||
let cy = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const p = pts[i];
|
||||
const q = pts[(i + 1) % n];
|
||||
const cr = p.x * q.y - q.x * p.y;
|
||||
a += cr;
|
||||
cx += (p.x + q.x) * cr;
|
||||
cy += (p.y + q.y) * cr;
|
||||
}
|
||||
a /= 2;
|
||||
if (Math.abs(a) < 1e-12) {
|
||||
let sx = 0;
|
||||
let sy = 0;
|
||||
for (const p of pts) {
|
||||
sx += p.x;
|
||||
sy += p.y;
|
||||
}
|
||||
return { x: sx / n, y: sy / n };
|
||||
}
|
||||
return { x: cx / (6 * a), y: cy / (6 * a) };
|
||||
}
|
||||
|
||||
// ── SIA-416-Kategorie-Hierarchie ─────────────────────────────────────────────
|
||||
//
|
||||
// SIA 416 gliedert die Geschossfläche (GF) eines Gebäudes hierarchisch:
|
||||
//
|
||||
// GF Geschossfläche
|
||||
// ├── KGF Konstruktionsfläche (Wände, Stützen, Schächte-Wandungen …)
|
||||
// └── NGF Nettogeschossfläche
|
||||
// ├── NF Nutzfläche
|
||||
// │ ├── HNF Hauptnutzfläche (der eigentliche Nutzungszweck)
|
||||
// │ └── NNF Nebennutzfläche (unterstützende Nutzung, Lager, WC …)
|
||||
// ├── VF Verkehrsfläche (Flure, Treppen, Aufzüge, Rampen)
|
||||
// └── FF Funktionsfläche (technische Anlagen, Haustechnik)
|
||||
//
|
||||
// Die „Blatt"-Kategorien, die einem konkreten Raum zugewiesen werden, sind
|
||||
// HNF, NNF, VF, FF und KGF. HNF+NNF = NF; NF+VF+FF = NGF; NGF+KGF = GF.
|
||||
|
||||
/** Blatt-Kategorien nach SIA 416, die einem Raum direkt zugewiesen werden. */
|
||||
export type SiaCategory = "HNF" | "NNF" | "VF" | "FF" | "KGF";
|
||||
|
||||
/** Aggregat-(Zwischensummen-)Ebenen der SIA-416-Hierarchie. */
|
||||
export type SiaAggregate = "NF" | "NGF" | "GF";
|
||||
|
||||
/** Alle in der Bilanz vorkommenden Schlüssel (Blatt + Aggregat). */
|
||||
export type SiaKey = SiaCategory | SiaAggregate;
|
||||
|
||||
/** Reihenfolge der Blatt-Kategorien für stabile Tabellen/CSV-Ausgabe. */
|
||||
export const SIA_CATEGORIES: readonly SiaCategory[] = [
|
||||
"HNF",
|
||||
"NNF",
|
||||
"VF",
|
||||
"FF",
|
||||
"KGF",
|
||||
] as const;
|
||||
|
||||
/** Deutsche Bezeichnungen (Kurz + Lang) je SIA-416-Schlüssel. */
|
||||
export interface SiaLabel {
|
||||
/** Kürzel, z. B. „HNF". */
|
||||
code: SiaKey;
|
||||
/** Ausgeschriebene deutsche Bezeichnung. */
|
||||
name: string;
|
||||
}
|
||||
|
||||
const SIA_LABELS: Record<SiaKey, string> = {
|
||||
HNF: "Hauptnutzfläche",
|
||||
NNF: "Nebennutzfläche",
|
||||
VF: "Verkehrsfläche",
|
||||
FF: "Funktionsfläche",
|
||||
KGF: "Konstruktionsfläche",
|
||||
NF: "Nutzfläche",
|
||||
NGF: "Nettogeschossfläche",
|
||||
GF: "Geschossfläche",
|
||||
};
|
||||
|
||||
/** Deutsche Bezeichnung zu einem SIA-Schlüssel. */
|
||||
export function siaLabel(key: SiaKey): string {
|
||||
return SIA_LABELS[key];
|
||||
}
|
||||
|
||||
/** „HNF — Hauptnutzfläche" o. ä. für Legenden/Dropdowns. */
|
||||
export function siaLabelFull(key: SiaKey): string {
|
||||
return `${key} — ${SIA_LABELS[key]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zuordnung Blatt-Kategorie → übergeordnete Aggregate. Eine HNF trägt zu
|
||||
* NF, NGF und GF bei; eine KGF nur zu GF; VF/FF zu NGF und GF; NNF wie HNF.
|
||||
* Wird für den Bilanz-Roll-up genutzt.
|
||||
*/
|
||||
const CATEGORY_ROLLUP: Record<SiaCategory, SiaAggregate[]> = {
|
||||
HNF: ["NF", "NGF", "GF"],
|
||||
NNF: ["NF", "NGF", "GF"],
|
||||
VF: ["NGF", "GF"],
|
||||
FF: ["NGF", "GF"],
|
||||
KGF: ["GF"],
|
||||
};
|
||||
|
||||
// ── Raum-Flächenergebnis ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Ergebnis der Flächenbewertung eines einzelnen Raums.
|
||||
*
|
||||
* `grossArea` ist die geometrische Polygonfläche (Bruttofläche der Raum-Kontur).
|
||||
* `netArea` ist die anrechenbare Netto-Nutzfläche: standardmässig gleich der
|
||||
* Bruttofläche (die Raum-Kontur ist bereits die lichte Innenfläche zwischen den
|
||||
* Wänden). Über einen optionalen Abzug (`deduction`, z. B. für nicht anrechen-
|
||||
* bare Bereiche mit lichter Höhe < 1.0 m nach SIA 416) kann `netArea` reduziert
|
||||
* werden. `category`/`label` tragen die SIA-Klassifikation.
|
||||
*/
|
||||
export interface RoomAreaResult {
|
||||
grossArea: number;
|
||||
netArea: number;
|
||||
perimeter: number;
|
||||
centroid: Vec2;
|
||||
category: SiaCategory;
|
||||
/** Deutsche Bezeichnung der Kategorie (z. B. „Hauptnutzfläche"). */
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** Optionen für die Flächenbewertung eines Raums. */
|
||||
export interface RoomAreaOptions {
|
||||
/**
|
||||
* Abzug von der Bruttofläche (m²), z. B. für Bereiche mit lichter Höhe unter
|
||||
* dem SIA-416-Grenzwert, die nicht zur anrechenbaren Fläche zählen. Default 0.
|
||||
*/
|
||||
deduction?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bewertet ein Raum-Polygon: berechnet Brutto-/Nettofläche, Umfang, Schwerpunkt
|
||||
* und hängt die SIA-416-Kategorie samt deutschem Label an.
|
||||
*/
|
||||
export function evaluateRoom(
|
||||
polygon: Vec2[],
|
||||
category: SiaCategory,
|
||||
options: RoomAreaOptions = {},
|
||||
): RoomAreaResult {
|
||||
const gross = polygonArea(polygon);
|
||||
const deduction = Math.max(0, options.deduction ?? 0);
|
||||
const net = Math.max(0, gross - deduction);
|
||||
return {
|
||||
grossArea: gross,
|
||||
netArea: net,
|
||||
perimeter: perimeter(polygon),
|
||||
centroid: centroid(polygon),
|
||||
category,
|
||||
label: SIA_LABELS[category],
|
||||
};
|
||||
}
|
||||
|
||||
// ── Bilanz (Flächenaufstellung) ──────────────────────────────────────────────
|
||||
|
||||
/** Ein Zeileneintrag der Bilanz: Schlüssel, Label, Fläche, Raumanzahl. */
|
||||
export interface BalanceRow {
|
||||
key: SiaKey;
|
||||
label: string;
|
||||
area: number;
|
||||
/** Anzahl Räume, die zu dieser (Blatt-)Kategorie zählen. Für Aggregate: Summe. */
|
||||
count: number;
|
||||
/** true, wenn es sich um eine Zwischensumme (NF/NGF/GF) handelt. */
|
||||
aggregate: boolean;
|
||||
}
|
||||
|
||||
/** Vollständige SIA-416-Bilanz: geordnete Zeilen + Gesamtsumme (GF). */
|
||||
export interface Balance {
|
||||
rows: BalanceRow[];
|
||||
/** Schneller Zugriff auf die Fläche je Schlüssel. */
|
||||
byKey: Record<SiaKey, number>;
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregiert Räume je SIA-416-Kategorie und rollt die Zwischensummen
|
||||
* (NF, NGF, GF) korrekt auf. Erwartet je Raum die Blatt-Kategorie und die
|
||||
* anzurechnende Fläche (i. d. R. `netArea` aus {@link evaluateRoom}).
|
||||
*
|
||||
* Die zurückgegebenen `rows` sind in kanonischer Reihenfolge:
|
||||
* HNF, NNF, NF(=Σ), VF, FF, NGF(=Σ), KGF, GF(=Σ)
|
||||
* — passend für die Bilanz-Tabelle und den CSV-Export.
|
||||
*/
|
||||
export function balance(
|
||||
rooms: { category: SiaCategory; area: number }[],
|
||||
): Balance {
|
||||
const leaf: Record<SiaCategory, number> = { HNF: 0, NNF: 0, VF: 0, FF: 0, KGF: 0 };
|
||||
const leafCount: Record<SiaCategory, number> = { HNF: 0, NNF: 0, VF: 0, FF: 0, KGF: 0 };
|
||||
const agg: Record<SiaAggregate, number> = { NF: 0, NGF: 0, GF: 0 };
|
||||
const aggCount: Record<SiaAggregate, number> = { NF: 0, NGF: 0, GF: 0 };
|
||||
|
||||
for (const r of rooms) {
|
||||
const area = Number.isFinite(r.area) ? r.area : 0;
|
||||
leaf[r.category] += area;
|
||||
leafCount[r.category] += 1;
|
||||
for (const a of CATEGORY_ROLLUP[r.category]) {
|
||||
agg[a] += area;
|
||||
aggCount[a] += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const byKey: Record<SiaKey, number> = {
|
||||
HNF: leaf.HNF,
|
||||
NNF: leaf.NNF,
|
||||
VF: leaf.VF,
|
||||
FF: leaf.FF,
|
||||
KGF: leaf.KGF,
|
||||
NF: agg.NF,
|
||||
NGF: agg.NGF,
|
||||
GF: agg.GF,
|
||||
};
|
||||
|
||||
const mkLeaf = (k: SiaCategory): BalanceRow => ({
|
||||
key: k,
|
||||
label: SIA_LABELS[k],
|
||||
area: leaf[k],
|
||||
count: leafCount[k],
|
||||
aggregate: false,
|
||||
});
|
||||
const mkAgg = (k: SiaAggregate): BalanceRow => ({
|
||||
key: k,
|
||||
label: SIA_LABELS[k],
|
||||
area: agg[k],
|
||||
count: aggCount[k],
|
||||
aggregate: true,
|
||||
});
|
||||
|
||||
const rows: BalanceRow[] = [
|
||||
mkLeaf("HNF"),
|
||||
mkLeaf("NNF"),
|
||||
mkAgg("NF"),
|
||||
mkLeaf("VF"),
|
||||
mkLeaf("FF"),
|
||||
mkAgg("NGF"),
|
||||
mkLeaf("KGF"),
|
||||
mkAgg("GF"),
|
||||
];
|
||||
|
||||
return { rows, byKey, total: agg.GF };
|
||||
}
|
||||
|
||||
// ── CSV-Serialisierung ───────────────────────────────────────────────────────
|
||||
|
||||
/** Ein Raum, wie er in den CSV-Export einfliesst. */
|
||||
export interface RoomCsvRecord {
|
||||
/** Raum-Nummer/-Kennung (frei), z. B. „R.01". */
|
||||
number?: string;
|
||||
/** Raum-Name, z. B. „Wohnen". */
|
||||
name?: string;
|
||||
/** Geschoss/Ebene, optional. */
|
||||
level?: string;
|
||||
category: SiaCategory;
|
||||
/** Anzurechnende Fläche (m²) — i. d. R. netArea. */
|
||||
area: number;
|
||||
}
|
||||
|
||||
/** Ein Feld CSV-sicher quoten (RFC 4180: „..." bei , " \n; " → ""). */
|
||||
function csvField(value: string): string {
|
||||
if (/[",\n\r;]/.test(value)) {
|
||||
return `"${value.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Fläche auf 2 Nachkommastellen, Punkt als Dezimaltrenner (m²). */
|
||||
function fmtArea(area: number): string {
|
||||
return (Math.round(area * 100) / 100).toFixed(2);
|
||||
}
|
||||
|
||||
export interface RoomsToCsvOptions {
|
||||
/** Feldtrenner. Default „;" (Excel-DE-freundlich). */
|
||||
delimiter?: string;
|
||||
/** true (Default): Bilanz-Zwischensummen unter die Raumliste anhängen. */
|
||||
includeBalance?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialisiert eine Raumliste als CSV (reiner String, kein Datei-IO) für den
|
||||
* späteren Export. Kopfzeile deutsch; optional folgt die SIA-416-Bilanz als
|
||||
* Summenblock. Nutzt `;` als Trenner (Standard im DE-Excel-Umfeld).
|
||||
*/
|
||||
export function roomsToCsv(
|
||||
rooms: RoomCsvRecord[],
|
||||
options: RoomsToCsvOptions = {},
|
||||
): string {
|
||||
const d = options.delimiter ?? ";";
|
||||
const includeBalance = options.includeBalance ?? true;
|
||||
const lines: string[] = [];
|
||||
|
||||
const header = ["Nummer", "Name", "Geschoss", "Kategorie", "Bezeichnung", "Fläche [m²]"];
|
||||
lines.push(header.map(csvField).join(d));
|
||||
|
||||
for (const r of rooms) {
|
||||
lines.push(
|
||||
[
|
||||
csvField(r.number ?? ""),
|
||||
csvField(r.name ?? ""),
|
||||
csvField(r.level ?? ""),
|
||||
csvField(r.category),
|
||||
csvField(SIA_LABELS[r.category]),
|
||||
fmtArea(r.area),
|
||||
].join(d),
|
||||
);
|
||||
}
|
||||
|
||||
if (includeBalance) {
|
||||
const bal = balance(rooms.map((r) => ({ category: r.category, area: r.area })));
|
||||
lines.push(""); // Leerzeile trennt Raumliste von Bilanz
|
||||
lines.push([csvField("Bilanz (SIA 416)"), "", "", "", "", ""].join(d));
|
||||
for (const row of bal.rows) {
|
||||
lines.push(
|
||||
[
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
csvField(row.key),
|
||||
csvField(row.label),
|
||||
fmtArea(row.area),
|
||||
].join(d),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\r\n");
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
// Raum-Erkennung — geschlossene Raum-Umrisse aus Wandsegmenten ableiten.
|
||||
//
|
||||
// Zwei komplementäre Ansätze:
|
||||
//
|
||||
// 1. detectRooms(walls) — baut die planare Anordnung (Arrangement) der Wand-
|
||||
// MITTELLINIEN, schneidet alle Kanten an ihren Kreuzungen, snappt kleine
|
||||
// Lücken zu und extrahiert die minimalen geschlossenen Maschen (Faces) des
|
||||
// Graphen über das „next edge counter-clockwise"-Verfahren (half-edge face
|
||||
// tracing). Die unbeschränkte Aussenmasche wird verworfen. Für jede innere
|
||||
// Masche wird die lichte Innenkontur berechnet, indem die Masche um die
|
||||
// halbe Wanddicke nach innen versetzt wird (Miter-Offset).
|
||||
//
|
||||
// 2. roomFromPointInside(point, wallFaces) — klick-in-Raum-Fallback: umläuft
|
||||
// ausgehend von einem Saatpunkt die den Punkt einschliessende Masche direkt
|
||||
// (gap-tolerant, wie die meisten CAD-Werkzeuge für „Raum durch Klick"). Er
|
||||
// liefert die lichte Innenkontur der angeklickten Zelle.
|
||||
//
|
||||
// Grenzen (ehrlich): Der Arrangement-Ansatz behandelt Wände als 1D-Mittellinien;
|
||||
// überlappende/kollineare Doppelwände oder gekrümmte Wände werden nicht speziell
|
||||
// aufgelöst. Lücken unterhalb `gapTol` werden per Endpunkt-Snapping geschlossen;
|
||||
// grössere Öffnungen (Türen) müssen als geschlossen modelliert oder vom Aufrufer
|
||||
// überbrückt werden. Der Offset nach innen nutzt Miter-Joins ohne Selbstschnitt-
|
||||
// Heilung — für orthogonale/konvexe Grundrisse (der Normalfall) exakt, bei sehr
|
||||
// spitzen einspringenden Ecken approximativ.
|
||||
//
|
||||
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). Einheit: METER.
|
||||
|
||||
import type { Vec2 } from "../model/types";
|
||||
import { signedArea, polygonArea } from "./roomArea";
|
||||
|
||||
/** Ein Wandsegment: Mittellinie a→b mit Dicke (beidseitig je thickness/2). */
|
||||
export interface WallSegment {
|
||||
a: Vec2;
|
||||
b: Vec2;
|
||||
thickness: number;
|
||||
}
|
||||
|
||||
/** Parameter der Raum-Erkennung. */
|
||||
export interface DetectRoomsOptions {
|
||||
/** Lücken/Endpunkt-Abstände ≤ gapTol (m) werden zusammengeschnappt. Default 0.05. */
|
||||
gapTol?: number;
|
||||
/** Kleinste als Raum akzeptierte Fläche (m²). Default 0.05. */
|
||||
minArea?: number;
|
||||
/**
|
||||
* true (Default): Maschen um halbe Wanddicke nach innen versetzen, sodass die
|
||||
* zurückgegebenen Loops die LICHTE Innenkontur sind. false: Wand-Mittellinien-
|
||||
* Maschen (roh) zurückgeben.
|
||||
*/
|
||||
offsetToInner?: boolean;
|
||||
}
|
||||
|
||||
// ── Vektorhelfer (lokal, ohne Fremdabhängigkeit) ─────────────────────────────
|
||||
|
||||
const sub = (a: Vec2, b: Vec2): Vec2 => ({ x: a.x - b.x, y: a.y - b.y });
|
||||
const add = (a: Vec2, b: Vec2): Vec2 => ({ x: a.x + b.x, y: a.y + b.y });
|
||||
const scale = (a: Vec2, s: number): Vec2 => ({ x: a.x * s, y: a.y * s });
|
||||
const cross = (a: Vec2, b: Vec2): number => a.x * b.y - a.y * b.x;
|
||||
const len = (a: Vec2): number => Math.hypot(a.x, a.y);
|
||||
const normalize = (a: Vec2): Vec2 => {
|
||||
const l = len(a);
|
||||
return l < 1e-12 ? { x: 0, y: 0 } : { x: a.x / l, y: a.y / l };
|
||||
};
|
||||
/** Linke Normale (90° gegen den Uhrzeigersinn). */
|
||||
const leftNormal = (a: Vec2): Vec2 => ({ x: -a.y, y: a.x });
|
||||
const dist = (a: Vec2, b: Vec2): number => Math.hypot(a.x - b.x, a.y - b.y);
|
||||
|
||||
const EPS = 1e-9;
|
||||
|
||||
// ── Planarer Graph (Knoten + ungerichtete Kanten) ────────────────────────────
|
||||
|
||||
interface Node {
|
||||
p: Vec2;
|
||||
}
|
||||
|
||||
interface UEdge {
|
||||
u: number; // Knotenindex
|
||||
v: number; // Knotenindex
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut aus den Wand-Mittellinien einen planaren Graphen: Endpunkte werden mit
|
||||
* Toleranz `snap` verschmolzen, jedes Segment an allen echten Kreuzungen mit
|
||||
* anderen Segmenten unterteilt, kollineare Duplikate entfernt.
|
||||
*/
|
||||
function buildPlanarGraph(
|
||||
segments: { a: Vec2; b: Vec2 }[],
|
||||
snap: number,
|
||||
): { nodes: Node[]; edges: UEdge[] } {
|
||||
const nodes: Node[] = [];
|
||||
|
||||
// Knoten mit Snapping-Toleranz einfügen (linearer Scan; für Grundriss-Grössen
|
||||
// ausreichend).
|
||||
const addNode = (p: Vec2): number => {
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
if (dist(nodes[i].p, p) <= snap) return i;
|
||||
}
|
||||
nodes.push({ p: { x: p.x, y: p.y } });
|
||||
return nodes.length - 1;
|
||||
};
|
||||
|
||||
// Für jedes Segment: alle Split-Parameter t sammeln (Endpunkte + Kreuzungen).
|
||||
const raw = segments
|
||||
.map((s) => ({ a: s.a, b: s.b }))
|
||||
.filter((s) => dist(s.a, s.b) > snap);
|
||||
|
||||
interface Split {
|
||||
t: number;
|
||||
p: Vec2;
|
||||
}
|
||||
|
||||
const perSegment: Split[][] = raw.map((s) => [
|
||||
{ t: 0, p: s.a },
|
||||
{ t: 1, p: s.b },
|
||||
]);
|
||||
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
for (let j = i + 1; j < raw.length; j++) {
|
||||
const A = raw[i];
|
||||
const B = raw[j];
|
||||
const da = sub(A.b, A.a);
|
||||
const db = sub(B.b, B.a);
|
||||
const denom = cross(da, db);
|
||||
if (Math.abs(denom) < EPS) continue; // parallel/kollinear
|
||||
const t = cross(sub(B.a, A.a), db) / denom;
|
||||
const s = cross(sub(B.a, A.a), da) / denom;
|
||||
// Echter innerer Schnitt (Endpunkt-Berührungen sind bereits Knoten).
|
||||
const tolT = snap / Math.max(len(da), 1e-9);
|
||||
const tolS = snap / Math.max(len(db), 1e-9);
|
||||
if (t < -tolT || t > 1 + tolT || s < -tolS || s > 1 + tolS) continue;
|
||||
const p = add(A.a, scale(da, t));
|
||||
perSegment[i].push({ t, p });
|
||||
perSegment[j].push({ t: s, p });
|
||||
}
|
||||
}
|
||||
|
||||
const edgeSet = new Set<string>();
|
||||
const edges: UEdge[] = [];
|
||||
const pushEdge = (u: number, v: number) => {
|
||||
if (u === v) return;
|
||||
const key = u < v ? `${u}_${v}` : `${v}_${u}`;
|
||||
if (edgeSet.has(key)) return;
|
||||
edgeSet.add(key);
|
||||
edges.push({ u, v });
|
||||
};
|
||||
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
const splits = perSegment[i].sort((p, q) => p.t - q.t);
|
||||
let prev = -1;
|
||||
let prevT = -Infinity;
|
||||
for (const sp of splits) {
|
||||
if (sp.t - prevT < 1e-9 && prev >= 0) continue;
|
||||
const idx = addNode(sp.p);
|
||||
if (prev >= 0 && idx !== prev) pushEdge(prev, idx);
|
||||
prev = idx;
|
||||
prevT = sp.t;
|
||||
}
|
||||
}
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
// ── Face-Extraktion über gerichtete Halbkanten ───────────────────────────────
|
||||
|
||||
interface HalfEdge {
|
||||
from: number;
|
||||
to: number;
|
||||
/** Ausgangswinkel from→to (für die CCW-Sortierung an jedem Knoten). */
|
||||
angle: number;
|
||||
used: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrahiert die minimalen geschlossenen Maschen (Faces) des planaren Graphen.
|
||||
* Verfahren: jede ungerichtete Kante wird zu zwei Halbkanten. An jedem Knoten
|
||||
* werden die abgehenden Halbkanten nach Winkel sortiert. Beim Umlaufen einer
|
||||
* Masche wählt man an jedem Zielknoten stets die Halbkante, die im Uhrzeigersinn
|
||||
* unmittelbar VOR der eingehenden (umgekehrten) liegt — das „next edge"-Kriterium
|
||||
* für konsistente Face-Umläufe. Jede Halbkante gehört zu genau einer Masche.
|
||||
* Die (einzige) Masche mit positiver Fläche und maximaler |Fläche|, deren Umlauf
|
||||
* die anderen umschliesst, ist die Aussenmasche und wird verworfen.
|
||||
*/
|
||||
function extractFaces(nodes: Node[], edges: UEdge[]): number[][] {
|
||||
// Adjazenz: je Knoten die Indizes seiner abgehenden Halbkanten.
|
||||
const halfEdges: HalfEdge[] = [];
|
||||
const outgoing: number[][] = nodes.map(() => []);
|
||||
|
||||
const angleOf = (from: number, to: number): number => {
|
||||
const d = sub(nodes[to].p, nodes[from].p);
|
||||
return Math.atan2(d.y, d.x);
|
||||
};
|
||||
|
||||
for (const e of edges) {
|
||||
const h1 = halfEdges.length;
|
||||
halfEdges.push({ from: e.u, to: e.v, angle: angleOf(e.u, e.v), used: false });
|
||||
outgoing[e.u].push(h1);
|
||||
const h2 = halfEdges.length;
|
||||
halfEdges.push({ from: e.v, to: e.u, angle: angleOf(e.v, e.u), used: false });
|
||||
outgoing[e.v].push(h2);
|
||||
}
|
||||
|
||||
// Abgehende Halbkanten je Knoten nach Winkel sortieren.
|
||||
for (const list of outgoing) {
|
||||
list.sort((h1, h2) => halfEdges[h1].angle - halfEdges[h2].angle);
|
||||
}
|
||||
// Positionsindex je Halbkante in der sortierten Liste seines from-Knotens.
|
||||
const posInList = new Map<number, number>();
|
||||
for (const list of outgoing) {
|
||||
for (let k = 0; k < list.length; k++) posInList.set(list[k], k);
|
||||
}
|
||||
|
||||
// Zwilling (umgekehrte Halbkante): Paare liegen benachbart (2i, 2i+1).
|
||||
const twin = (h: number): number => (h % 2 === 0 ? h + 1 : h - 1);
|
||||
|
||||
const faces: number[][] = [];
|
||||
|
||||
for (let start = 0; start < halfEdges.length; start++) {
|
||||
if (halfEdges[start].used) continue;
|
||||
const faceNodes: number[] = [];
|
||||
let h = start;
|
||||
let guard = 0;
|
||||
const maxSteps = halfEdges.length + 2;
|
||||
do {
|
||||
halfEdges[h].used = true;
|
||||
faceNodes.push(halfEdges[h].from);
|
||||
// An to = halfEdges[h].to die nächste Halbkante wählen: rund um den Knoten
|
||||
// die im Uhrzeigersinn nächste NACH dem Zwilling der eingehenden Kante.
|
||||
const tw = twin(h);
|
||||
const list = outgoing[halfEdges[h].to];
|
||||
const pos = posInList.get(tw)!;
|
||||
const nextPos = (pos - 1 + list.length) % list.length; // im Uhrzeigersinn davor
|
||||
h = list[nextPos];
|
||||
guard++;
|
||||
} while (h !== start && guard < maxSteps);
|
||||
if (faceNodes.length >= 3) faces.push(faceNodes);
|
||||
}
|
||||
|
||||
// Aussenmasche(n) verwerfen: eine Masche ist „innen", wenn sie CCW orientiert
|
||||
// ist (positive Shoelace-Fläche). Die Aussenmasche läuft CW (negative Fläche).
|
||||
const inner: number[][] = [];
|
||||
for (const f of faces) {
|
||||
const poly = f.map((n) => nodes[n].p);
|
||||
if (signedArea(poly) > EPS) inner.push(f);
|
||||
}
|
||||
return inner;
|
||||
}
|
||||
|
||||
// ── Innen-Offset einer Masche (halbe Wanddicke) ──────────────────────────────
|
||||
|
||||
/**
|
||||
* Versetzt ein CCW-Polygon um `d` nach innen (jede Kante um d nach rechts, da
|
||||
* CCW → Innenseite rechts der Laufrichtung? Nein: bei CCW liegt das Innere LINKS.
|
||||
* Wir verschieben daher jede Kante um d in Richtung Inneres = linke Normale) via
|
||||
* Miter-Join. Bei ungültigem Schnitt (fast parallel) wird der verschobene Eck-
|
||||
* punkt direkt genommen.
|
||||
*/
|
||||
function offsetInward(poly: Vec2[], d: number): Vec2[] {
|
||||
const n = poly.length;
|
||||
if (n < 3 || d <= 0) return poly.slice();
|
||||
// Sicherstellen: CCW (Inneres links). Falls CW, umdrehen.
|
||||
const pts = signedArea(poly) > 0 ? poly : poly.slice().reverse();
|
||||
// Verschobene Kanten (Inneres liegt links → linke Normale zeigt nach innen).
|
||||
const shifted: { p: Vec2; dir: Vec2 }[] = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = pts[i];
|
||||
const b = pts[(i + 1) % n];
|
||||
const dir = normalize(sub(b, a));
|
||||
const nrm = leftNormal(dir); // zeigt ins Innere bei CCW
|
||||
shifted.push({ p: add(a, scale(nrm, d)), dir });
|
||||
}
|
||||
const out: Vec2[] = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const prev = shifted[(i - 1 + n) % n];
|
||||
const curr = shifted[i];
|
||||
// Schnitt der beiden verschobenen (unendlichen) Geraden.
|
||||
const denom = cross(prev.dir, curr.dir);
|
||||
if (Math.abs(denom) < 1e-9) {
|
||||
out.push(curr.p); // (nahezu) kollinear → Startpunkt der aktuellen Kante
|
||||
continue;
|
||||
}
|
||||
const t = cross(sub(curr.p, prev.p), curr.dir) / denom;
|
||||
out.push(add(prev.p, scale(prev.dir, t)));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Dedupliziert aufeinanderfolgende (nahezu) gleiche Punkte im Ring. */
|
||||
function dedupeRing(pts: Vec2[], tol = 1e-7): Vec2[] {
|
||||
const out: Vec2[] = [];
|
||||
for (const p of pts) {
|
||||
const last = out[out.length - 1];
|
||||
if (!last || dist(last, p) > tol) out.push(p);
|
||||
}
|
||||
if (out.length > 1 && dist(out[0], out[out.length - 1]) <= tol) out.pop();
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Durchschnittliche Wanddicke der Segmente, die eine Kante bilden (Fallback 0). */
|
||||
function averageThickness(walls: WallSegment[]): number {
|
||||
if (walls.length === 0) return 0;
|
||||
let s = 0;
|
||||
for (const w of walls) s += w.thickness;
|
||||
return s / walls.length;
|
||||
}
|
||||
|
||||
// ── Öffentliche API ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Erkennt geschlossene Räume aus einer Menge von Wandsegmenten (Mittellinien
|
||||
* mit Dicke). Liefert die lichten Innen-Umrisse (Vec2[] je Raum), CCW orientiert.
|
||||
*
|
||||
* Ablauf: planarer Graph aus Mittellinien → Face-Extraktion → Aussenmasche
|
||||
* verwerfen → jede innere Masche um halbe (mittlere) Wanddicke nach innen
|
||||
* versetzen. Kleine Lücken werden über `gapTol` zusammengeschnappt.
|
||||
*/
|
||||
export function detectRooms(
|
||||
walls: WallSegment[],
|
||||
options: DetectRoomsOptions = {},
|
||||
): Vec2[][] {
|
||||
const gapTol = options.gapTol ?? 0.05;
|
||||
const minArea = options.minArea ?? 0.05;
|
||||
const offsetToInner = options.offsetToInner ?? true;
|
||||
|
||||
if (walls.length < 3) return [];
|
||||
|
||||
const { nodes, edges } = buildPlanarGraph(
|
||||
walls.map((w) => ({ a: w.a, b: w.b })),
|
||||
gapTol,
|
||||
);
|
||||
if (edges.length < 3) return [];
|
||||
|
||||
const faces = extractFaces(nodes, edges);
|
||||
const half = averageThickness(walls) / 2;
|
||||
|
||||
const rooms: Vec2[][] = [];
|
||||
for (const f of faces) {
|
||||
let poly = f.map((n) => nodes[n].p);
|
||||
poly = dedupeRing(poly);
|
||||
if (poly.length < 3) continue;
|
||||
if (offsetToInner && half > 0) {
|
||||
poly = dedupeRing(offsetInward(poly, half));
|
||||
if (poly.length < 3) continue;
|
||||
}
|
||||
if (polygonArea(poly) < minArea) continue;
|
||||
// Kanonisch CCW zurückgeben.
|
||||
if (signedArea(poly) < 0) poly = poly.reverse();
|
||||
rooms.push(poly);
|
||||
}
|
||||
return rooms;
|
||||
}
|
||||
|
||||
// ── Klick-in-Raum: Umlauf-Tracer um einen Saatpunkt ──────────────────────────
|
||||
|
||||
/** Eine Wand-Innenfläche (Face) als gerichtete/ungerichtete Strecke. */
|
||||
export interface WallFace {
|
||||
a: Vec2;
|
||||
b: Vec2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, ob ein Punkt in einem Polygon liegt (Ray-Casting, ungerade Kreuzungen).
|
||||
*/
|
||||
export function pointInPolygon(p: Vec2, poly: Vec2[]): boolean {
|
||||
let inside = false;
|
||||
const n = poly.length;
|
||||
for (let i = 0, j = n - 1; i < n; j = i++) {
|
||||
const a = poly[i];
|
||||
const b = poly[j];
|
||||
const intersects =
|
||||
a.y > p.y !== b.y > p.y &&
|
||||
p.x < ((b.x - a.x) * (p.y - a.y)) / (b.y - a.y + 0) + a.x;
|
||||
if (intersects) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
/**
|
||||
* Klick-in-Raum-Fallback: findet die Masche, die den Saatpunkt einschliesst, und
|
||||
* gibt ihren lichten Innen-Umriss zurück (oder null, wenn der Punkt in keiner
|
||||
* geschlossenen Zelle liegt). Robust gegen T-Stösse; kleine Lücken werden über
|
||||
* `gapTol` geschlossen. Nutzt intern dieselbe Arrangement-/Face-Extraktion und
|
||||
* wählt die Masche mit kleinster Fläche, die den Punkt enthält (die unmittelbar
|
||||
* umgebende Zelle statt einer grösseren umschliessenden).
|
||||
*/
|
||||
export function roomFromPointInside(
|
||||
point: Vec2,
|
||||
walls: WallSegment[],
|
||||
options: DetectRoomsOptions = {},
|
||||
): Vec2[] | null {
|
||||
const gapTol = options.gapTol ?? 0.05;
|
||||
const offsetToInner = options.offsetToInner ?? true;
|
||||
|
||||
if (walls.length < 3) return null;
|
||||
|
||||
const { nodes, edges } = buildPlanarGraph(
|
||||
walls.map((w) => ({ a: w.a, b: w.b })),
|
||||
gapTol,
|
||||
);
|
||||
if (edges.length < 3) return null;
|
||||
|
||||
const faces = extractFaces(nodes, edges);
|
||||
const half = averageThickness(walls) / 2;
|
||||
|
||||
let best: Vec2[] | null = null;
|
||||
let bestArea = Infinity;
|
||||
|
||||
for (const f of faces) {
|
||||
let raw = dedupeRing(f.map((n) => nodes[n].p));
|
||||
if (raw.length < 3) continue;
|
||||
// Test gegen die ROHE Mittellinien-Masche (schliesst den Klickpunkt weiter ein).
|
||||
if (!pointInPolygon(point, raw)) continue;
|
||||
const area = polygonArea(raw);
|
||||
if (area >= bestArea) continue;
|
||||
let inner = raw;
|
||||
if (offsetToInner && half > 0) {
|
||||
inner = dedupeRing(offsetInward(raw, half));
|
||||
if (inner.length < 3) inner = raw;
|
||||
}
|
||||
if (signedArea(inner) < 0) inner = inner.reverse();
|
||||
best = inner;
|
||||
bestArea = area;
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Variante des Klick-Tracers, die direkt auf bereits berechneten Wand-INNEN-
|
||||
* FLÄCHEN (Faces) arbeitet: die Strecken bilden bereits die lichten Wandseiten.
|
||||
* Kein Innen-Offset (die Faces sind schon die Innenkontur). Für Aufrufer, die
|
||||
* die Wandflächen ohnehin vorhalten (z. B. aus dem Wand-Rendering).
|
||||
*/
|
||||
export function roomFromPointInsideFaces(
|
||||
point: Vec2,
|
||||
wallFaces: WallFace[],
|
||||
gapTol = 0.05,
|
||||
): Vec2[] | null {
|
||||
if (wallFaces.length < 3) return null;
|
||||
const { nodes, edges } = buildPlanarGraph(
|
||||
wallFaces.map((f) => ({ a: f.a, b: f.b })),
|
||||
gapTol,
|
||||
);
|
||||
if (edges.length < 3) return null;
|
||||
const faces = extractFaces(nodes, edges);
|
||||
let best: Vec2[] | null = null;
|
||||
let bestArea = Infinity;
|
||||
for (const f of faces) {
|
||||
const poly = dedupeRing(f.map((n) => nodes[n].p));
|
||||
if (poly.length < 3) continue;
|
||||
if (!pointInPolygon(point, poly)) continue;
|
||||
const area = polygonArea(poly);
|
||||
if (area < bestArea) {
|
||||
best = signedArea(poly) < 0 ? poly.reverse() : poly;
|
||||
bestArea = area;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
// Geometrie-Helfer für Treppen (Stairs) — reine Funktionen über `Vec2`, kein
|
||||
// externer Kernel. Aus einer `Stair` (gerade/L/Wendel) werden abgeleitet:
|
||||
// • die Tritt-Rechtecke im Grundriss (jeweils mit ihrer Z-Höhe der Setzstufe),
|
||||
// • das Zwischenpodest-Polygon (L / Wendel-Auge),
|
||||
// • die Lauflinie (Polyline) + der Auf-/Abpfeil,
|
||||
// • die Schnittposition (welche Tritte unter/über der Grundriss-Schnitthöhe
|
||||
// liegen → im Plan durchgezogen vs. gestrichelt) samt SIA-Bruchlinie.
|
||||
//
|
||||
// Die 3D-Darstellung baut aus denselben Tritt-Rechtecken + Setzstufen-Höhen die
|
||||
// Stufenboxen (siehe Viewport3D). So bleiben Plan und 3D konsistent aus EINER
|
||||
// Quelle abgeleitet.
|
||||
//
|
||||
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||||
|
||||
import type { Stair, Vec2 } from "../model/types";
|
||||
|
||||
const DEG = Math.PI / 180;
|
||||
|
||||
const sub = (a: Vec2, b: Vec2): Vec2 => ({ x: a.x - b.x, y: a.y - b.y });
|
||||
const add = (a: Vec2, b: Vec2): Vec2 => ({ x: a.x + b.x, y: a.y + b.y });
|
||||
const scale = (a: Vec2, s: number): Vec2 => ({ x: a.x * s, y: a.y * s });
|
||||
const lenOf = (a: Vec2): number => Math.hypot(a.x, a.y);
|
||||
const norm = (a: Vec2): Vec2 => {
|
||||
const l = lenOf(a) || 1e-9;
|
||||
return { x: a.x / l, y: a.y / l };
|
||||
};
|
||||
/** Linke Normale (n = (−u.y, u.x)) — quer zur Laufrichtung. */
|
||||
const leftN = (u: Vec2): Vec2 => ({ x: -u.y, y: u.x });
|
||||
|
||||
/** SIA-nahe Schrittregel: gute Steigungshöhe ≈ 0.17 m, Auftritt ≈ 0.29 m. */
|
||||
export const IDEAL_RISER = 0.17;
|
||||
export const IDEAL_TREAD = 0.29;
|
||||
export const MIN_STEPS = 2;
|
||||
|
||||
/**
|
||||
* Sinnvolle Default-Stufenzahl aus Steighöhe + verfügbarer Lauflänge: möglichst
|
||||
* nahe an der idealen Steigungshöhe, gedeckelt durch die Lauflänge (min.
|
||||
* Auftritt ~0.24 m). Immer ≥ MIN_STEPS.
|
||||
*/
|
||||
export function defaultStepCount(totalRise: number, runLength: number): number {
|
||||
const byRise = Math.max(MIN_STEPS, Math.round(totalRise / IDEAL_RISER));
|
||||
// Auftrittstiefe = runLength / (steps − 1); mind. 0.24 m halten.
|
||||
const maxByRun = runLength > 0 ? Math.max(MIN_STEPS, Math.floor(runLength / 0.24) + 1) : byRise;
|
||||
return Math.max(MIN_STEPS, Math.min(byRise, maxByRun));
|
||||
}
|
||||
|
||||
/** Ein Tritt im Grundriss (Rechteck-Polygon) samt Setzstufen-Z (Oberkante). */
|
||||
export interface TreadRect {
|
||||
/** Vier Eckpunkte (CCW), Grundriss-Meter. */
|
||||
pts: Vec2[];
|
||||
/** Index der Stufe (0 = unterste). */
|
||||
index: number;
|
||||
/** Oberkante dieses Tritts relativ zur Treppen-UK (Meter). */
|
||||
topRise: number;
|
||||
/** Unterkante-Höhe (Setzstufe darunter) relativ zur UK (Meter). */
|
||||
baseRise: number;
|
||||
}
|
||||
|
||||
/** Vollständige, abgeleitete Treppengeometrie (Plan + 3D + Schnitt). */
|
||||
export interface StairGeometry {
|
||||
/** Alle Tritt-Rechtecke (untere → obere Stufe). */
|
||||
treads: TreadRect[];
|
||||
/** Zwischenpodest-Polygon (L/Wendel) oder null (gerade). */
|
||||
landing: Vec2[] | null;
|
||||
/** Setzstufen-Höhe (Meter). */
|
||||
riserHeight: number;
|
||||
/** Auftrittstiefe (Meter) des geraden Laufs. */
|
||||
treadDepth: number;
|
||||
/** Lauflinie als Polyline (Meter) — Mitte des Laufs, Start → Austritt. */
|
||||
runLine: Vec2[];
|
||||
/** Auf-/Abpfeil-Geometrie (Schaft + zwei Spitzenflügel). */
|
||||
arrow: { shaft: [Vec2, Vec2]; head: [Vec2, Vec2, Vec2] };
|
||||
/** Gesamt-Steighöhe (Meter). */
|
||||
totalRise: number;
|
||||
}
|
||||
|
||||
/** Rechteck-Tritt zwischen Achsparametern a0..a1 entlang `dir`, Breite `width`. */
|
||||
function treadRect(
|
||||
origin: Vec2,
|
||||
dir: Vec2,
|
||||
n: Vec2,
|
||||
a0: number,
|
||||
a1: number,
|
||||
halfW: number,
|
||||
): Vec2[] {
|
||||
const p0 = add(origin, scale(dir, a0));
|
||||
const p1 = add(origin, scale(dir, a1));
|
||||
return [
|
||||
add(p0, scale(n, halfW)),
|
||||
add(p1, scale(n, halfW)),
|
||||
add(p1, scale(n, -halfW)),
|
||||
add(p0, scale(n, -halfW)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Leitet die vollständige Geometrie einer Treppe ab. `totalRise` MUSS vom
|
||||
* Aufrufer aufgelöst übergeben werden (Geschosshöhe-Default liegt in
|
||||
* `stairVerticalExtent`), damit dieses Modul three-/projektfrei bleibt.
|
||||
*/
|
||||
export function stairGeometry(stair: Stair, totalRise: number): StairGeometry {
|
||||
const steps = Math.max(MIN_STEPS, Math.round(stair.stepCount));
|
||||
const riserHeight = totalRise / steps;
|
||||
const halfW = Math.max(0.05, stair.width / 2);
|
||||
const u = norm(stair.dir);
|
||||
const n = leftN(u);
|
||||
|
||||
if (stair.shape === "spiral") {
|
||||
return spiralGeometry(stair, totalRise, steps, riserHeight, halfW);
|
||||
}
|
||||
if (stair.shape === "L") {
|
||||
return lGeometry(stair, totalRise, steps, riserHeight, halfW, u, n);
|
||||
}
|
||||
return straightGeometry(stair, totalRise, steps, riserHeight, halfW, u, n);
|
||||
}
|
||||
|
||||
/** Gerade Treppe: gleich breite Tritte über die Lauflänge. */
|
||||
function straightGeometry(
|
||||
stair: Stair,
|
||||
totalRise: number,
|
||||
steps: number,
|
||||
riserHeight: number,
|
||||
halfW: number,
|
||||
u: Vec2,
|
||||
n: Vec2,
|
||||
): StairGeometry {
|
||||
const runLength = Math.max(0.1, stair.runLength);
|
||||
const treadDepth = runLength / steps;
|
||||
const treads: TreadRect[] = [];
|
||||
for (let i = 0; i < steps; i++) {
|
||||
treads.push({
|
||||
pts: treadRect(stair.start, u, n, i * treadDepth, (i + 1) * treadDepth, halfW),
|
||||
index: i,
|
||||
baseRise: i * riserHeight,
|
||||
topRise: (i + 1) * riserHeight,
|
||||
});
|
||||
}
|
||||
const runLine: Vec2[] = [
|
||||
add(stair.start, scale(u, treadDepth * 0.5)),
|
||||
add(stair.start, scale(u, runLength - treadDepth * 0.15)),
|
||||
];
|
||||
return {
|
||||
treads,
|
||||
landing: null,
|
||||
riserHeight,
|
||||
treadDepth,
|
||||
runLine,
|
||||
arrow: arrowFor(runLine, stair.up !== false),
|
||||
totalRise,
|
||||
};
|
||||
}
|
||||
|
||||
/** L-Treppe: zwei Läufe + quadratisches Zwischenpodest an der Ecke. */
|
||||
function lGeometry(
|
||||
stair: Stair,
|
||||
totalRise: number,
|
||||
steps: number,
|
||||
riserHeight: number,
|
||||
halfW: number,
|
||||
u: Vec2,
|
||||
n: Vec2,
|
||||
): StairGeometry {
|
||||
const run1 = Math.max(0.1, stair.runLength);
|
||||
const run2 = Math.max(0.1, stair.run2Length ?? stair.runLength);
|
||||
const turn = stair.turn ?? 1;
|
||||
// Podest quadratisch (Kantenlänge = Laufbreite) am Ende des ersten Laufs.
|
||||
const podest = 2 * halfW;
|
||||
// Stufen auf beide Läufe verteilen (grob proportional zur Lauflänge), ein Tritt
|
||||
// fällt aufs Podest.
|
||||
const bodySteps = steps - 1; // ein Tritt = Podest
|
||||
const s1 = Math.max(1, Math.round((bodySteps * run1) / (run1 + run2)));
|
||||
const s2 = Math.max(1, bodySteps - s1);
|
||||
const td1 = run1 / s1;
|
||||
const td2 = run2 / s2;
|
||||
|
||||
const treads: TreadRect[] = [];
|
||||
let rise = 0;
|
||||
for (let i = 0; i < s1; i++) {
|
||||
treads.push({
|
||||
pts: treadRect(stair.start, u, n, i * td1, (i + 1) * td1, halfW),
|
||||
index: i,
|
||||
baseRise: rise,
|
||||
topRise: rise + riserHeight,
|
||||
});
|
||||
rise += riserHeight;
|
||||
}
|
||||
// Zweiter Lauf beginnt am Ende des Podests. Richtung um 90° gedreht (turn).
|
||||
const cornerCenter = add(stair.start, scale(u, run1 + halfW));
|
||||
const u2: Vec2 = turn > 0 ? n : scale(n, -1); // links/rechts abbiegen
|
||||
const n2 = leftN(u2);
|
||||
// Podest-Tritt (quadratisch um cornerCenter, an u/u2 ausgerichtet).
|
||||
const landing: Vec2[] = [
|
||||
add(add(cornerCenter, scale(u, -halfW)), scale(n, halfW)),
|
||||
add(add(cornerCenter, scale(u, halfW)), scale(n, halfW)),
|
||||
add(add(cornerCenter, scale(u, halfW)), scale(n, -halfW)),
|
||||
add(add(cornerCenter, scale(u, -halfW)), scale(n, -halfW)),
|
||||
];
|
||||
const podestTopRise = rise + riserHeight;
|
||||
rise += riserHeight;
|
||||
// Startpunkt des zweiten Laufs: Podest-Kante in Richtung u2.
|
||||
const run2Start = add(cornerCenter, scale(u2, halfW));
|
||||
for (let i = 0; i < s2; i++) {
|
||||
treads.push({
|
||||
pts: treadRect(run2Start, u2, n2, i * td2, (i + 1) * td2, halfW),
|
||||
index: s1 + 1 + i,
|
||||
baseRise: rise,
|
||||
topRise: rise + riserHeight,
|
||||
});
|
||||
rise += riserHeight;
|
||||
}
|
||||
void podest;
|
||||
void podestTopRise;
|
||||
|
||||
const runLine: Vec2[] = [
|
||||
add(stair.start, scale(u, td1 * 0.5)),
|
||||
cornerCenter,
|
||||
add(run2Start, scale(u2, run2 - td2 * 0.15)),
|
||||
];
|
||||
return {
|
||||
treads,
|
||||
landing,
|
||||
riserHeight,
|
||||
treadDepth: td1,
|
||||
runLine,
|
||||
arrow: arrowFor(runLine, stair.up !== false),
|
||||
totalRise,
|
||||
};
|
||||
}
|
||||
|
||||
/** Wendeltreppe: keilförmige Tritte um ein Zentrum (vereinfachtes Modell). */
|
||||
function spiralGeometry(
|
||||
stair: Stair,
|
||||
totalRise: number,
|
||||
steps: number,
|
||||
riserHeight: number,
|
||||
halfW: number,
|
||||
): StairGeometry {
|
||||
const center = stair.center ?? stair.start;
|
||||
const radius = Math.max(halfW + 0.1, stair.radius ?? stair.width);
|
||||
const sweep = (stair.sweep ?? 270) * DEG;
|
||||
// Startwinkel aus start→center (Lauflinie beginnt am Startpunkt).
|
||||
const startVec = sub(stair.start, center);
|
||||
const a0 = Math.atan2(startVec.y, startVec.x);
|
||||
const dA = sweep / steps;
|
||||
const rIn = Math.max(0.02, radius - halfW);
|
||||
const rOut = radius + halfW;
|
||||
|
||||
const treads: TreadRect[] = [];
|
||||
for (let i = 0; i < steps; i++) {
|
||||
const t0 = a0 + i * dA;
|
||||
const t1 = a0 + (i + 1) * dA;
|
||||
// Keil-Viereck: innen(t0) → außen(t0) → außen(t1) → innen(t1).
|
||||
const pts: Vec2[] = [
|
||||
{ x: center.x + rIn * Math.cos(t0), y: center.y + rIn * Math.sin(t0) },
|
||||
{ x: center.x + rOut * Math.cos(t0), y: center.y + rOut * Math.sin(t0) },
|
||||
{ x: center.x + rOut * Math.cos(t1), y: center.y + rOut * Math.sin(t1) },
|
||||
{ x: center.x + rIn * Math.cos(t1), y: center.y + rIn * Math.sin(t1) },
|
||||
];
|
||||
treads.push({
|
||||
pts,
|
||||
index: i,
|
||||
baseRise: i * riserHeight,
|
||||
topRise: (i + 1) * riserHeight,
|
||||
});
|
||||
}
|
||||
// Lauflinie = Bogen auf Radius `radius` (mittlere Lauflinie).
|
||||
const runLine: Vec2[] = [];
|
||||
const N = Math.max(2, steps);
|
||||
for (let i = 0; i <= N; i++) {
|
||||
const tt = a0 + (sweep * i) / N;
|
||||
runLine.push({ x: center.x + radius * Math.cos(tt), y: center.y + radius * Math.sin(tt) });
|
||||
}
|
||||
return {
|
||||
treads,
|
||||
// Wendel-Auge (Innenkreis) als „Podest"-Polygon.
|
||||
landing: eyePolygon(center, rIn),
|
||||
riserHeight,
|
||||
treadDepth: (2 * Math.PI * radius * (sweep / (2 * Math.PI))) / steps,
|
||||
runLine,
|
||||
arrow: arrowFor(runLine, stair.up !== false),
|
||||
totalRise,
|
||||
};
|
||||
}
|
||||
|
||||
/** Achteck-Approximation des Wendel-Auges (Innenkreis). */
|
||||
function eyePolygon(center: Vec2, r: number): Vec2[] {
|
||||
const pts: Vec2[] = [];
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const t = (i / 12) * Math.PI * 2;
|
||||
pts.push({ x: center.x + r * Math.cos(t), y: center.y + r * Math.sin(t) });
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auf-/Abpfeil aus der Lauflinie: Schaft = erstes→letztes Segment, Spitze am
|
||||
* oberen (bzw. bei `up=false` am unteren) Ende. `up` kehrt die Richtung um.
|
||||
*/
|
||||
function arrowFor(
|
||||
runLine: Vec2[],
|
||||
up: boolean,
|
||||
): { shaft: [Vec2, Vec2]; head: [Vec2, Vec2, Vec2] } {
|
||||
const a = runLine[0];
|
||||
const b = runLine[runLine.length - 1];
|
||||
const tip = up ? b : a;
|
||||
const from = up ? runLine[runLine.length - 2] : runLine[1];
|
||||
const dir = norm(sub(tip, from));
|
||||
const n = leftN(dir);
|
||||
const s = 0.22; // Flügellänge (Meter)
|
||||
const back = add(tip, scale(dir, -s));
|
||||
const w1 = add(back, scale(n, s * 0.55));
|
||||
const w2 = add(back, scale(n, -s * 0.55));
|
||||
return { shaft: [a, b], head: [w1, tip, w2] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Schnitt-Aufteilung im Grundriss: DOSSIER schneidet die Treppe auf `cutRise`
|
||||
* (Schnitthöhe über OKFF). Tritte mit Oberkante ≤ cutRise werden durchgezogen
|
||||
* gezeichnet (unterhalb der Schnittebene), darüberliegende gestrichelt (oberhalb,
|
||||
* verdeckt). Der ERSTE Tritt oberhalb markiert die SIA-Bruchlinie (diagonaler
|
||||
* Doppelstrich über die Laufbreite).
|
||||
*/
|
||||
export interface StairCut {
|
||||
/** Indizes der Tritte UNTER der Schnittebene (durchgezogen). */
|
||||
belowIndices: number[];
|
||||
/** Indizes der Tritte ÜBER der Schnittebene (gestrichelt/verdeckt). */
|
||||
aboveIndices: number[];
|
||||
/** Bruchlinie (SIA): zwei parallele Diagonalstriche über die Laufbreite. */
|
||||
breakLine: [Vec2, Vec2][] | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bestimmt die Schnittaufteilung. `cutRise` = Schnitthöhe über der Treppen-UK.
|
||||
* Findet den ersten Tritt, dessen Oberkante die Schnittebene überschreitet, und
|
||||
* legt dort die Bruchlinie quer über den Lauf.
|
||||
*/
|
||||
export function stairCut(geo: StairGeometry, cutRise: number): StairCut {
|
||||
const below: number[] = [];
|
||||
const above: number[] = [];
|
||||
let breakIdx = -1;
|
||||
for (const tr of geo.treads) {
|
||||
if (tr.topRise <= cutRise + 1e-6) {
|
||||
below.push(tr.index);
|
||||
} else {
|
||||
if (breakIdx < 0) breakIdx = tr.index;
|
||||
above.push(tr.index);
|
||||
}
|
||||
}
|
||||
let breakLine: [Vec2, Vec2][] | null = null;
|
||||
if (breakIdx >= 0) {
|
||||
const tr = geo.treads.find((t) => t.index === breakIdx);
|
||||
if (tr) {
|
||||
// Diagonale Bruchlinie über den Tritt: von einer Ecke zur gegenüberliegenden,
|
||||
// als Doppelstrich (leicht versetzt) — SIA-Treppen-Schnittsymbol.
|
||||
const [c0, c1, c2, c3] = tr.pts;
|
||||
const mid01 = scale(add(c0, c1), 0.5);
|
||||
const mid23 = scale(add(c2, c3), 0.5);
|
||||
const off = scale(norm(sub(c1, c0)), 0.06);
|
||||
breakLine = [
|
||||
[add(mid01, off), add(mid23, off)],
|
||||
[sub(mid01, off), sub(mid23, off)],
|
||||
];
|
||||
}
|
||||
}
|
||||
return { belowIndices: below, aboveIndices: above, breakLine };
|
||||
}
|
||||
|
||||
/** Achsparallele Bounding-Box aller Tritte (+ Podest) einer Treppengeometrie. */
|
||||
export function stairBBox(geo: StairGeometry): {
|
||||
minX: number;
|
||||
minY: number;
|
||||
maxX: number;
|
||||
maxY: number;
|
||||
} {
|
||||
let minX = Infinity,
|
||||
minY = Infinity,
|
||||
maxX = -Infinity,
|
||||
maxY = -Infinity;
|
||||
const acc = (p: Vec2) => {
|
||||
minX = Math.min(minX, p.x);
|
||||
minY = Math.min(minY, p.y);
|
||||
maxX = Math.max(maxX, p.x);
|
||||
maxY = Math.max(maxY, p.y);
|
||||
};
|
||||
for (const tr of geo.treads) for (const p of tr.pts) acc(p);
|
||||
if (geo.landing) for (const p of geo.landing) acc(p);
|
||||
if (!isFinite(minX)) return { minX: 0, minY: 0, maxX: 0, maxY: 0 };
|
||||
return { minX, minY, maxX, maxY };
|
||||
}
|
||||
|
||||
/** Punkt-in-Polygon (Ray-Casting) — für die Body-Auswahl im Grundriss/3D. */
|
||||
export function pointInPolygon(p: Vec2, poly: Vec2[]): boolean {
|
||||
let inside = false;
|
||||
const nn = poly.length;
|
||||
for (let i = 0, j = nn - 1; i < nn; j = i++) {
|
||||
const a = poly[i];
|
||||
const b = poly[j];
|
||||
const intersect =
|
||||
a.y > p.y !== b.y > p.y &&
|
||||
p.x < ((b.x - a.x) * (p.y - a.y)) / (b.y - a.y || 1e-12) + a.x;
|
||||
if (intersect) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
/** Ob ein Modellpunkt IRGENDEINEN Tritt (oder das Podest) einer Treppe trifft. */
|
||||
export function pointHitsStair(p: Vec2, geo: StairGeometry): boolean {
|
||||
for (const tr of geo.treads) if (pointInPolygon(p, tr.pts)) return true;
|
||||
if (geo.landing && pointInPolygon(p, geo.landing)) return true;
|
||||
return false;
|
||||
}
|
||||
+268
@@ -49,6 +49,10 @@ export const de = {
|
||||
"topbar.fit.hint": "Auf Plan einpassen",
|
||||
"topbar.fitSelection": "Auswahl",
|
||||
"topbar.fitSelection.hint": "Auf Auswahl einpassen (sonst auf Plan)",
|
||||
"topbar.exportPdf": "PDF",
|
||||
"topbar.exportPdf.hint": "Grundriss als Vektor-PDF exportieren",
|
||||
"topbar.exportDxf": "DXF",
|
||||
"topbar.exportDxf.hint": "Grundriss als DXF (Modell-Space in Metern) exportieren",
|
||||
"topbar.zoom.current": "Aktueller Zoom",
|
||||
|
||||
"topbar.renderGroup": "Render-Modus",
|
||||
@@ -68,6 +72,7 @@ export const de = {
|
||||
"display.style.mono": "Schwarz-Weiss",
|
||||
"display.style.shaded": "Schattiert",
|
||||
"display.style.white": "Weiss",
|
||||
"display.style.textured": "Texturiert",
|
||||
"display.style.wireframe": "Drahtgitter",
|
||||
"display.style.hidden": "Kanten",
|
||||
|
||||
@@ -103,12 +108,23 @@ export const de = {
|
||||
"tool.group": "Werkzeuge",
|
||||
"tool.select": "Auswahl",
|
||||
"tool.wall": "Wand",
|
||||
"tool.ceiling": "Decke",
|
||||
"tool.window": "Fenster",
|
||||
"tool.door": "Tür",
|
||||
"tool.stair": "Treppe",
|
||||
"tool.line": "Linie",
|
||||
"tool.polyline": "Polylinie",
|
||||
"tool.rect": "Rechteck",
|
||||
"tool.select.hint": "Auswahl — Elemente klicken/aufziehen",
|
||||
"tool.window.hint": "Fenster: Wand wählen, dann Position setzen",
|
||||
"tool.door.hint": "Tür: Wand wählen, dann Position setzen",
|
||||
"tool.stair.hint": "Treppe: Startpunkt, dann Laufrichtung/Ende setzen",
|
||||
"tool.room": "Raum",
|
||||
"tool.room.hint": "Raum: in umschlossenen Bereich klicken (oder Umriss zeichnen)",
|
||||
"tool.wall.firstPoint": "Wand: ersten Achspunkt setzen",
|
||||
"tool.wall.nextPoint": "Wand: nächsten Punkt — Doppelklick/Rechtsklick beendet",
|
||||
"tool.ceiling.firstPoint": "Decke: ersten Umrisspunkt setzen",
|
||||
"tool.ceiling.nextPoint": "Decke: nächster Punkt — Klick auf Start schließt, Enter beendet",
|
||||
"tool.line.firstPoint": "Linie: Startpunkt setzen",
|
||||
"tool.line.secondPoint": "Linie: Endpunkt setzen",
|
||||
"tool.polyline.firstPoint": "Polylinie: ersten Punkt setzen",
|
||||
@@ -116,6 +132,7 @@ export const de = {
|
||||
"tool.rect.firstCorner": "Rechteck: erste Ecke setzen",
|
||||
"tool.rect.secondCorner": "Rechteck: gegenüberliegende Ecke setzen",
|
||||
"tool.wallType": "Wandtyp",
|
||||
"tool.ceilingType": "Deckentyp",
|
||||
"tool.layer": "Ebene",
|
||||
"tool.layer.hint": "Aktive Ebene — alles Gezeichnete kommt auf diese Kategorie",
|
||||
"tool.floorOnlyDisabled": "Wand nur auf einem Geschoss zeichenbar",
|
||||
@@ -209,8 +226,12 @@ export const de = {
|
||||
"objinfo.height": "Höhe",
|
||||
"objinfo.type": "Typ",
|
||||
"objinfo.kind.wall": "Wand",
|
||||
"objinfo.kind.ceiling": "Decke",
|
||||
"objinfo.kind.drawing2d": "2D-Objekt",
|
||||
"objinfo.kind.door": "Tür",
|
||||
"objinfo.kind.opening": "Öffnung",
|
||||
"objinfo.kind.stair": "Treppe",
|
||||
"objinfo.kind.room": "Raum",
|
||||
// ── Wand-Attribute (Object-Info-Panel) ──────────────────────────────────
|
||||
"objinfo.wall.section": "Wand",
|
||||
"objinfo.wall.refLine": "Referenzlinie",
|
||||
@@ -230,6 +251,65 @@ export const de = {
|
||||
"objinfo.wall.anchorFloor": "An Geschoss gebunden",
|
||||
"objinfo.wall.anchorCustom": "Eigene Höhe",
|
||||
"objinfo.wall.height": "Höhe",
|
||||
// ── Decken-Attribute (Object-Info-Panel) ────────────────────────────────
|
||||
"objinfo.ceiling.section": "Decke",
|
||||
"objinfo.ceiling.buildup": "Aufbau",
|
||||
"objinfo.ceiling.thickness": "Dicke",
|
||||
"objinfo.ceiling.preset": "Deckenaufbau",
|
||||
"objinfo.ceiling.refFloor": "Referenzgeschoss",
|
||||
"objinfo.ceiling.top": "Oberkante (OK)",
|
||||
"objinfo.ceiling.bottom": "Unterkante (UK)",
|
||||
"objinfo.ceiling.height": "Dicke (OK−UK)",
|
||||
"objinfo.ceiling.area": "Fläche",
|
||||
"objinfo.ceiling.anchorFloor": "An Geschoss gebunden",
|
||||
"objinfo.ceiling.anchorCustom": "Eigene Höhe",
|
||||
// ── Öffnungs-Attribute (Object-Info-Panel) ──────────────────────────────
|
||||
"objinfo.opening.section": "Öffnung",
|
||||
"objinfo.opening.kind": "Art",
|
||||
"objinfo.opening.kind.window": "Fenster",
|
||||
"objinfo.opening.kind.door": "Tür",
|
||||
"objinfo.opening.hostWall": "Wirts-Wand",
|
||||
"objinfo.opening.position": "Position (ab Wandanfang)",
|
||||
"objinfo.opening.width": "Breite",
|
||||
"objinfo.opening.height": "Höhe",
|
||||
"objinfo.opening.sill": "Brüstung",
|
||||
"objinfo.opening.swingAngle": "Öffnungswinkel",
|
||||
"objinfo.opening.hinge": "Anschlag",
|
||||
"objinfo.opening.hinge.start": "Anfang",
|
||||
"objinfo.opening.hinge.end": "Ende",
|
||||
"objinfo.opening.swing": "Aufschlagseite",
|
||||
"objinfo.opening.swing.left": "Links",
|
||||
"objinfo.opening.swing.right": "Rechts",
|
||||
"objinfo.opening.dir": "Richtung",
|
||||
"objinfo.opening.dir.in": "Innen",
|
||||
"objinfo.opening.dir.out": "Außen",
|
||||
"objinfo.opening.bottom": "Unterkante (UK)",
|
||||
"objinfo.opening.top": "Oberkante (OK)",
|
||||
// ── Treppen-Attribute (Object-Info-Panel) ───────────────────────────────
|
||||
"objinfo.stair.section": "Treppe",
|
||||
"objinfo.stair.shape": "Grundform",
|
||||
"objinfo.stair.shape.straight": "Gerade",
|
||||
"objinfo.stair.shape.L": "L-Treppe",
|
||||
"objinfo.stair.shape.spiral": "Wendel",
|
||||
"objinfo.stair.width": "Laufbreite",
|
||||
"objinfo.stair.steps": "Stufenanzahl",
|
||||
"objinfo.stair.rise": "Steighöhe (gesamt)",
|
||||
"objinfo.stair.riser": "Steigungshöhe",
|
||||
"objinfo.stair.tread": "Auftrittstiefe",
|
||||
"objinfo.stair.direction": "Laufrichtung",
|
||||
"objinfo.stair.direction.up": "Aufwärts",
|
||||
"objinfo.stair.direction.down": "Abwärts",
|
||||
"objinfo.stair.refFloor": "Referenzgeschoss",
|
||||
"objinfo.stair.bottom": "Unterkante (UK)",
|
||||
"objinfo.stair.top": "Oberkante (OK)",
|
||||
// ── Raum-Attribute (Object-Info-Panel) ──────────────────────────────────
|
||||
"objinfo.room.section": "Raum",
|
||||
"objinfo.room.name": "Name",
|
||||
"objinfo.room.sia": "SIA-Kategorie",
|
||||
"objinfo.room.area": "Fläche",
|
||||
"objinfo.room.perimeter": "Umfang",
|
||||
"objinfo.room.refFloor": "Referenzgeschoss",
|
||||
"objinfo.room.editStamp": "Stempel bearbeiten …",
|
||||
"objinfo.wall.floorAboveNone": "Kein oberes Geschoss",
|
||||
"levels.addFloor": "+ Geschoss",
|
||||
"levels.addDrawing": "+ Zeichnung",
|
||||
@@ -280,6 +360,11 @@ export const de = {
|
||||
"ctx.fitSelection": "Auf Auswahl einpassen",
|
||||
"ctx.clearSelection": "Auswahl aufheben",
|
||||
"ctx.fit": "Ansicht einpassen",
|
||||
"ctx.union": "Vereinigen",
|
||||
"ctx.subtract": "Subtrahieren",
|
||||
"ctx.intersect": "Schneiden",
|
||||
"ctx.boolean.disabled":
|
||||
"Mindestens zwei geschlossene Flächen auswählen (Rechteck oder geschlossene Polylinie).",
|
||||
"ctx.noWallHit": "Keine Wand getroffen",
|
||||
"ctx.noWallHit.hint": "Rechtsklick auf eine Wand wählt sie und zeigt Wand-Aktionen.",
|
||||
|
||||
@@ -358,8 +443,47 @@ export const de = {
|
||||
"resources.col.prio": "Prio",
|
||||
"resources.col.prio.hint": "Höhere Priorität läuft am Stoß durch",
|
||||
"resources.col.texture": "Textur",
|
||||
"resources.col.material": "Material",
|
||||
"resources.delete.component": "Bauteil „{name}\" löschen",
|
||||
|
||||
// 3D-Material (PBR) — Bibliotheks-Auswahl + Upload.
|
||||
"material.assign": "Material …",
|
||||
"material.none": "— ohne —",
|
||||
"material.dialog.title": "3D-Material zuweisen",
|
||||
"material.dialog.hint": "Wähle ein eingebautes Material oder lade eigene Texturen hoch. Wirkt im 3D-Modus „Texturiert\".",
|
||||
"material.library": "Bibliothek (ambientCG, CC0)",
|
||||
"material.upload": "Eigene Texturen",
|
||||
"material.upload.hint": "Lade Bilder als Karten hoch (mindestens Farbe). Größe = physische Kachelgröße in Metern.",
|
||||
"material.upload.color": "Farbe (Albedo)",
|
||||
"material.upload.normal": "Normal",
|
||||
"material.upload.roughness": "Rauheit",
|
||||
"material.upload.displacement": "Höhe",
|
||||
"material.upload.ao": "AO",
|
||||
"material.upload.choose": "Bild …",
|
||||
"material.upload.apply": "Zuweisen",
|
||||
"material.size": "Kachelgröße (m)",
|
||||
"material.current": "Aktuell",
|
||||
"material.clear": "Material entfernen",
|
||||
"material.close": "Schließen",
|
||||
"material.uploaded": "Hochgeladen",
|
||||
// ── ambientCG-Bibliothek durchsuchen (Live) ──────────────────────────────
|
||||
"material.browse": "Bibliothek durchsuchen",
|
||||
"material.browse.starter": "Schnellauswahl",
|
||||
"material.browse.hint":
|
||||
"Durchsuche die komplette CC0-Bibliothek von ambientCG (~2000+ Materialien). Ein Klick lädt die Texturen zur Laufzeit herunter und weist sie zu.",
|
||||
"material.browse.search": "Suchen …",
|
||||
"material.browse.searchPlaceholder": "z. B. Holz, Beton, Fliesen …",
|
||||
"material.browse.category.all": "Alle Kategorien",
|
||||
"material.browse.resolution": "Auflösung",
|
||||
"material.browse.more": "Mehr laden",
|
||||
"material.browse.loading": "Lädt …",
|
||||
"material.browse.downloading": "Textur wird geladen …",
|
||||
"material.browse.empty": "Keine Materialien gefunden.",
|
||||
"material.browse.count": "{shown} von {total}",
|
||||
"material.browse.error":
|
||||
"Bibliothek nicht erreichbar. Zur Laufzeit ist ein Proxy für ambientCG nötig (Dev: Vite-Proxy aktiv; Prod: VITE_AMBIENTCG_PROXY setzen).",
|
||||
"material.browse.downloadError": "Download fehlgeschlagen (CORS/Proxy prüfen).",
|
||||
|
||||
// Schraffuren.
|
||||
"resources.hatches.hint": "Muster, Maßstab und Winkel steuern die Schnitt-Schraffur; der Linienstil bestimmt die Stärke der Musterlinien.",
|
||||
"resources.hatches.empty": "Noch keine Schraffuren.",
|
||||
@@ -397,6 +521,30 @@ export const de = {
|
||||
"cmd.aria": "Befehlszeile",
|
||||
"cmd.idle.prompt": "Befehl:",
|
||||
"cmd.idle.placeholder": "Befehl eingeben … (Tab)",
|
||||
"cmd.wall.label": "Wand",
|
||||
"cmd.wall.start": "Startpunkt der Wand:",
|
||||
"cmd.wall.next": "Nächster Punkt:",
|
||||
"cmd.ceiling.label": "Decke",
|
||||
"cmd.ceiling.start": "Erster Umrisspunkt der Decke:",
|
||||
"cmd.ceiling.next": "Nächster Punkt ( Schliessen ):",
|
||||
"cmd.opening.label": "Öffnung",
|
||||
"cmd.opening.windowLabel": "Fenster",
|
||||
"cmd.opening.doorLabel": "Tür",
|
||||
"cmd.opening.wall": "Wand für die Öffnung wählen:",
|
||||
"cmd.opening.pos": "Position entlang der Wand ( Abstand tippen ):",
|
||||
"cmd.opening.kind": "Art",
|
||||
"cmd.opening.hinge": "Anschlag",
|
||||
"cmd.opening.side": "Seite",
|
||||
"cmd.opening.dir": "Richtung",
|
||||
"cmd.opening.sillField": "Brüstung",
|
||||
"cmd.opening.swingField": "Winkel",
|
||||
"cmd.stair.label": "Treppe",
|
||||
"cmd.stair.start": "Startpunkt der Treppe:",
|
||||
"cmd.stair.run": "Laufrichtung / Ende ( Grundform · Auf/Ab ):",
|
||||
"cmd.stair.shape": "Grundform",
|
||||
"cmd.stair.updown": "Auf/Ab",
|
||||
"cmd.stair.stepsField": "Stufen",
|
||||
"cmd.stair.riseField": "Steighöhe",
|
||||
"cmd.line.label": "Linie",
|
||||
"cmd.line.start": "Startpunkt der Linie:",
|
||||
"cmd.line.end": "Endpunkt der Linie:",
|
||||
@@ -405,9 +553,15 @@ export const de = {
|
||||
"cmd.polyline.next": "Nächster Punkt:",
|
||||
"cmd.polyline.close": "Schliessen",
|
||||
"cmd.polyline.undo": "Zurück",
|
||||
"cmd.polyline.closedOpt": "Geschlossen",
|
||||
"cmd.rect.label": "Rechteck",
|
||||
"cmd.rect.first": "Erste Ecke:",
|
||||
"cmd.rect.second": "Gegenüberliegende Ecke:",
|
||||
"cmd.rect.method": "Methode",
|
||||
"cmd.rect.center": "Mittelpunkt:",
|
||||
"cmd.rect.corner": "Ecke:",
|
||||
"cmd.rect.baseEnd": "Zweite Ecke (Basiskante):",
|
||||
"cmd.rect.rise": "Höhe:",
|
||||
"cmd.circle.label": "Kreis",
|
||||
"cmd.circle.center": "Mittelpunkt:",
|
||||
"cmd.circle.radius": "Radius:",
|
||||
@@ -416,6 +570,11 @@ export const de = {
|
||||
"cmd.move.base": "Basispunkt:",
|
||||
"cmd.move.target": "Zielpunkt:",
|
||||
"cmd.move.empty": "Nichts gewählt (erst Objekte wählen).",
|
||||
"cmd.mirror.label": "Spiegeln",
|
||||
"cmd.mirror.first": "Erster Punkt der Spiegelachse:",
|
||||
"cmd.mirror.second": "Zweiter Punkt der Spiegelachse:",
|
||||
"cmd.mirror.empty": "Nichts gewählt (erst Objekte wählen).",
|
||||
"cmd.join.prompt": "Verbinde gewählte Elemente …",
|
||||
"cmd.copy.label": "Kopieren",
|
||||
"cmd.copy.base": "Basispunkt:",
|
||||
"cmd.copy.target": "Zielpunkt (Enter beendet):",
|
||||
@@ -423,6 +582,9 @@ export const de = {
|
||||
"cmd.offset.label": "Versatz",
|
||||
"cmd.offset.pick": "Kurve wählen:",
|
||||
"cmd.offset.side": "Seite oder Distanz:",
|
||||
// Trim (Quick-Trim): auf den wegzuschneidenden Abschnitt klicken; wiederholend.
|
||||
"cmd.trim.label": "Stutzen",
|
||||
"cmd.trim.pick": "Abschnitt zum Wegschneiden anklicken (Esc beendet):",
|
||||
// Editier-Werkzeuge Split / Join / Segment-Löschen (Tastatur: Ctrl+S / Ctrl+J /
|
||||
// Alt+Klick). Labels für Befehlsleiste/Menüs.
|
||||
"edit.split.label": "Teilen",
|
||||
@@ -435,6 +597,8 @@ export const de = {
|
||||
"cmd.field.height": "Höhe",
|
||||
"cmd.field.radius": "Radius",
|
||||
"cmd.field.distance": "Distanz",
|
||||
"cmd.field.thickness": "Dicke",
|
||||
"cmd.edit.point": "Griff ziehen (Tab: Länge/Winkel):",
|
||||
|
||||
// ── Befehle: Import / Gelände ────────────────────────────────────────────
|
||||
"cmd.import.label": "Importieren",
|
||||
@@ -442,6 +606,17 @@ export const de = {
|
||||
"cmd.terrain.label": "Gelände",
|
||||
"cmd.terrain.prompt": "Gelände aus Konturen erzeugen …",
|
||||
|
||||
// ── Flächenbilanz (SIA 416) ──────────────────────────────────────────────
|
||||
"nav.roomBalance": "Flächenbilanz",
|
||||
"balance.title": "Flächenbilanz (SIA 416)",
|
||||
"balance.rooms": "Räume",
|
||||
"balance.summary": "Bilanz",
|
||||
"balance.export": "CSV exportieren",
|
||||
"balance.empty": "Keine Räume vorhanden. Mit dem Raum-Werkzeug erstellen.",
|
||||
// ── Raum-Stempel-Editor ──────────────────────────────────────────────────
|
||||
"room.stamp.editTitle": "Raum-Stempel bearbeiten",
|
||||
"room.stamp.close": "Schliessen",
|
||||
|
||||
// ── Site-/Kontext-Panel (SitePanel) ──────────────────────────────────────
|
||||
"nav.site": "Gelände",
|
||||
"site.title": "Gelände & Kontext",
|
||||
@@ -487,6 +662,28 @@ export const de = {
|
||||
"import.badge.elevation": "Ansicht",
|
||||
"import.badge.drawing": "Zeichnung",
|
||||
|
||||
// ── PDF-Export ───────────────────────────────────────────────────────────
|
||||
"export.title": "PDF exportieren",
|
||||
"export.close": "Schließen",
|
||||
"export.plan": "Plan",
|
||||
"export.scale": "Massstab",
|
||||
"export.paper": "Papierformat",
|
||||
"export.orientation": "Ausrichtung",
|
||||
"export.orientation.portrait": "Hochformat",
|
||||
"export.orientation.landscape": "Querformat",
|
||||
"export.warnFit":
|
||||
"Der Plan passt im gewählten Massstab nicht ganz auf das Blatt — größeres Format oder kleineren Massstab wählen.",
|
||||
"export.confirm": "Exportieren",
|
||||
"export.cancel": "Abbrechen",
|
||||
|
||||
// ── DXF-Export ───────────────────────────────────────────────────────────
|
||||
"exportDxf.title": "DXF exportieren",
|
||||
"exportDxf.hatches": "Schraffuren",
|
||||
"exportDxf.hatches.yes": "Mitexportieren",
|
||||
"exportDxf.hatches.no": "Nur Umrisse",
|
||||
"exportDxf.unitsNote":
|
||||
"Modell-Space in Metern (1:1). Layer entsprechen den Ebenen des Projekts; beim Plot beliebig skalierbar.",
|
||||
|
||||
// ── Drag & Drop (App-weiter Datei-Drop) ──────────────────────────────────
|
||||
"drop.hint": "DXF/DWG-Datei hier ablegen",
|
||||
|
||||
@@ -495,6 +692,77 @@ export const de = {
|
||||
"error.title": "Etwas ist schiefgelaufen",
|
||||
"error.body": "Ein unerwarteter Fehler ist aufgetreten. Details in der Konsole.",
|
||||
"error.reload": "Neu laden",
|
||||
|
||||
// ── Standort-Import (geo.admin / OSM) ─────────────────────────────────────
|
||||
"site.importLocation": "Standort importieren",
|
||||
"ctxImport.title": "Standort importieren",
|
||||
"ctxImport.close": "Schließen",
|
||||
"ctxImport.location": "Ort / Adresse",
|
||||
"ctxImport.searchPlaceholder": "z. B. Bundesplatz Bern",
|
||||
"ctxImport.search": "Suchen",
|
||||
"ctxImport.searching": "Suche…",
|
||||
"ctxImport.noResults": "Keine Treffer.",
|
||||
"ctxImport.searchError": "Suche fehlgeschlagen.",
|
||||
"ctxImport.radius": "Radius",
|
||||
"ctxImport.sources": "Quellen",
|
||||
"ctxImport.src.swissBuildings": "Swisstopo-Gebäude",
|
||||
"ctxImport.src.osmBuildings": "OSM-Gebäude",
|
||||
"ctxImport.src.osmRoads": "OSM-Strassen",
|
||||
"ctxImport.src.osmWater": "OSM-Wasser",
|
||||
"ctxImport.src.osmGreen": "OSM-Grün",
|
||||
"ctxImport.cancel": "Abbrechen",
|
||||
"ctxImport.confirm": "Importieren",
|
||||
"ctxImport.importing": "Importiere…",
|
||||
"ctxImport.working": "Lade Geodaten…",
|
||||
"ctxImport.empty": "Keine Geometrie im gewählten Bereich gefunden.",
|
||||
"ctxImport.importError": "Import fehlgeschlagen.",
|
||||
"ctxImport.imported": "{count} Objekte in {sets} Sätzen importiert.",
|
||||
|
||||
// ── Raum-Befehl ─────────────────────────────────────────────────────────────
|
||||
"cmd.room.label": "Raum",
|
||||
"cmd.room.mode": "Modus",
|
||||
"cmd.room.inside": "Klick in Raum setzen:",
|
||||
"cmd.room.first": "Ersten Raumpunkt setzen:",
|
||||
"cmd.room.next": "Nächsten Punkt setzen ( Enter/Start schließt ):",
|
||||
|
||||
// ── Text-Gruppe (Oberleiste) ─────────────────────────────────────────────
|
||||
"text.group": "Text",
|
||||
"text.style": "Stil",
|
||||
"text.style.none": "— Stil —",
|
||||
"text.font": "Schrift",
|
||||
"text.size": "Grösse",
|
||||
"text.size.custom": "Eigene …",
|
||||
"text.size.customPrompt": "Schriftgrösse in pt:",
|
||||
"text.bold": "Fett",
|
||||
"text.italic": "Kursiv",
|
||||
"text.underline": "Unterstrichen",
|
||||
"text.align.left": "Linksbündig",
|
||||
"text.align.center": "Zentriert",
|
||||
"text.align.right": "Rechtsbündig",
|
||||
"text.add": "Text",
|
||||
"text.add.hint": "Text-Werkzeug — neuen Text platzieren (bald verfügbar)",
|
||||
"text.editTitle": "Text bearbeiten",
|
||||
"text.apply": "Übernehmen",
|
||||
"text.cancel": "Abbrechen",
|
||||
"roomStamp.title": "Raumstempel bearbeiten",
|
||||
"roomStamp.number": "Raumnummer",
|
||||
"roomStamp.name": "Raumname",
|
||||
"roomStamp.nameLine2": "Raumname Zeile 2",
|
||||
"roomStamp.showFloorArea": "Bodenfläche anzeigen",
|
||||
"roomStamp.floorAreaPrefix": "Präfix Bodenfläche",
|
||||
"roomStamp.showUsage": "Nutzung anzeigen",
|
||||
"text.selectedHint": "Formatierung wirkt auf den ausgewählten Text",
|
||||
|
||||
// ── Rich-Text-Editor ────────────────────────────────────────────────────────
|
||||
"rt.bold": "Fett",
|
||||
"rt.italic": "Kursiv",
|
||||
"rt.underline": "Unterstrichen",
|
||||
"rt.strike": "Durchgestrichen",
|
||||
"rt.strikethrough": "Durchgestrichen",
|
||||
"rt.fontSize": "Schriftgrösse",
|
||||
"rt.color": "Farbe",
|
||||
"rt.preset": "Vorlage",
|
||||
"rt.preset.none": "— Vorlage —",
|
||||
} as const;
|
||||
|
||||
export type TranslationKey = keyof typeof de;
|
||||
|
||||
+267
@@ -48,6 +48,10 @@ export const en: Record<TranslationKey, string> = {
|
||||
"topbar.fit.hint": "Fit to plan",
|
||||
"topbar.fitSelection": "Selection",
|
||||
"topbar.fitSelection.hint": "Fit to selection (otherwise to plan)",
|
||||
"topbar.exportPdf": "PDF",
|
||||
"topbar.exportPdf.hint": "Export the floor plan as a vector PDF",
|
||||
"topbar.exportDxf": "DXF",
|
||||
"topbar.exportDxf.hint": "Export the floor plan as DXF (model space in meters)",
|
||||
"topbar.zoom.current": "Current zoom",
|
||||
|
||||
"topbar.renderGroup": "Render mode",
|
||||
@@ -67,6 +71,7 @@ export const en: Record<TranslationKey, string> = {
|
||||
"display.style.mono": "Black & white",
|
||||
"display.style.shaded": "Shaded",
|
||||
"display.style.white": "White",
|
||||
"display.style.textured": "Textured",
|
||||
"display.style.wireframe": "Wireframe",
|
||||
"display.style.hidden": "Hidden line",
|
||||
|
||||
@@ -102,12 +107,23 @@ export const en: Record<TranslationKey, string> = {
|
||||
"tool.group": "Tools",
|
||||
"tool.select": "Select",
|
||||
"tool.wall": "Wall",
|
||||
"tool.ceiling": "Ceiling",
|
||||
"tool.window": "Window",
|
||||
"tool.door": "Door",
|
||||
"tool.stair": "Stair",
|
||||
"tool.line": "Line",
|
||||
"tool.polyline": "Polyline",
|
||||
"tool.rect": "Rectangle",
|
||||
"tool.select.hint": "Select — click/drag elements",
|
||||
"tool.window.hint": "Window: pick a wall, then set the position",
|
||||
"tool.door.hint": "Door: pick a wall, then set the position",
|
||||
"tool.stair.hint": "Stair: set start point, then run direction/end",
|
||||
"tool.room": "Room",
|
||||
"tool.room.hint": "Room: click inside an enclosed area (or draw an outline)",
|
||||
"tool.wall.firstPoint": "Wall: set first axis point",
|
||||
"tool.wall.nextPoint": "Wall: next point — double/right-click to finish",
|
||||
"tool.ceiling.firstPoint": "Ceiling: set first outline point",
|
||||
"tool.ceiling.nextPoint": "Ceiling: next point — click start to close, Enter finishes",
|
||||
"tool.line.firstPoint": "Line: set start point",
|
||||
"tool.line.secondPoint": "Line: set end point",
|
||||
"tool.polyline.firstPoint": "Polyline: set first point",
|
||||
@@ -115,6 +131,7 @@ export const en: Record<TranslationKey, string> = {
|
||||
"tool.rect.firstCorner": "Rectangle: set first corner",
|
||||
"tool.rect.secondCorner": "Rectangle: set opposite corner",
|
||||
"tool.wallType": "Wall type",
|
||||
"tool.ceilingType": "Ceiling type",
|
||||
"tool.layer": "Layer",
|
||||
"tool.layer.hint": "Active layer — everything drawn goes onto this category",
|
||||
"tool.floorOnlyDisabled": "Walls can only be drawn on a floor",
|
||||
@@ -208,8 +225,12 @@ export const en: Record<TranslationKey, string> = {
|
||||
"objinfo.height": "Height",
|
||||
"objinfo.type": "Type",
|
||||
"objinfo.kind.wall": "Wall",
|
||||
"objinfo.kind.ceiling": "Ceiling",
|
||||
"objinfo.kind.drawing2d": "2D object",
|
||||
"objinfo.kind.door": "Door",
|
||||
"objinfo.kind.opening": "Opening",
|
||||
"objinfo.kind.stair": "Stair",
|
||||
"objinfo.kind.room": "Room",
|
||||
// ── Wall attributes (object info panel) ──────────────────────────────────
|
||||
"objinfo.wall.section": "Wall",
|
||||
"objinfo.wall.refLine": "Reference line",
|
||||
@@ -230,6 +251,64 @@ export const en: Record<TranslationKey, string> = {
|
||||
"objinfo.wall.anchorCustom": "Custom height",
|
||||
"objinfo.wall.height": "Height",
|
||||
"objinfo.wall.floorAboveNone": "No floor above",
|
||||
// ── Ceiling attributes (object info panel) ───────────────────────────────
|
||||
"objinfo.ceiling.section": "Ceiling",
|
||||
"objinfo.ceiling.buildup": "Build-up",
|
||||
"objinfo.ceiling.thickness": "Thickness",
|
||||
"objinfo.ceiling.preset": "Ceiling style",
|
||||
"objinfo.ceiling.refFloor": "Reference floor",
|
||||
"objinfo.ceiling.top": "Top (OK)",
|
||||
"objinfo.ceiling.bottom": "Bottom (UK)",
|
||||
"objinfo.ceiling.height": "Thickness (OK−UK)",
|
||||
"objinfo.ceiling.area": "Area",
|
||||
"objinfo.ceiling.anchorFloor": "Bound to floor",
|
||||
"objinfo.ceiling.anchorCustom": "Custom height",
|
||||
// ── Opening attributes (object info panel) ──────────────────────────────
|
||||
"objinfo.opening.section": "Opening",
|
||||
"objinfo.opening.kind": "Type",
|
||||
"objinfo.opening.kind.window": "Window",
|
||||
"objinfo.opening.kind.door": "Door",
|
||||
"objinfo.opening.hostWall": "Host wall",
|
||||
"objinfo.opening.position": "Position (from wall start)",
|
||||
"objinfo.opening.width": "Width",
|
||||
"objinfo.opening.height": "Height",
|
||||
"objinfo.opening.sill": "Sill height",
|
||||
"objinfo.opening.swingAngle": "Swing angle",
|
||||
"objinfo.opening.hinge": "Hinge",
|
||||
"objinfo.opening.hinge.start": "Start",
|
||||
"objinfo.opening.hinge.end": "End",
|
||||
"objinfo.opening.swing": "Swing side",
|
||||
"objinfo.opening.swing.left": "Left",
|
||||
"objinfo.opening.swing.right": "Right",
|
||||
"objinfo.opening.dir": "Direction",
|
||||
"objinfo.opening.dir.in": "In",
|
||||
"objinfo.opening.dir.out": "Out",
|
||||
"objinfo.opening.bottom": "Bottom (UK)",
|
||||
"objinfo.opening.top": "Top (OK)",
|
||||
"objinfo.stair.section": "Stair",
|
||||
"objinfo.stair.shape": "Shape",
|
||||
"objinfo.stair.shape.straight": "Straight",
|
||||
"objinfo.stair.shape.L": "L-shaped",
|
||||
"objinfo.stair.shape.spiral": "Spiral",
|
||||
"objinfo.stair.width": "Run width",
|
||||
"objinfo.stair.steps": "Step count",
|
||||
"objinfo.stair.rise": "Total rise",
|
||||
"objinfo.stair.riser": "Riser height",
|
||||
"objinfo.stair.tread": "Tread depth",
|
||||
"objinfo.stair.direction": "Run direction",
|
||||
"objinfo.stair.direction.up": "Upwards",
|
||||
"objinfo.stair.direction.down": "Downwards",
|
||||
"objinfo.stair.refFloor": "Reference floor",
|
||||
"objinfo.stair.bottom": "Bottom (UK)",
|
||||
"objinfo.stair.top": "Top (OK)",
|
||||
// ── Room attributes (object info panel) ──────────────────────────────────
|
||||
"objinfo.room.section": "Room",
|
||||
"objinfo.room.name": "Name",
|
||||
"objinfo.room.sia": "SIA category",
|
||||
"objinfo.room.area": "Area",
|
||||
"objinfo.room.perimeter": "Perimeter",
|
||||
"objinfo.room.refFloor": "Reference floor",
|
||||
"objinfo.room.editStamp": "Edit stamp …",
|
||||
"levels.addFloor": "+ Floor",
|
||||
"levels.addDrawing": "+ Drawing",
|
||||
"layers.addCategory": "+ Layer",
|
||||
@@ -279,6 +358,11 @@ export const en: Record<TranslationKey, string> = {
|
||||
"ctx.fitSelection": "Fit to selection",
|
||||
"ctx.clearSelection": "Clear selection",
|
||||
"ctx.fit": "Fit view",
|
||||
"ctx.union": "Union",
|
||||
"ctx.subtract": "Subtract",
|
||||
"ctx.intersect": "Intersect",
|
||||
"ctx.boolean.disabled":
|
||||
"Select at least two closed areas (rectangle or closed polyline).",
|
||||
"ctx.noWallHit": "No wall hit",
|
||||
"ctx.noWallHit.hint": "Right-clicking a wall selects it and shows wall actions.",
|
||||
|
||||
@@ -355,8 +439,47 @@ export const en: Record<TranslationKey, string> = {
|
||||
"resources.col.prio": "Prio",
|
||||
"resources.col.prio.hint": "Higher priority runs through at the join",
|
||||
"resources.col.texture": "Texture",
|
||||
"resources.col.material": "Material",
|
||||
"resources.delete.component": "Delete component “{name}”",
|
||||
|
||||
// 3D material (PBR) — built-in library + upload.
|
||||
"material.assign": "Material …",
|
||||
"material.none": "— none —",
|
||||
"material.dialog.title": "Assign 3D material",
|
||||
"material.dialog.hint": "Pick a built-in material or upload your own textures. Applies in the 3D “Textured” mode.",
|
||||
"material.library": "Library (ambientCG, CC0)",
|
||||
"material.upload": "Own textures",
|
||||
"material.upload.hint": "Upload images as maps (at least color). Size = physical tile size in meters.",
|
||||
"material.upload.color": "Color (albedo)",
|
||||
"material.upload.normal": "Normal",
|
||||
"material.upload.roughness": "Roughness",
|
||||
"material.upload.displacement": "Height",
|
||||
"material.upload.ao": "AO",
|
||||
"material.upload.choose": "Image …",
|
||||
"material.upload.apply": "Assign",
|
||||
"material.size": "Tile size (m)",
|
||||
"material.current": "Current",
|
||||
"material.clear": "Remove material",
|
||||
"material.close": "Close",
|
||||
"material.uploaded": "Uploaded",
|
||||
// ── Browse ambientCG library (live) ──────────────────────────────────────
|
||||
"material.browse": "Browse library",
|
||||
"material.browse.starter": "Quick pick",
|
||||
"material.browse.hint":
|
||||
"Search the full CC0 ambientCG library (~2000+ materials). One click downloads the textures at runtime and assigns them.",
|
||||
"material.browse.search": "Search …",
|
||||
"material.browse.searchPlaceholder": "e.g. wood, concrete, tiles …",
|
||||
"material.browse.category.all": "All categories",
|
||||
"material.browse.resolution": "Resolution",
|
||||
"material.browse.more": "Load more",
|
||||
"material.browse.loading": "Loading …",
|
||||
"material.browse.downloading": "Downloading texture …",
|
||||
"material.browse.empty": "No materials found.",
|
||||
"material.browse.count": "{shown} of {total}",
|
||||
"material.browse.error":
|
||||
"Library unreachable. A proxy for ambientCG is required at runtime (dev: Vite proxy active; prod: set VITE_AMBIENTCG_PROXY).",
|
||||
"material.browse.downloadError": "Download failed (check CORS/proxy).",
|
||||
|
||||
"resources.hatches.hint": "Pattern, scale and angle drive the section hatch; the line style sets the weight of the pattern lines.",
|
||||
"resources.hatches.empty": "No hatches yet.",
|
||||
"resources.hatches.placeholder": "Hatch",
|
||||
@@ -390,6 +513,30 @@ export const en: Record<TranslationKey, string> = {
|
||||
"cmd.aria": "Command line",
|
||||
"cmd.idle.prompt": "Command:",
|
||||
"cmd.idle.placeholder": "Type a command … (Tab)",
|
||||
"cmd.wall.label": "Wall",
|
||||
"cmd.wall.start": "Start of wall:",
|
||||
"cmd.wall.next": "Next point:",
|
||||
"cmd.ceiling.label": "Ceiling",
|
||||
"cmd.ceiling.start": "First outline point of ceiling:",
|
||||
"cmd.ceiling.next": "Next point ( Close ):",
|
||||
"cmd.opening.label": "Opening",
|
||||
"cmd.opening.windowLabel": "Window",
|
||||
"cmd.opening.doorLabel": "Door",
|
||||
"cmd.opening.wall": "Pick a wall for the opening:",
|
||||
"cmd.opening.pos": "Position along the wall ( type distance ):",
|
||||
"cmd.opening.kind": "Type",
|
||||
"cmd.opening.hinge": "Hinge",
|
||||
"cmd.opening.side": "Side",
|
||||
"cmd.opening.dir": "Direction",
|
||||
"cmd.opening.sillField": "Sill",
|
||||
"cmd.opening.swingField": "Angle",
|
||||
"cmd.stair.label": "Stair",
|
||||
"cmd.stair.start": "Start point of the stair:",
|
||||
"cmd.stair.run": "Run direction / end ( Shape · Up/Down ):",
|
||||
"cmd.stair.shape": "Shape",
|
||||
"cmd.stair.updown": "Up/Down",
|
||||
"cmd.stair.stepsField": "Steps",
|
||||
"cmd.stair.riseField": "Rise",
|
||||
"cmd.line.label": "Line",
|
||||
"cmd.line.start": "Start of line:",
|
||||
"cmd.line.end": "End of line:",
|
||||
@@ -398,9 +545,15 @@ export const en: Record<TranslationKey, string> = {
|
||||
"cmd.polyline.next": "Next point:",
|
||||
"cmd.polyline.close": "Close",
|
||||
"cmd.polyline.undo": "Undo",
|
||||
"cmd.polyline.closedOpt": "Closed",
|
||||
"cmd.rect.label": "Rectangle",
|
||||
"cmd.rect.first": "First corner:",
|
||||
"cmd.rect.second": "Opposite corner:",
|
||||
"cmd.rect.method": "Method",
|
||||
"cmd.rect.center": "Center:",
|
||||
"cmd.rect.corner": "Corner:",
|
||||
"cmd.rect.baseEnd": "Second corner (base edge):",
|
||||
"cmd.rect.rise": "Height:",
|
||||
"cmd.circle.label": "Circle",
|
||||
"cmd.circle.center": "Center:",
|
||||
"cmd.circle.radius": "Radius:",
|
||||
@@ -408,6 +561,11 @@ export const en: Record<TranslationKey, string> = {
|
||||
"cmd.move.base": "Base point:",
|
||||
"cmd.move.target": "Target point:",
|
||||
"cmd.move.empty": "Nothing selected (select objects first).",
|
||||
"cmd.mirror.label": "Mirror",
|
||||
"cmd.mirror.first": "First point of mirror axis:",
|
||||
"cmd.mirror.second": "Second point of mirror axis:",
|
||||
"cmd.mirror.empty": "Nothing selected (select objects first).",
|
||||
"cmd.join.prompt": "Joining selected elements …",
|
||||
"cmd.copy.label": "Copy",
|
||||
"cmd.copy.base": "Base point:",
|
||||
"cmd.copy.target": "Target point (Enter to finish):",
|
||||
@@ -415,6 +573,9 @@ export const en: Record<TranslationKey, string> = {
|
||||
"cmd.offset.label": "Offset",
|
||||
"cmd.offset.pick": "Select curve:",
|
||||
"cmd.offset.side": "Side or distance:",
|
||||
// Trim (quick-trim): click the part to cut away; repeats until Esc.
|
||||
"cmd.trim.label": "Trim",
|
||||
"cmd.trim.pick": "Click the part to trim away (Esc to finish):",
|
||||
// Edit tools Split / Join / Delete segment (keyboard: Ctrl+S / Ctrl+J /
|
||||
// Alt+Click). Labels for the command line / menus.
|
||||
"edit.split.label": "Split",
|
||||
@@ -427,6 +588,8 @@ export const en: Record<TranslationKey, string> = {
|
||||
"cmd.field.height": "Height",
|
||||
"cmd.field.radius": "Radius",
|
||||
"cmd.field.distance": "Distance",
|
||||
"cmd.field.thickness": "Thickness",
|
||||
"cmd.edit.point": "Drag grip (Tab: length/angle):",
|
||||
|
||||
// Commands: import / terrain.
|
||||
"cmd.import.label": "Import",
|
||||
@@ -434,6 +597,17 @@ export const en: Record<TranslationKey, string> = {
|
||||
"cmd.terrain.label": "Terrain",
|
||||
"cmd.terrain.prompt": "Generate terrain from contours …",
|
||||
|
||||
// Area balance (SIA 416).
|
||||
"nav.roomBalance": "Area balance",
|
||||
"balance.title": "Area balance (SIA 416)",
|
||||
"balance.rooms": "Rooms",
|
||||
"balance.summary": "Balance",
|
||||
"balance.export": "Export CSV",
|
||||
"balance.empty": "No rooms yet. Create one with the room tool.",
|
||||
// Room stamp editor.
|
||||
"room.stamp.editTitle": "Edit room stamp",
|
||||
"room.stamp.close": "Close",
|
||||
|
||||
// Site / context panel (SitePanel).
|
||||
"nav.site": "Terrain",
|
||||
"site.title": "Terrain & Context",
|
||||
@@ -479,6 +653,28 @@ export const en: Record<TranslationKey, string> = {
|
||||
"import.badge.elevation": "Elevation",
|
||||
"import.badge.drawing": "Drawing",
|
||||
|
||||
// PDF export.
|
||||
"export.title": "Export PDF",
|
||||
"export.close": "Close",
|
||||
"export.plan": "Plan",
|
||||
"export.scale": "Scale",
|
||||
"export.paper": "Paper size",
|
||||
"export.orientation": "Orientation",
|
||||
"export.orientation.portrait": "Portrait",
|
||||
"export.orientation.landscape": "Landscape",
|
||||
"export.warnFit":
|
||||
"At the chosen scale the plan does not fully fit the sheet — pick a larger format or a smaller scale.",
|
||||
"export.confirm": "Export",
|
||||
"export.cancel": "Cancel",
|
||||
|
||||
// DXF export.
|
||||
"exportDxf.title": "Export DXF",
|
||||
"exportDxf.hatches": "Hatches",
|
||||
"exportDxf.hatches.yes": "Include",
|
||||
"exportDxf.hatches.no": "Outlines only",
|
||||
"exportDxf.unitsNote":
|
||||
"Model space in meters (1:1). Layers mirror the project's categories; scale freely on plot.",
|
||||
|
||||
// Drag & drop (app-wide file drop).
|
||||
"drop.hint": "Drop a DXF/DWG file here",
|
||||
|
||||
@@ -487,4 +683,75 @@ export const en: Record<TranslationKey, string> = {
|
||||
"error.title": "Something went wrong",
|
||||
"error.body": "An unexpected error occurred. Details in the console.",
|
||||
"error.reload": "Reload",
|
||||
|
||||
// Location import (geo.admin / OSM).
|
||||
"site.importLocation": "Import location",
|
||||
"ctxImport.title": "Import location",
|
||||
"ctxImport.close": "Close",
|
||||
"ctxImport.location": "Place / address",
|
||||
"ctxImport.searchPlaceholder": "e.g. Bundesplatz Bern",
|
||||
"ctxImport.search": "Search",
|
||||
"ctxImport.searching": "Searching…",
|
||||
"ctxImport.noResults": "No matches.",
|
||||
"ctxImport.searchError": "Search failed.",
|
||||
"ctxImport.radius": "Radius",
|
||||
"ctxImport.sources": "Sources",
|
||||
"ctxImport.src.swissBuildings": "Swisstopo buildings",
|
||||
"ctxImport.src.osmBuildings": "OSM buildings",
|
||||
"ctxImport.src.osmRoads": "OSM roads",
|
||||
"ctxImport.src.osmWater": "OSM water",
|
||||
"ctxImport.src.osmGreen": "OSM greenery",
|
||||
"ctxImport.cancel": "Cancel",
|
||||
"ctxImport.confirm": "Import",
|
||||
"ctxImport.importing": "Importing…",
|
||||
"ctxImport.working": "Loading geodata…",
|
||||
"ctxImport.empty": "No geometry found in the selected area.",
|
||||
"ctxImport.importError": "Import failed.",
|
||||
"ctxImport.imported": "Imported {count} objects in {sets} sets.",
|
||||
|
||||
// Room command.
|
||||
"cmd.room.label": "Room",
|
||||
"cmd.room.mode": "Mode",
|
||||
"cmd.room.inside": "Click inside room:",
|
||||
"cmd.room.first": "Set first room point:",
|
||||
"cmd.room.next": "Set next point ( Enter/Start closes ):",
|
||||
|
||||
// Rich-text editor.
|
||||
// Text group (top bar).
|
||||
"text.group": "Text",
|
||||
"text.style": "Style",
|
||||
"text.style.none": "— Style —",
|
||||
"text.font": "Font",
|
||||
"text.size": "Size",
|
||||
"text.size.custom": "Custom …",
|
||||
"text.size.customPrompt": "Font size in pt:",
|
||||
"text.bold": "Bold",
|
||||
"text.italic": "Italic",
|
||||
"text.underline": "Underline",
|
||||
"text.align.left": "Align left",
|
||||
"text.align.center": "Center",
|
||||
"text.align.right": "Align right",
|
||||
"text.add": "Text",
|
||||
"text.add.hint": "Text tool — place new text (coming soon)",
|
||||
"text.editTitle": "Edit text",
|
||||
"text.apply": "Apply",
|
||||
"text.cancel": "Cancel",
|
||||
"roomStamp.title": "Edit room stamp",
|
||||
"roomStamp.number": "Room number",
|
||||
"roomStamp.name": "Room name",
|
||||
"roomStamp.nameLine2": "Room name line 2",
|
||||
"roomStamp.showFloorArea": "Show floor area",
|
||||
"roomStamp.floorAreaPrefix": "Floor area prefix",
|
||||
"roomStamp.showUsage": "Show usage",
|
||||
"text.selectedHint": "Formatting applies to the selected text",
|
||||
|
||||
"rt.bold": "Bold",
|
||||
"rt.italic": "Italic",
|
||||
"rt.underline": "Underline",
|
||||
"rt.strike": "Strikethrough",
|
||||
"rt.strikethrough": "Strikethrough",
|
||||
"rt.fontSize": "Font size",
|
||||
"rt.color": "Color",
|
||||
"rt.preset": "Preset",
|
||||
"rt.preset.none": "— Preset —",
|
||||
};
|
||||
|
||||
+513
-75
@@ -11,23 +11,34 @@
|
||||
// dwg.dwg_read_data(buffer, Dwg_File_Type.DWG) → Dwg_Data-Pointer
|
||||
// dwg.convert(ptr) → DwgDatabase (stark typisiert)
|
||||
// dwg.dwg_free(ptr) → Speicher freigeben
|
||||
// db.entities → Modellraum-Entities (DwgEntity[])
|
||||
// db.tables.BLOCK_RECORD.entries → Block-Definitionen
|
||||
// (name → entities[]) für INSERT.
|
||||
// Der WASM-Build dieser Lib hat DXF-Schreiben/-Lesen DEAKTIVIERT (disable-dxf),
|
||||
// daher gehen wir bewusst NICHT über DWG→DXF-Text, sondern mappen die geparste
|
||||
// DwgDatabase direkt (ein Mapping, gespiegelt aus dxfParser).
|
||||
//
|
||||
// Unterstützte Entities (Modellraum):
|
||||
// Unterstützte Entities (Modellraum + expandierte Blöcke):
|
||||
// • 3DFACE → Dreiecks-Mesh (3 bzw. 4 Ecken → 1–2 Tri).
|
||||
// • POLYLINE2D (Polyface, Flag 64) → Dreiecks-Mesh (Polyface-Faces).
|
||||
// • LWPOLYLINE / POLYLINE2D / 3D → Kontur (z aus elevation/Vertex-Z).
|
||||
// • LINE → Kontur (zwei-Punkt-Linienzug).
|
||||
// • ARC → Kontur (Bogen, ~6° je Segment, offen).
|
||||
// • CIRCLE → Kontur (geschlossen, 48 Segmente).
|
||||
// • ELLIPSE → Kontur (geschlossen bei Vollellipse).
|
||||
// • SPLINE → Kontur (fitPoints, sonst controlPoints).
|
||||
// • INSERT → Block-Expansion mit Transformation
|
||||
// (Translation/Skalierung/Rotation),
|
||||
// rekursiv (Tiefen-Limit gegen Zyklen).
|
||||
// • POINT → bewusst ignoriert (kein Polylinien-Bezug).
|
||||
//
|
||||
// Lazy: Das Paket wird per dynamischem import() geladen (kein Bloat im Initial-
|
||||
// Bundle); WASM startet erst beim ersten DWG-Import.
|
||||
//
|
||||
// Bezeichner englisch, Kommentare deutsch (CLAUDE.md).
|
||||
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||||
|
||||
import type { Contour, ContourSet, ImportedMesh, Vec2 } from "../model/types";
|
||||
import type { DxfImportResult } from "./dxfParser";
|
||||
import type { DxfImportResult, ImportDiagnostics } from "./dxfParser";
|
||||
|
||||
// Lose getippte Sicht auf die libredwg-web-Entities (wir greifen tolerant auf die
|
||||
// Felder zu, die wir brauchen; alle Punkte sind {x,y,z}).
|
||||
@@ -56,13 +67,42 @@ interface DwgEntityLike {
|
||||
corner3?: DwgPoint;
|
||||
corner4?: DwgPoint;
|
||||
vertices?: DwgVertexLike[];
|
||||
// ── ARC / CIRCLE ──
|
||||
center?: DwgPoint;
|
||||
radius?: number;
|
||||
startAngle?: number;
|
||||
endAngle?: number;
|
||||
// ── ELLIPSE ──
|
||||
majorAxisEndPoint?: DwgPoint;
|
||||
axisRatio?: number;
|
||||
// ── SPLINE ──
|
||||
fitPoints?: DwgPoint[];
|
||||
controlPoints?: DwgPoint[];
|
||||
// ── INSERT ──
|
||||
name?: string;
|
||||
insertionPoint?: DwgPoint;
|
||||
xScale?: number;
|
||||
yScale?: number;
|
||||
zScale?: number;
|
||||
rotation?: number;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
// Block-Record-Tabelleneintrag: Block-Name → Entities (siehe blockRecord.d.ts).
|
||||
interface DwgBlockRecordLike {
|
||||
name?: string;
|
||||
entities?: DwgEntityLike[];
|
||||
}
|
||||
|
||||
// Schmale Sicht auf die LibreDWG-Instanz (nur die Methoden, die wir nutzen).
|
||||
interface LibreDwgApi {
|
||||
dwg_read_data(data: ArrayBuffer, fileType: number): number | undefined;
|
||||
convert(ptr: number): { entities?: unknown[] };
|
||||
convert(ptr: number): {
|
||||
entities?: unknown[];
|
||||
tables?: {
|
||||
BLOCK_RECORD?: { entries?: DwgBlockRecordLike[] };
|
||||
};
|
||||
};
|
||||
dwg_free(ptr: number): void;
|
||||
}
|
||||
|
||||
@@ -111,10 +151,118 @@ const POLYFACE_FLAG = 64;
|
||||
// POLYLINE-Flag 1 = geschlossen.
|
||||
const CLOSED_FLAG = 1;
|
||||
|
||||
// Tessellierungs-Parameter (Bögen/Kreise/Ellipsen/Splines).
|
||||
const ARC_SEG_RAD = (8 * Math.PI) / 180; // ~8° je Bogen-Segment …
|
||||
const ARC_MIN_SEGMENTS = 6; // … aber mindestens 6 Segmente.
|
||||
const CIRCLE_SEGMENTS = 48; // Vollkreis-Auflösung.
|
||||
const ELLIPSE_SEGMENTS = 64; // Vollellipse-Auflösung.
|
||||
const SPLINE_SEGMENTS = 64; // Spline-Auflösung (controlPoints-Fall).
|
||||
|
||||
// Block-Expansions-Tiefenlimit (gegen zyklische INSERTs / Blöcke in Blöcken).
|
||||
const MAX_BLOCK_DEPTH = 10;
|
||||
|
||||
/**
|
||||
* 2D-Affin-Transform (für INSERT-Expansion). Bildet einen Block-Modellraum auf
|
||||
* den Welt-Modellraum ab: `[x', y'] = M·[x, y] + t`. Z wird separat (additiv +
|
||||
* zScale) behandelt, da unsere Konturen 2D-Punkte + eine z-Höhe führen.
|
||||
*/
|
||||
interface Transform2D {
|
||||
// Lineare 2×2-Matrix (Skalierung · Rotation), spaltenweise.
|
||||
m00: number;
|
||||
m01: number;
|
||||
m10: number;
|
||||
m11: number;
|
||||
// Translation.
|
||||
tx: number;
|
||||
ty: number;
|
||||
// Z-Anteil (additiver Versatz + Skalierung).
|
||||
zOffset: number;
|
||||
zScale: number;
|
||||
}
|
||||
|
||||
const IDENTITY: Transform2D = {
|
||||
m00: 1,
|
||||
m01: 0,
|
||||
m10: 0,
|
||||
m11: 1,
|
||||
tx: 0,
|
||||
ty: 0,
|
||||
zOffset: 0,
|
||||
zScale: 1,
|
||||
};
|
||||
|
||||
/** Wendet eine Transform2D auf einen 2D-Punkt an. */
|
||||
function applyXY(t: Transform2D, x: number, y: number): Vec2 {
|
||||
return {
|
||||
x: t.m00 * x + t.m01 * y + t.tx,
|
||||
y: t.m10 * x + t.m11 * y + t.ty,
|
||||
};
|
||||
}
|
||||
|
||||
/** Wendet die Z-Komponente einer Transform2D auf einen z-Wert an. */
|
||||
function applyZ(t: Transform2D, z: number): number {
|
||||
return z * t.zScale + t.zOffset;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verkettet zwei Transforms: `outer ∘ inner` (inner wird zuerst angewandt, dann
|
||||
* outer). Für rekursive Block-Expansion (Block in Block).
|
||||
*/
|
||||
function compose(outer: Transform2D, inner: Transform2D): Transform2D {
|
||||
return {
|
||||
m00: outer.m00 * inner.m00 + outer.m01 * inner.m10,
|
||||
m01: outer.m00 * inner.m01 + outer.m01 * inner.m11,
|
||||
m10: outer.m10 * inner.m00 + outer.m11 * inner.m10,
|
||||
m11: outer.m10 * inner.m01 + outer.m11 * inner.m11,
|
||||
tx: outer.m00 * inner.tx + outer.m01 * inner.ty + outer.tx,
|
||||
ty: outer.m10 * inner.tx + outer.m11 * inner.ty + outer.ty,
|
||||
zOffset: outer.zScale * inner.zOffset + outer.zOffset,
|
||||
zScale: outer.zScale * inner.zScale,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut die Einfüge-Transform eines INSERT: Skalierung (xScale/yScale/zScale) →
|
||||
* Rotation (um Z) → Translation (insertionPoint).
|
||||
*/
|
||||
function insertTransform(e: DwgEntityLike): Transform2D {
|
||||
const ip = e.insertionPoint ?? { x: 0, y: 0, z: 0 };
|
||||
const sx = Number.isFinite(e.xScale) ? (e.xScale as number) : 1;
|
||||
const sy = Number.isFinite(e.yScale) ? (e.yScale as number) : 1;
|
||||
const sz = Number.isFinite(e.zScale) ? (e.zScale as number) : 1;
|
||||
const rot = Number.isFinite(e.rotation) ? (e.rotation as number) : 0;
|
||||
const cos = Math.cos(rot);
|
||||
const sin = Math.sin(rot);
|
||||
// M = R · S (erst skalieren, dann rotieren).
|
||||
return {
|
||||
m00: cos * sx,
|
||||
m01: -sin * sy,
|
||||
m10: sin * sx,
|
||||
m11: cos * sy,
|
||||
tx: ip.x,
|
||||
ty: ip.y,
|
||||
zOffset: ip.z ?? 0,
|
||||
zScale: sz,
|
||||
};
|
||||
}
|
||||
|
||||
// Aufnahme-Kontext für ein Mapping-Durchlauf (Meshes + Konturen + Histogramm).
|
||||
interface Accum {
|
||||
meshTriangles: number[];
|
||||
meshIndices: number[];
|
||||
contours: Contour[];
|
||||
entityCounts: Record<string, number>;
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parst eine DWG-Datei (als ArrayBuffer) in dasselbe Kontext-Geometrie-Format
|
||||
* wie der DXF-Parser. Lädt LibreDWG-WASM lazy. Wirft bei fatalen Fehlern (der
|
||||
* Aufrufer fängt das ab und zeigt den ODA-Fallback) — pro-Entity-Fehler nicht.
|
||||
*
|
||||
* Reale DWGs stecken Geometrie oft in BLÖCKEN, die nur per INSERT referenziert
|
||||
* werden; wir lesen die Block-Definitionen aus `db.tables.BLOCK_RECORD` und
|
||||
* expandieren jedes INSERT mit Transformation (rekursiv, Tiefen-Limit).
|
||||
*/
|
||||
export async function parseDwg(data: ArrayBuffer): Promise<DxfImportResult> {
|
||||
const libredwg = await createLibreDwg();
|
||||
@@ -131,63 +279,71 @@ export async function parseDwg(data: ArrayBuffer): Promise<DxfImportResult> {
|
||||
const db = libredwg.convert(dwgPtr);
|
||||
const entities = (db?.entities ?? []) as unknown as DwgEntityLike[];
|
||||
|
||||
const meshTriangles: number[] = [];
|
||||
const meshIndices: number[] = [];
|
||||
const contours: Contour[] = [];
|
||||
|
||||
for (const e of entities) {
|
||||
const type = (e.type ?? "").toUpperCase();
|
||||
switch (type) {
|
||||
case "3DFACE":
|
||||
addFace(meshTriangles, meshIndices, e);
|
||||
break;
|
||||
case "POLYLINE2D":
|
||||
case "POLYLINE3D":
|
||||
if (isPolyfaceMesh(e)) {
|
||||
addPolyfaceMesh(meshTriangles, meshIndices, e);
|
||||
} else {
|
||||
const ct = polylineContour(e);
|
||||
if (ct) contours.push(ct);
|
||||
}
|
||||
break;
|
||||
case "LWPOLYLINE": {
|
||||
const ct = polylineContour(e);
|
||||
if (ct) contours.push(ct);
|
||||
break;
|
||||
}
|
||||
case "LINE": {
|
||||
const ct = lineContour(e);
|
||||
if (ct) contours.push(ct);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
// Unbekannte/irrelevante Entity → ignorieren (tolerant).
|
||||
break;
|
||||
// Block-Definitionen (name → entities) für die INSERT-Expansion.
|
||||
const blockEntries = db?.tables?.BLOCK_RECORD?.entries ?? [];
|
||||
const blocks = new Map<string, DwgEntityLike[]>();
|
||||
for (const b of blockEntries) {
|
||||
if (b?.name && Array.isArray(b.entities)) {
|
||||
blocks.set(b.name, b.entities);
|
||||
}
|
||||
}
|
||||
|
||||
const acc: Accum = {
|
||||
meshTriangles: [],
|
||||
meshIndices: [],
|
||||
contours: [],
|
||||
entityCounts: {},
|
||||
total: 0,
|
||||
};
|
||||
|
||||
// Modellraum-Entities mappen (INSERTs expandieren Blöcke rekursiv).
|
||||
mapEntities(entities, IDENTITY, blocks, 0, acc);
|
||||
|
||||
const meshes: ImportedMesh[] = [];
|
||||
if (meshIndices.length > 0) {
|
||||
if (acc.meshIndices.length > 0) {
|
||||
meshes.push({
|
||||
id: nextId("imported"),
|
||||
type: "importedMesh",
|
||||
name: "DWG-Mesh",
|
||||
positions: meshTriangles,
|
||||
indices: meshIndices,
|
||||
positions: acc.meshTriangles,
|
||||
indices: acc.meshIndices,
|
||||
});
|
||||
}
|
||||
|
||||
const contourSets: ContourSet[] = [];
|
||||
if (contours.length > 0) {
|
||||
if (acc.contours.length > 0) {
|
||||
contourSets.push({
|
||||
id: nextId("contours"),
|
||||
type: "contourSet",
|
||||
name: "DWG-Konturen",
|
||||
contours,
|
||||
contours: acc.contours,
|
||||
});
|
||||
}
|
||||
|
||||
return { meshes, contours: contourSets };
|
||||
const diagnostics: ImportDiagnostics = {
|
||||
entityCounts: acc.entityCounts,
|
||||
total: acc.total,
|
||||
};
|
||||
|
||||
// Diagnose immer loggen; im „nichts gemappt"-Fall hervorheben (Histogramm
|
||||
// macht die Ursache sichtbar: 0 Entities = Encoding/Version vs. Abdeckungs-
|
||||
// lücke = z. B. „500 ARC, 200 INSERT").
|
||||
const mapped = meshes.length + contourSets.length;
|
||||
if (mapped === 0) {
|
||||
console.info(
|
||||
"[dwgParser] Keine Geometrie gemappt. Entity-Histogramm:",
|
||||
acc.entityCounts,
|
||||
`(${acc.total} Entities)`,
|
||||
);
|
||||
} else {
|
||||
console.info(
|
||||
"[dwgParser] Entity-Histogramm:",
|
||||
acc.entityCounts,
|
||||
`(${acc.total} Entities)`,
|
||||
);
|
||||
}
|
||||
|
||||
return { meshes, contours: contourSets, diagnostics };
|
||||
} finally {
|
||||
// Speicher der WASM-Instanz freigeben (auch im Fehlerfall).
|
||||
if (dwgPtr != null) {
|
||||
@@ -200,8 +356,106 @@ export async function parseDwg(data: ArrayBuffer): Promise<DxfImportResult> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mappt eine Entity-Liste unter einer Transform in den Aufnahme-Kontext. INSERTs
|
||||
* werden über `blocks` aufgelöst und rekursiv (mit verketteter Transform) erneut
|
||||
* gemappt; `depth` begrenzt die Rekursion gegen zyklische Blöcke.
|
||||
*/
|
||||
function mapEntities(
|
||||
entities: DwgEntityLike[],
|
||||
xform: Transform2D,
|
||||
blocks: Map<string, DwgEntityLike[]>,
|
||||
depth: number,
|
||||
acc: Accum,
|
||||
): void {
|
||||
for (const e of entities) {
|
||||
const type = (e.type ?? "").toUpperCase();
|
||||
// Histogramm zählt JEDE betrachtete Entity (auch ignorierte/expandierte).
|
||||
acc.entityCounts[type] = (acc.entityCounts[type] ?? 0) + 1;
|
||||
acc.total += 1;
|
||||
|
||||
switch (type) {
|
||||
case "3DFACE":
|
||||
addFace(acc, e, xform);
|
||||
break;
|
||||
case "POLYLINE2D":
|
||||
case "POLYLINE3D":
|
||||
if (isPolyfaceMesh(e)) {
|
||||
addPolyfaceMesh(acc, e, xform);
|
||||
} else {
|
||||
const ct = polylineContour(e, xform);
|
||||
if (ct) acc.contours.push(ct);
|
||||
}
|
||||
break;
|
||||
case "LWPOLYLINE": {
|
||||
const ct = polylineContour(e, xform);
|
||||
if (ct) acc.contours.push(ct);
|
||||
break;
|
||||
}
|
||||
case "LINE": {
|
||||
const ct = lineContour(e, xform);
|
||||
if (ct) acc.contours.push(ct);
|
||||
break;
|
||||
}
|
||||
case "ARC": {
|
||||
const ct = arcContour(e, xform);
|
||||
if (ct) acc.contours.push(ct);
|
||||
break;
|
||||
}
|
||||
case "CIRCLE": {
|
||||
const ct = circleContour(e, xform);
|
||||
if (ct) acc.contours.push(ct);
|
||||
break;
|
||||
}
|
||||
case "ELLIPSE": {
|
||||
const ct = ellipseContour(e, xform);
|
||||
if (ct) acc.contours.push(ct);
|
||||
break;
|
||||
}
|
||||
case "SPLINE": {
|
||||
const ct = splineContour(e, xform);
|
||||
if (ct) acc.contours.push(ct);
|
||||
break;
|
||||
}
|
||||
case "INSERT": {
|
||||
if (depth >= MAX_BLOCK_DEPTH) break; // Zyklen-/Tiefenschutz.
|
||||
const blockEntities = e.name ? blocks.get(e.name) : undefined;
|
||||
if (!blockEntities || blockEntities.length === 0) break;
|
||||
const local = compose(xform, insertTransform(e));
|
||||
mapEntities(blockEntities, local, blocks, depth + 1, acc);
|
||||
break;
|
||||
}
|
||||
case "POINT":
|
||||
// POINT bewusst ignorieren (kein Polylinien-/Flächenbezug).
|
||||
break;
|
||||
default:
|
||||
// Unbekannte/irrelevante Entity → ignorieren (tolerant).
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mesh-Entities ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 3DFACE: 3 oder 4 Eckpunkte (corner1..4). Bei 4 ≠ 3 Punkten zwei Dreiecke (Fan).
|
||||
* Punkte werden mit der aktiven Transform in den Modellraum gebracht.
|
||||
*/
|
||||
function addFace(acc: Accum, e: DwgEntityLike, xform: Transform2D): void {
|
||||
const positions = acc.meshTriangles;
|
||||
const indices = acc.meshIndices;
|
||||
const corners = [e.corner1, e.corner2, e.corner3, e.corner4].filter(
|
||||
(c): c is DwgPoint => !!c && Number.isFinite(c.x) && Number.isFinite(c.y),
|
||||
);
|
||||
if (corners.length < 3) return;
|
||||
const idx = corners.map((c) => {
|
||||
const p = applyXY(xform, c.x, c.y);
|
||||
return pushVertex(positions, p.x, p.y, applyZ(xform, c.z ?? 0));
|
||||
});
|
||||
pushTri(indices, positions, idx[0], idx[1], idx[2]);
|
||||
if (idx.length >= 4) pushTri(indices, positions, idx[0], idx[2], idx[3]);
|
||||
}
|
||||
|
||||
/** Fügt einen Vertex zu positions hinzu und liefert seinen Index. */
|
||||
function pushVertex(
|
||||
positions: number[],
|
||||
@@ -214,25 +468,6 @@ function pushVertex(
|
||||
return idx;
|
||||
}
|
||||
|
||||
/**
|
||||
* 3DFACE: 3 oder 4 Eckpunkte (corner1..4). Bei 4 ≠ 3 Punkten zwei Dreiecke (Fan).
|
||||
*/
|
||||
function addFace(
|
||||
positions: number[],
|
||||
indices: number[],
|
||||
e: DwgEntityLike,
|
||||
): void {
|
||||
const corners = [e.corner1, e.corner2, e.corner3, e.corner4].filter(
|
||||
(c): c is DwgPoint => !!c && Number.isFinite(c.x) && Number.isFinite(c.y),
|
||||
);
|
||||
if (corners.length < 3) return;
|
||||
const idx = corners.map((c) =>
|
||||
pushVertex(positions, c.x, c.y, c.z ?? 0),
|
||||
);
|
||||
pushTri(indices, positions, idx[0], idx[1], idx[2]);
|
||||
if (idx.length >= 4) pushTri(indices, positions, idx[0], idx[2], idx[3]);
|
||||
}
|
||||
|
||||
/** Ob eine POLYLINE eine Polyface-Mesh-Variante ist (Flag 64 oder Polyface-Indizes). */
|
||||
function isPolyfaceMesh(e: DwgEntityLike): boolean {
|
||||
if (((e.flag ?? 0) & POLYFACE_FLAG) !== 0) return true;
|
||||
@@ -251,10 +486,12 @@ function isPolyfaceMesh(e: DwgEntityLike): boolean {
|
||||
* bare Kante Polyface-Indizes). Wir trennen beide und fan-triangulieren jede Face.
|
||||
*/
|
||||
function addPolyfaceMesh(
|
||||
positions: number[],
|
||||
indices: number[],
|
||||
acc: Accum,
|
||||
e: DwgEntityLike,
|
||||
xform: Transform2D,
|
||||
): void {
|
||||
const positions = acc.meshTriangles;
|
||||
const indices = acc.meshIndices;
|
||||
const all = e.vertices ?? [];
|
||||
const hasFaceIdx = (v: DwgVertexLike): boolean =>
|
||||
!!(
|
||||
@@ -267,7 +504,10 @@ function addPolyfaceMesh(
|
||||
const faceRecs = all.filter((v) => hasFaceIdx(v));
|
||||
if (geom.length < 3) return;
|
||||
const base = positions.length / 3;
|
||||
for (const v of geom) positions.push(v.x, v.y, v.z ?? 0);
|
||||
for (const v of geom) {
|
||||
const p = applyXY(xform, v.x, v.y);
|
||||
positions.push(p.x, p.y, applyZ(xform, v.z ?? 0));
|
||||
}
|
||||
for (const fr of faceRecs) {
|
||||
// 1-basierte Indizes; Vorzeichen markiert (un)sichtbare Kanten → abs().
|
||||
const ring = [
|
||||
@@ -308,26 +548,27 @@ function pushTri(
|
||||
/**
|
||||
* LWPOLYLINE / POLYLINE2D / POLYLINE3D → Kontur. Z-Höhe: erster gültiger
|
||||
* Vertex-Z, sonst `elevation`, sonst 0. `closed` aus Flag 1. < 2 Punkte → null.
|
||||
* Punkte werden mit der aktiven Transform abgebildet.
|
||||
*/
|
||||
function polylineContour(e: DwgEntityLike): Contour | null {
|
||||
function polylineContour(e: DwgEntityLike, xform: Transform2D): Contour | null {
|
||||
const vs = e.vertices ?? [];
|
||||
const pts: Vec2[] = [];
|
||||
let zFromVertex: number | undefined;
|
||||
for (const v of vs) {
|
||||
if (!Number.isFinite(v.x) || !Number.isFinite(v.y)) continue;
|
||||
pts.push({ x: v.x, y: v.y });
|
||||
pts.push(applyXY(xform, v.x, v.y));
|
||||
if (zFromVertex === undefined && Number.isFinite(v.z)) zFromVertex = v.z;
|
||||
}
|
||||
if (pts.length < 2) return null;
|
||||
const z =
|
||||
const z0 =
|
||||
zFromVertex ??
|
||||
(Number.isFinite(e.elevation) ? (e.elevation as number) : 0);
|
||||
const closed = ((e.flag ?? 0) & CLOSED_FLAG) !== 0;
|
||||
return { z, pts, closed, layer: e.layer };
|
||||
return { z: applyZ(xform, z0), pts, closed, layer: e.layer };
|
||||
}
|
||||
|
||||
/** LINE → zweipunktige (offene) Kontur. Z aus dem Startpunkt (sonst 0). */
|
||||
function lineContour(e: DwgEntityLike): Contour | null {
|
||||
function lineContour(e: DwgEntityLike, xform: Transform2D): Contour | null {
|
||||
const a = e.startPoint;
|
||||
const b = e.endPoint;
|
||||
if (
|
||||
@@ -340,14 +581,211 @@ function lineContour(e: DwgEntityLike): Contour | null {
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const z = Number.isFinite(a.z) ? (a.z as number) : 0;
|
||||
const z0 = Number.isFinite(a.z) ? (a.z as number) : 0;
|
||||
return {
|
||||
z,
|
||||
pts: [
|
||||
{ x: a.x, y: a.y },
|
||||
{ x: b.x, y: b.y },
|
||||
],
|
||||
z: applyZ(xform, z0),
|
||||
pts: [applyXY(xform, a.x, a.y), applyXY(xform, b.x, b.y)],
|
||||
closed: false,
|
||||
layer: e.layer,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* ARC → offene Polylinien-Kontur. Wir tessellieren von startAngle nach endAngle
|
||||
* (CCW, normalisiert) in ~ARC_SEG_RAD-Schritten (min. ARC_MIN_SEGMENTS). Winkel
|
||||
* sind im Bogenmaß (libredwg liefert ARC-Winkel in Radiant).
|
||||
*/
|
||||
function arcContour(e: DwgEntityLike, xform: Transform2D): Contour | null {
|
||||
const c = e.center;
|
||||
const r = e.radius;
|
||||
if (
|
||||
!c ||
|
||||
!Number.isFinite(c.x) ||
|
||||
!Number.isFinite(c.y) ||
|
||||
!Number.isFinite(r) ||
|
||||
(r as number) <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
let a0 = Number.isFinite(e.startAngle) ? (e.startAngle as number) : 0;
|
||||
let a1 = Number.isFinite(e.endAngle) ? (e.endAngle as number) : 2 * Math.PI;
|
||||
// Sweep CCW normalisieren (a1 stets ≥ a0).
|
||||
while (a1 < a0) a1 += 2 * Math.PI;
|
||||
const sweep = a1 - a0;
|
||||
const segments = Math.max(
|
||||
ARC_MIN_SEGMENTS,
|
||||
Math.ceil(sweep / ARC_SEG_RAD),
|
||||
);
|
||||
const pts: Vec2[] = [];
|
||||
const radius = r as number;
|
||||
for (let i = 0; i <= segments; i++) {
|
||||
const ang = a0 + (sweep * i) / segments;
|
||||
const x = c.x + radius * Math.cos(ang);
|
||||
const y = c.y + radius * Math.sin(ang);
|
||||
pts.push(applyXY(xform, x, y));
|
||||
}
|
||||
const z0 = Number.isFinite(c.z) ? (c.z as number) : 0;
|
||||
return { z: applyZ(xform, z0), pts, closed: false, layer: e.layer };
|
||||
}
|
||||
|
||||
/** CIRCLE → geschlossene Polylinien-Kontur (CIRCLE_SEGMENTS Segmente). */
|
||||
function circleContour(e: DwgEntityLike, xform: Transform2D): Contour | null {
|
||||
const c = e.center;
|
||||
const r = e.radius;
|
||||
if (
|
||||
!c ||
|
||||
!Number.isFinite(c.x) ||
|
||||
!Number.isFinite(c.y) ||
|
||||
!Number.isFinite(r) ||
|
||||
(r as number) <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const radius = r as number;
|
||||
const pts: Vec2[] = [];
|
||||
for (let i = 0; i < CIRCLE_SEGMENTS; i++) {
|
||||
const ang = (2 * Math.PI * i) / CIRCLE_SEGMENTS;
|
||||
const x = c.x + radius * Math.cos(ang);
|
||||
const y = c.y + radius * Math.sin(ang);
|
||||
pts.push(applyXY(xform, x, y));
|
||||
}
|
||||
const z0 = Number.isFinite(c.z) ? (c.z as number) : 0;
|
||||
return { z: applyZ(xform, z0), pts, closed: true, layer: e.layer };
|
||||
}
|
||||
|
||||
/**
|
||||
* ELLIPSE → Polylinien-Kontur. `majorAxisEndPoint` ist der Endpunkt der Haupt-
|
||||
* achse RELATIV zum Zentrum (WCS); `axisRatio` = Nebenachse/Hauptachse. start/
|
||||
* endAngle sind Parameter (0 … 2π bei Vollellipse). Geschlossen, wenn der Sweep
|
||||
* (nahezu) eine volle Umdrehung ist.
|
||||
*/
|
||||
function ellipseContour(e: DwgEntityLike, xform: Transform2D): Contour | null {
|
||||
const c = e.center;
|
||||
const maj = e.majorAxisEndPoint;
|
||||
if (
|
||||
!c ||
|
||||
!maj ||
|
||||
!Number.isFinite(c.x) ||
|
||||
!Number.isFinite(c.y) ||
|
||||
!Number.isFinite(maj.x) ||
|
||||
!Number.isFinite(maj.y)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const ratio = Number.isFinite(e.axisRatio) ? (e.axisRatio as number) : 1;
|
||||
// Hauptachsen-Vektor (relativ) + dazu senkrechter Nebenachsen-Vektor.
|
||||
const ux = maj.x;
|
||||
const uy = maj.y;
|
||||
const vx = -uy * ratio;
|
||||
const vy = ux * ratio;
|
||||
let p0 = Number.isFinite(e.startAngle) ? (e.startAngle as number) : 0;
|
||||
let p1 = Number.isFinite(e.endAngle) ? (e.endAngle as number) : 2 * Math.PI;
|
||||
while (p1 < p0) p1 += 2 * Math.PI;
|
||||
const sweep = p1 - p0;
|
||||
const full = sweep >= 2 * Math.PI - 1e-6;
|
||||
const segments = Math.max(
|
||||
ARC_MIN_SEGMENTS,
|
||||
Math.ceil((ELLIPSE_SEGMENTS * sweep) / (2 * Math.PI)),
|
||||
);
|
||||
const pts: Vec2[] = [];
|
||||
// Bei Vollellipse Endpunkt = Startpunkt → letztes Segment auslassen (closed).
|
||||
const count = full ? segments : segments + 1;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const param = p0 + (sweep * i) / segments;
|
||||
const ct = Math.cos(param);
|
||||
const st = Math.sin(param);
|
||||
const x = c.x + ux * ct + vx * st;
|
||||
const y = c.y + uy * ct + vy * st;
|
||||
pts.push(applyXY(xform, x, y));
|
||||
}
|
||||
if (pts.length < 2) return null;
|
||||
const z0 = Number.isFinite(c.z) ? (c.z as number) : 0;
|
||||
return { z: applyZ(xform, z0), pts, closed: full, layer: e.layer };
|
||||
}
|
||||
|
||||
/**
|
||||
* SPLINE → Polylinien-Kontur. Bevorzugt `fitPoints` (durch die der Spline läuft);
|
||||
* fehlen sie, tessellieren wir eine offene uniforme B-Spline über die
|
||||
* `controlPoints` (De-Boor mit Standard-Clamped-Knoten, Grad aus `degree`).
|
||||
* `closed` aus Flag 1 (geschlossener Spline). Z aus dem ersten Punkt.
|
||||
*/
|
||||
function splineContour(e: DwgEntityLike, xform: Transform2D): Contour | null {
|
||||
const closed = ((e.flag ?? 0) & CLOSED_FLAG) !== 0;
|
||||
const fit = (e.fitPoints ?? []).filter(
|
||||
(p) => !!p && Number.isFinite(p.x) && Number.isFinite(p.y),
|
||||
);
|
||||
let raw: Vec2[];
|
||||
let z0 = 0;
|
||||
if (fit.length >= 2) {
|
||||
raw = fit.map((p) => ({ x: p.x, y: p.y }));
|
||||
z0 = Number.isFinite(fit[0].z) ? (fit[0].z as number) : 0;
|
||||
} else {
|
||||
const ctrl = (e.controlPoints ?? []).filter(
|
||||
(p) => !!p && Number.isFinite(p.x) && Number.isFinite(p.y),
|
||||
);
|
||||
if (ctrl.length < 2) return null;
|
||||
z0 = Number.isFinite(ctrl[0].z) ? (ctrl[0].z as number) : 0;
|
||||
const degree = Number.isFinite(e.degree)
|
||||
? Math.max(1, Math.min(ctrl.length - 1, e.degree as number))
|
||||
: Math.min(3, ctrl.length - 1);
|
||||
raw = tessellateBSpline(
|
||||
ctrl.map((p) => ({ x: p.x, y: p.y })),
|
||||
degree,
|
||||
SPLINE_SEGMENTS,
|
||||
);
|
||||
}
|
||||
if (raw.length < 2) return null;
|
||||
const pts = raw.map((p) => applyXY(xform, p.x, p.y));
|
||||
return { z: applyZ(xform, z0), pts, closed, layer: e.layer };
|
||||
}
|
||||
|
||||
/**
|
||||
* Uniforme Clamped-B-Spline über `ctrl` (Grad `degree`) in `samples` gleichmäßige
|
||||
* Auswertungspunkte. Standard-Knotenvektor (clamped: Endknoten degree+1-fach).
|
||||
* Bewusst einfach; ausreichend für Kontext-/Snap-Geometrie. Liefert bei < degree+1
|
||||
* Kontrollpunkten einfach den Kontrollpolygonzug zurück.
|
||||
*/
|
||||
function tessellateBSpline(ctrl: Vec2[], degree: number, samples: number): Vec2[] {
|
||||
const n = ctrl.length - 1;
|
||||
if (n < degree) return ctrl.slice();
|
||||
// Clamped uniform knots: 0…0 (degree+1×), 1,2,…, m-degree-1, dann max (degree+1×).
|
||||
const knotCount = n + degree + 2;
|
||||
const knots: number[] = [];
|
||||
const interior = knotCount - 2 * (degree + 1);
|
||||
for (let i = 0; i < degree + 1; i++) knots.push(0);
|
||||
for (let i = 1; i <= interior; i++) knots.push(i / (interior + 1));
|
||||
for (let i = 0; i < degree + 1; i++) knots.push(1);
|
||||
const out: Vec2[] = [];
|
||||
for (let s = 0; s <= samples; s++) {
|
||||
// u im halboffenen [0,1); letztes Sample exakt 1.
|
||||
const u = s === samples ? 1 - 1e-9 : s / samples;
|
||||
out.push(deBoor(ctrl, knots, degree, u));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** De-Boor-Auswertung einer B-Spline an Parameter u (0…1). */
|
||||
function deBoor(ctrl: Vec2[], knots: number[], degree: number, u: number): Vec2 {
|
||||
// Knoten-Span k finden mit knots[k] <= u < knots[k+1].
|
||||
const n = ctrl.length - 1;
|
||||
let k = degree;
|
||||
while (k < n && knots[k + 1] <= u) k++;
|
||||
// Lokale Kontrollpunkte kopieren.
|
||||
const d: Vec2[] = [];
|
||||
for (let j = 0; j <= degree; j++) {
|
||||
const cp = ctrl[k - degree + j] ?? ctrl[ctrl.length - 1];
|
||||
d.push({ x: cp.x, y: cp.y });
|
||||
}
|
||||
for (let r = 1; r <= degree; r++) {
|
||||
for (let j = degree; j >= r; j--) {
|
||||
const i = k - degree + j;
|
||||
const denom = knots[i + degree - r + 1] - knots[i];
|
||||
const alpha = denom === 0 ? 0 : (u - knots[i]) / denom;
|
||||
d[j] = {
|
||||
x: (1 - alpha) * d[j - 1].x + alpha * d[j].x,
|
||||
y: (1 - alpha) * d[j - 1].y + alpha * d[j].y,
|
||||
};
|
||||
}
|
||||
}
|
||||
return d[degree];
|
||||
}
|
||||
|
||||
@@ -21,10 +21,25 @@
|
||||
import DxfParser from "dxf-parser";
|
||||
import type { Contour, ContourSet, ImportedMesh, Vec2 } from "../model/types";
|
||||
|
||||
/**
|
||||
* Optionale Import-Diagnose: Histogramm der gelesenen Entity-Typen (Typ → Anzahl)
|
||||
* und die Gesamtzahl. Wird vor allem vom DWG-Parser gefüllt, damit beim Fall
|
||||
* „keine Geometrie gefunden" sichtbar wird, WORAN es liegt (z. B. „0 Entities" =
|
||||
* Encoding-/Versions-Problem vs. „500 ARC, 200 INSERT" = Abdeckungslücke).
|
||||
*/
|
||||
export interface ImportDiagnostics {
|
||||
/** Anzahl je (Modellraum-)Entity-Typ, inkl. expandierter Block-Entities. */
|
||||
entityCounts: Record<string, number>;
|
||||
/** Gesamtzahl betrachteter Entities. */
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** Ergebnis eines DXF-Imports: rohe Meshes + Konturen-Sätze. */
|
||||
export interface DxfImportResult {
|
||||
meshes: ImportedMesh[];
|
||||
contours: ContourSet[];
|
||||
/** Optionale Diagnose (Entity-Typ-Histogramm); vom DWG-Parser gefüllt. */
|
||||
diagnostics?: ImportDiagnostics;
|
||||
}
|
||||
|
||||
// Lose getippte Sicht auf die dxf-parser-Ausgabe (das Paket liefert keine engen
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
// `layerToCode` liefert (deterministisch) einen Code je
|
||||
// Layer-Name; unbekannte/leere Layer fallen auf `fallbackCode`.
|
||||
//
|
||||
// Bezeichner englisch, Kommentare deutsch (CLAUDE.md).
|
||||
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||||
|
||||
import type { Contour, ContourSet, Drawing2D, Drawing2DGeom } from "../model/types";
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
// Gemeinsame Typen + Helfer für den Standort-Import (swisstopo & OSM).
|
||||
//
|
||||
// Beide Quellen liefern am Ende dasselbe: geschlossene/offene Polylinien in
|
||||
// LOKALEN Modell-Metern (bereits um die Origin verschoben, siehe lv95.ts),
|
||||
// kategorisiert nach Art (Gebäude/Strasse/Wasser/Grün). Die UI wandelt diese in
|
||||
// `ContourSet`-Kontext-Objekte pro Kategorie um und legt sie in `project.context`.
|
||||
//
|
||||
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||||
|
||||
import type { GeoOrigin } from "./lv95";
|
||||
import type { ContextObject, Contour } from "../model/types";
|
||||
|
||||
/** Fachliche Kategorie einer importierten Kontext-Linie. */
|
||||
export type GeoCategory = "building" | "road" | "water" | "green";
|
||||
|
||||
/** Ein importiertes Polygon/Polylinie in lokalen Modell-Metern. */
|
||||
export interface GeoFeature {
|
||||
category: GeoCategory;
|
||||
/** Stützpunkte in lokalen Metern (x=Ost, y=Nord). */
|
||||
pts: { x: number; y: number }[];
|
||||
/** Geschlossener Ring (Gebäude/Wasser/Grün) vs. offene Linie (Strasse). */
|
||||
closed: boolean;
|
||||
}
|
||||
|
||||
/** Ergebnis eines Import-Laufs: Features + verwendete Origin. */
|
||||
export interface GeoImportResult {
|
||||
origin: GeoOrigin;
|
||||
features: GeoFeature[];
|
||||
}
|
||||
|
||||
/** Menschlicher Layer-/Objektname je Kategorie (deutsch, für die Kontext-Liste). */
|
||||
const CATEGORY_LABEL: Record<GeoCategory, string> = {
|
||||
building: "Gebäude",
|
||||
road: "Strassen",
|
||||
water: "Gewässer",
|
||||
green: "Grünflächen",
|
||||
};
|
||||
|
||||
/** Layer-Kennung (englischer Identifier) je Kategorie — für spätere Zuordnung. */
|
||||
const CATEGORY_LAYER: Record<GeoCategory, string> = {
|
||||
building: "context-buildings",
|
||||
road: "context-roads",
|
||||
water: "context-water",
|
||||
green: "context-green",
|
||||
};
|
||||
|
||||
/** Erzeugt eine (kollisionsarme) ID für ein neues Kontext-Objekt. */
|
||||
function contextId(prefix: string): string {
|
||||
return `${prefix}-${Date.now().toString(36)}-${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 8)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wandelt importierte Features in `ContextObject`s (je Kategorie EIN
|
||||
* ContourSet). Die Konturen liegen auf z=0 (Grundriss-Kontext); geschlossene
|
||||
* Ringe (Gebäude/Wasser/Grün) werden als geschlossene Konturen markiert,
|
||||
* Strassen als offene Linienzüge. Der Aufrufer legt das Ergebnis über
|
||||
* `onAddContextObjects` in `project.context` ab.
|
||||
*/
|
||||
export function featuresToContextObjects(
|
||||
features: GeoFeature[],
|
||||
sourceLabel: string,
|
||||
): ContextObject[] {
|
||||
const byCat = new Map<GeoCategory, Contour[]>();
|
||||
for (const f of features) {
|
||||
if (f.pts.length < 2) continue;
|
||||
const list = byCat.get(f.category) ?? [];
|
||||
list.push({
|
||||
z: 0,
|
||||
pts: f.pts.map((p) => ({ x: p.x, y: p.y })),
|
||||
closed: f.closed,
|
||||
layer: CATEGORY_LAYER[f.category],
|
||||
});
|
||||
byCat.set(f.category, list);
|
||||
}
|
||||
|
||||
const objs: ContextObject[] = [];
|
||||
for (const [cat, contours] of byCat) {
|
||||
if (contours.length === 0) continue;
|
||||
objs.push({
|
||||
id: contextId(`geo-${cat}`),
|
||||
type: "contourSet",
|
||||
name: `${CATEGORY_LABEL[cat]} (${sourceLabel})`,
|
||||
layer: CATEGORY_LAYER[cat],
|
||||
contours,
|
||||
});
|
||||
}
|
||||
return objs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Basis-URL des optionalen Geo-Proxys (openbureau-core `/geoproxy`). Wird für
|
||||
* Overpass zwingend benutzt (CORS unzuverlässig); für geo.admin nur als
|
||||
* Fallback. Konfiguration über `VITE_GEO_PROXY` (z. B. "/geoproxy" bei
|
||||
* gleichem Origin oder eine absolute URL auf den API-Server). Leer = kein Proxy.
|
||||
*/
|
||||
export function geoProxyBase(): string {
|
||||
const env = (import.meta as unknown as { env?: Record<string, string> }).env;
|
||||
return (env?.VITE_GEO_PROXY ?? "").replace(/\/$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Verpackt eine absolute Upstream-URL für den Geo-Proxy. Ohne konfigurierten
|
||||
* Proxy wird die URL unverändert zurückgegeben (Direktabruf).
|
||||
*/
|
||||
export function viaProxy(url: string): string {
|
||||
const base = geoProxyBase();
|
||||
return base ? `${base}?u=${encodeURIComponent(url)}` : url;
|
||||
}
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
// LV95 ↔ WGS84 Koordinaten-Transformation + Ursprungs-Verschiebung.
|
||||
//
|
||||
// Schweizer Landeskoordinaten (LV95 / EPSG:2056) liegen im Millionenbereich
|
||||
// (Ost ~2'600'000 m, Nord ~1'200'000 m). Für die numerische Genauigkeit im
|
||||
// CAD (float32 in three.js!) verschieben wir importierte Geometrie in die Nähe
|
||||
// des Modell-Ursprungs (0,0). Der einmal berechnete Offset wird mitgeführt, so
|
||||
// dass ALLE Objekte eines Imports dieselbe Verschiebung teilen und korrekt
|
||||
// zueinander liegen.
|
||||
//
|
||||
// Die Umrechnung LV95↔WGS84 verwendet die von swisstopo publizierten
|
||||
// Näherungsformeln („Bezugsrahmenwechsel und Kartenprojektionen", genauigkeit
|
||||
// im Meter-Bereich, für Kontext-Geometrie völlig ausreichend). Referenz-
|
||||
// Rundlauf (siehe Unit-Check im Report) < 1 m.
|
||||
//
|
||||
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). Einheiten: Meter
|
||||
// bzw. Dezimalgrad.
|
||||
|
||||
/** Geographische Koordinate (WGS84), Dezimalgrad. */
|
||||
export interface LatLon {
|
||||
lat: number;
|
||||
lon: number;
|
||||
}
|
||||
|
||||
/** Schweizer Landeskoordinate LV95 (EPSG:2056), Meter. */
|
||||
export interface LV95 {
|
||||
/** Ostwert (E), ~2'600'000. */
|
||||
e: number;
|
||||
/** Nordwert (N), ~1'200'000. */
|
||||
n: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* WGS84 → LV95 (approximativ, swisstopo-Näherungsformeln).
|
||||
* Eingabe Dezimalgrad, Ausgabe Meter (E/N).
|
||||
*/
|
||||
export function wgs84ToLv95(lat: number, lon: number): LV95 {
|
||||
// Breite/Länge in Sexagesimalsekunden.
|
||||
const phi = lat * 3600;
|
||||
const lam = lon * 3600;
|
||||
// Hilfsgrößen relativ zum Bezugspunkt Bern (46°57'08.66" N, 7°26'22.50" E).
|
||||
const p = (phi - 169028.66) / 10000;
|
||||
const l = (lam - 26782.5) / 10000;
|
||||
const p2 = p * p;
|
||||
const l2 = l * l;
|
||||
|
||||
// Ostwert (Y in LV03 + 2'000'000 für LV95).
|
||||
const y =
|
||||
2600072.37 +
|
||||
211455.93 * l -
|
||||
10938.51 * l * p -
|
||||
0.36 * l * p2 -
|
||||
44.54 * l * l2;
|
||||
|
||||
// Nordwert (X in LV03 + 1'000'000 für LV95).
|
||||
const x =
|
||||
1200147.07 +
|
||||
308807.95 * p +
|
||||
3745.25 * l2 +
|
||||
76.63 * p2 -
|
||||
194.56 * l2 * p +
|
||||
119.79 * p * p2;
|
||||
|
||||
return { e: y, n: x };
|
||||
}
|
||||
|
||||
/**
|
||||
* LV95 → WGS84 (approximativ, swisstopo-Näherungsformeln).
|
||||
* Eingabe Meter (E/N), Ausgabe Dezimalgrad.
|
||||
*/
|
||||
export function lv95ToWgs84(e: number, n: number): LatLon {
|
||||
// Relativ zum Projektions-Ursprung, in 1'000'000-Einheiten.
|
||||
const y = (e - 2600000) / 1000000;
|
||||
const x = (n - 1200000) / 1000000;
|
||||
const y2 = y * y;
|
||||
const x2 = x * x;
|
||||
|
||||
// Breite/Länge in 10'000"-Einheiten (Näherungspolynome).
|
||||
const lam =
|
||||
2.6779094 +
|
||||
4.728982 * y +
|
||||
0.791484 * y * x +
|
||||
0.1306 * y * x2 -
|
||||
0.0436 * y * y2;
|
||||
|
||||
const phi =
|
||||
16.9023892 +
|
||||
3.238272 * x -
|
||||
0.270978 * y2 -
|
||||
0.002528 * x2 -
|
||||
0.0447 * y2 * x -
|
||||
0.014 * x * x2;
|
||||
|
||||
// Zurück in Dezimalgrad (Faktor 100/36).
|
||||
return {
|
||||
lat: (phi * 100) / 36,
|
||||
lon: (lam * 100) / 36,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Ursprungs-Verschiebung: der LV95-Punkt, der im Modell auf (0,0)
|
||||
* abgebildet wird. Alle importierten Objekte eines Imports teilen dieselbe
|
||||
* Origin, damit sie zueinander lagerichtig bleiben.
|
||||
*/
|
||||
export interface GeoOrigin {
|
||||
e: number;
|
||||
n: number;
|
||||
}
|
||||
|
||||
/** Erzeugt eine Origin aus einem LV95-Zentrum. */
|
||||
export function makeOrigin(center: LV95): GeoOrigin {
|
||||
return { e: center.e, n: center.n };
|
||||
}
|
||||
|
||||
/**
|
||||
* LV95 → lokale Modell-Meter (Ost→X, Nord→Y) relativ zur Origin. LV95 ist bereits
|
||||
* metrisch und (nahezu) nordorientiert, daher genügt eine reine Translation.
|
||||
*/
|
||||
export function lv95ToLocal(pt: LV95, origin: GeoOrigin): { x: number; y: number } {
|
||||
return { x: pt.e - origin.e, y: pt.n - origin.n };
|
||||
}
|
||||
|
||||
/** Lokale Modell-Meter → LV95 (Umkehrung von {@link lv95ToLocal}). */
|
||||
export function localToLv95(
|
||||
x: number,
|
||||
y: number,
|
||||
origin: GeoOrigin,
|
||||
): LV95 {
|
||||
return { e: x + origin.e, n: y + origin.n };
|
||||
}
|
||||
|
||||
/**
|
||||
* WGS84 → lokale Modell-Meter (über LV95 und die Origin). Praktisch für
|
||||
* OSM-Punkte (lon/lat), die zuerst nach LV95 projiziert und dann verschoben
|
||||
* werden — so teilen OSM- und swisstopo-Import exakt dasselbe Bezugssystem.
|
||||
*/
|
||||
export function wgs84ToLocal(
|
||||
lat: number,
|
||||
lon: number,
|
||||
origin: GeoOrigin,
|
||||
): { x: number; y: number } {
|
||||
return lv95ToLocal(wgs84ToLv95(lat, lon), origin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut eine LV95-Bounding-Box (quadratisch) um ein Zentrum mit gegebenem Radius
|
||||
* (Meter). Reihenfolge [eMin, nMin, eMax, nMax].
|
||||
*/
|
||||
export function bboxAround(
|
||||
center: LV95,
|
||||
radius: number,
|
||||
): [number, number, number, number] {
|
||||
return [
|
||||
center.e - radius,
|
||||
center.n - radius,
|
||||
center.e + radius,
|
||||
center.n + radius,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Wandelt eine LV95-Bounding-Box in eine WGS84-Box (für Overpass:
|
||||
* [south, west, north, east]). Nutzt die vier Ecken und nimmt die Extremwerte,
|
||||
* damit die leichte Nord-Rotation der LV95-Projektion abgedeckt ist.
|
||||
*/
|
||||
export function lv95BboxToWgs84(
|
||||
bbox: [number, number, number, number],
|
||||
): { south: number; west: number; north: number; east: number } {
|
||||
const [eMin, nMin, eMax, nMax] = bbox;
|
||||
const corners: LatLon[] = [
|
||||
lv95ToWgs84(eMin, nMin),
|
||||
lv95ToWgs84(eMax, nMin),
|
||||
lv95ToWgs84(eMin, nMax),
|
||||
lv95ToWgs84(eMax, nMax),
|
||||
];
|
||||
return {
|
||||
south: Math.min(...corners.map((c) => c.lat)),
|
||||
west: Math.min(...corners.map((c) => c.lon)),
|
||||
north: Math.max(...corners.map((c) => c.lat)),
|
||||
east: Math.max(...corners.map((c) => c.lon)),
|
||||
};
|
||||
}
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
// OpenStreetMap-Anbindung (Overpass API) für den Standort-Import.
|
||||
//
|
||||
// Holt für eine LV95-Box Gebäude / Strassen / Wasser / Grünflächen und wandelt
|
||||
// sie in Polylinien in LOKALEN Modell-Metern (über LV95 + Origin, siehe
|
||||
// lv95.ts). Overpass sendet je nach Mirror unzuverlässige CORS-Header, daher
|
||||
// läuft der Abruf über den openbureau-core Geo-Proxy (`viaProxy`), sofern
|
||||
// `VITE_GEO_PROXY` gesetzt ist — sonst Direktabruf gegen den CORS-fähigen
|
||||
// Mirror.
|
||||
//
|
||||
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). Einheiten: Meter.
|
||||
|
||||
import { lv95BboxToWgs84, makeOrigin, wgs84ToLocal } from "./lv95";
|
||||
import type { GeoOrigin, LV95 } from "./lv95";
|
||||
import { bboxAround } from "./lv95";
|
||||
import type { GeoCategory, GeoFeature } from "./geoContext";
|
||||
import { viaProxy } from "./geoContext";
|
||||
|
||||
// Direkter Mirror mit CORS `*` (Fallback, falls kein Proxy konfiguriert ist).
|
||||
const OVERPASS = "https://maps.mail.ru/osm/tools/overpass/api/interpreter";
|
||||
|
||||
/** Welche OSM-Kategorien der Aufrufer möchte. */
|
||||
export interface OsmSelection {
|
||||
buildings: boolean;
|
||||
roads: boolean;
|
||||
water: boolean;
|
||||
green: boolean;
|
||||
}
|
||||
|
||||
/** Ein Overpass-Element mit inline-Geometrie (`out geom`). */
|
||||
interface OverpassElement {
|
||||
type: "node" | "way" | "relation";
|
||||
id: number;
|
||||
tags?: Record<string, string>;
|
||||
geometry?: { lat: number; lon: number }[];
|
||||
members?: {
|
||||
type: string;
|
||||
role: string;
|
||||
geometry?: { lat: number; lon: number }[];
|
||||
}[];
|
||||
}
|
||||
|
||||
/** Baut die Overpass-QL-Abfrage aus der WGS84-Box + Auswahl. */
|
||||
function buildQuery(
|
||||
bbox: { south: number; west: number; north: number; east: number },
|
||||
sel: OsmSelection,
|
||||
): string {
|
||||
const b = `${bbox.south},${bbox.west},${bbox.north},${bbox.east}`;
|
||||
const parts: string[] = [];
|
||||
if (sel.buildings) parts.push(`way["building"](${b});`);
|
||||
if (sel.roads) parts.push(`way["highway"](${b});`);
|
||||
if (sel.water) {
|
||||
parts.push(`way["natural"="water"](${b});`);
|
||||
parts.push(`way["waterway"](${b});`);
|
||||
parts.push(`relation["natural"="water"](${b});`);
|
||||
}
|
||||
if (sel.green) {
|
||||
parts.push(`way["leisure"="park"](${b});`);
|
||||
parts.push(`way["landuse"~"grass|forest|meadow|recreation_ground"](${b});`);
|
||||
parts.push(`way["natural"="wood"](${b});`);
|
||||
}
|
||||
return `[out:json][timeout:40];(${parts.join("")});out geom;`;
|
||||
}
|
||||
|
||||
/** Ordnet ein Element anhand seiner Tags einer Kategorie zu (oder null). */
|
||||
function categorize(
|
||||
tags: Record<string, string> | undefined,
|
||||
sel: OsmSelection,
|
||||
): GeoCategory | null {
|
||||
if (!tags) return null;
|
||||
if (sel.buildings && tags.building) return "building";
|
||||
if (sel.water && (tags.natural === "water" || tags.waterway)) return "water";
|
||||
if (
|
||||
sel.green &&
|
||||
(tags.leisure === "park" ||
|
||||
tags.natural === "wood" ||
|
||||
/grass|forest|meadow|recreation_ground/.test(tags.landuse ?? ""))
|
||||
)
|
||||
return "green";
|
||||
if (sel.roads && tags.highway) return "road";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Geschlossene Kategorien (Flächen) vs. offene (Strassen). */
|
||||
function isClosedCategory(cat: GeoCategory): boolean {
|
||||
return cat !== "road";
|
||||
}
|
||||
|
||||
/**
|
||||
* OSM-Features für Zentrum + Radius (Meter) laden. `originIn` erlaubt es, exakt
|
||||
* dieselbe Origin wie ein vorheriger swisstopo-Import zu verwenden.
|
||||
*/
|
||||
export async function fetchOsm(
|
||||
center: LV95,
|
||||
radius: number,
|
||||
sel: OsmSelection,
|
||||
originIn?: GeoOrigin,
|
||||
): Promise<{ origin: GeoOrigin; features: GeoFeature[] }> {
|
||||
const origin = originIn ?? makeOrigin(center);
|
||||
if (!sel.buildings && !sel.roads && !sel.water && !sel.green) {
|
||||
return { origin, features: [] };
|
||||
}
|
||||
|
||||
const bbox = lv95BboxToWgs84(bboxAround(center, radius));
|
||||
const query = buildQuery(bbox, sel);
|
||||
|
||||
const res = await fetch(viaProxy(OVERPASS), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: "data=" + encodeURIComponent(query),
|
||||
});
|
||||
if (!res.ok) throw new Error(`OSM-Abruf fehlgeschlagen (${res.status})`);
|
||||
const data = (await res.json()) as { elements?: OverpassElement[] };
|
||||
|
||||
const features: GeoFeature[] = [];
|
||||
const push = (
|
||||
geometry: { lat: number; lon: number }[] | undefined,
|
||||
cat: GeoCategory,
|
||||
) => {
|
||||
if (!geometry || geometry.length < 2) return;
|
||||
const pts = geometry.map((g) => wgs84ToLocal(g.lat, g.lon, origin));
|
||||
const closed = isClosedCategory(cat);
|
||||
if (closed && pts.length < 3) return;
|
||||
features.push({ category: cat, pts, closed });
|
||||
};
|
||||
|
||||
for (const el of data.elements ?? []) {
|
||||
const cat = categorize(el.tags, sel);
|
||||
if (!cat) continue;
|
||||
if (el.type === "way") {
|
||||
push(el.geometry, cat);
|
||||
} else if (el.type === "relation") {
|
||||
// Multipolygon: outer-Ringe als einzelne geschlossene Linien übernehmen.
|
||||
for (const m of el.members ?? []) {
|
||||
if (m.role === "outer" || m.role === "") push(m.geometry, cat);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { origin, features };
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
// swisstopo / geo.admin-Anbindung für den Standort-Import.
|
||||
//
|
||||
// • geocode(query) — Ortssuche/Adresse → Kandidaten mit LV95-Koordinaten.
|
||||
// Nutzt api3.geo.admin.ch SearchServer (sendet CORS `*`,
|
||||
// daher Direktabruf; Proxy nur als Fallback).
|
||||
// • fetchBuildings() — Gebäude-GRUNDRISSE für eine LV95-Box → Ringe in
|
||||
// lokalen Modell-Metern. Nutzt den geo.admin
|
||||
// MapServer/identify auf `ch.swisstopo.vec25-gebaeude`
|
||||
// (liefert Polygon-`rings` in LV95, aus dem Browser
|
||||
// abrufbar). swissBUILDINGS3D-Kachel-Downloads (STAC)
|
||||
// sind für einen Live-Box-Query im Browser zu schwer und
|
||||
// werden bewusst NICHT verwendet.
|
||||
//
|
||||
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md). Einheiten: Meter.
|
||||
|
||||
import { bboxAround, makeOrigin, wgs84ToLv95 } from "./lv95";
|
||||
import type { GeoOrigin, LV95 } from "./lv95";
|
||||
import type { GeoFeature } from "./geoContext";
|
||||
import { viaProxy } from "./geoContext";
|
||||
|
||||
const GEOADMIN = "https://api3.geo.admin.ch";
|
||||
|
||||
/** Ein Geocode-Treffer: Anzeigename + LV95-Position. */
|
||||
export interface GeocodeCandidate {
|
||||
label: string;
|
||||
lv95: LV95;
|
||||
}
|
||||
|
||||
/** Entfernt HTML-Auszeichnung aus den SearchServer-Labels. */
|
||||
function stripHtml(s: string): string {
|
||||
return s.replace(/<[^>]*>/g, "").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ortssuche (Adresse oder Ortsname) → bis zu `limit` Kandidaten mit
|
||||
* LV95-Koordinaten. Wirft bei Netzfehler.
|
||||
*/
|
||||
export async function geocode(
|
||||
query: string,
|
||||
limit = 8,
|
||||
): Promise<GeocodeCandidate[]> {
|
||||
const q = query.trim();
|
||||
if (!q) return [];
|
||||
const url =
|
||||
`${GEOADMIN}/rest/services/api/SearchServer` +
|
||||
`?searchText=${encodeURIComponent(q)}` +
|
||||
`&type=locations&sr=2056&limit=${limit}`;
|
||||
|
||||
const res = await fetch(viaProxy(url));
|
||||
if (!res.ok) throw new Error(`Geocode fehlgeschlagen (${res.status})`);
|
||||
const data = (await res.json()) as {
|
||||
results?: {
|
||||
attrs?: {
|
||||
label?: string;
|
||||
y?: number; // Ostwert (E) in sr=2056
|
||||
x?: number; // Nordwert (N) in sr=2056
|
||||
lat?: number;
|
||||
lon?: number;
|
||||
};
|
||||
}[];
|
||||
};
|
||||
|
||||
const out: GeocodeCandidate[] = [];
|
||||
for (const r of data.results ?? []) {
|
||||
const a = r.attrs;
|
||||
if (!a) continue;
|
||||
// Bei sr=2056 liefert der SearchServer y=E, x=N (LV95). Fallback: aus lat/lon.
|
||||
let e = a.y;
|
||||
let n = a.x;
|
||||
if (
|
||||
e == null ||
|
||||
n == null ||
|
||||
!Number.isFinite(e) ||
|
||||
!Number.isFinite(n) ||
|
||||
e < 2_000_000
|
||||
) {
|
||||
if (a.lat != null && a.lon != null) {
|
||||
const p = wgs84ToLv95(a.lat, a.lon);
|
||||
e = p.e;
|
||||
n = p.n;
|
||||
}
|
||||
}
|
||||
if (e == null || n == null) continue;
|
||||
out.push({ label: stripHtml(a.label ?? q), lv95: { e, n } });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Esri-Polygon-Geometrie, wie sie identify bei returnGeometry=true liefert. */
|
||||
interface EsriPolygon {
|
||||
rings?: number[][][]; // [ring][point][x,y] in LV95
|
||||
}
|
||||
|
||||
/**
|
||||
* Gebäude-Grundrisse für ein Zentrum + Radius (Meter) laden.
|
||||
* Rückgabe: Ringe in LOKALEN Metern relativ zur (aus dem Zentrum abgeleiteten
|
||||
* oder übergebenen) Origin — plus die verwendete Origin, damit weitere Quellen
|
||||
* dasselbe Bezugssystem teilen.
|
||||
*/
|
||||
export async function fetchBuildings(
|
||||
center: LV95,
|
||||
radius: number,
|
||||
originIn?: GeoOrigin,
|
||||
): Promise<{ origin: GeoOrigin; features: GeoFeature[] }> {
|
||||
const origin = originIn ?? makeOrigin(center);
|
||||
const [eMin, nMin, eMax, nMax] = bboxAround(center, radius);
|
||||
const geom = `${eMin},${nMin},${eMax},${nMax}`;
|
||||
|
||||
const url =
|
||||
`${GEOADMIN}/rest/services/api/MapServer/identify` +
|
||||
`?geometryType=esriGeometryEnvelope` +
|
||||
`&geometry=${geom}` +
|
||||
`&mapExtent=${geom}` +
|
||||
`&imageDisplay=500,500,96` +
|
||||
`&tolerance=0` +
|
||||
`&layers=all:ch.swisstopo.vec25-gebaeude` +
|
||||
`&returnGeometry=true&sr=2056&limit=200`;
|
||||
|
||||
const res = await fetch(viaProxy(url));
|
||||
if (!res.ok) throw new Error(`Gebäude-Abruf fehlgeschlagen (${res.status})`);
|
||||
const data = (await res.json()) as {
|
||||
results?: { geometry?: EsriPolygon }[];
|
||||
};
|
||||
|
||||
const features: GeoFeature[] = [];
|
||||
for (const r of data.results ?? []) {
|
||||
const rings = r.geometry?.rings;
|
||||
if (!rings) continue;
|
||||
for (const ring of rings) {
|
||||
if (ring.length < 3) continue;
|
||||
const pts = ring.map(([e, n]) => ({ x: e - origin.e, y: n - origin.n }));
|
||||
features.push({ category: "building", pts, closed: true });
|
||||
}
|
||||
}
|
||||
return { origin, features };
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
// ambientCG-API-Client — Live-Zugriff auf die KOMPLETTE CC0-Materialbibliothek
|
||||
// (ambientcg.com, ~2000+ Materialien). Statt alles zu bündeln (Gigabytes)
|
||||
// durchsucht die App die Bibliothek zur Laufzeit über die API, zeigt Thumbnails
|
||||
// und lädt die Textur-Karten eines Materials ERST bei Auswahl herunter,
|
||||
// entpackt das Zip (jszip) und erzeugt Blob-URLs je Karte — direkt kompatibel
|
||||
// mit `MaterialRuntime` (runtime.ts), das beliebige URLs/Blob-URLs lädt.
|
||||
//
|
||||
// CORS: Die Such-JSON (`ambientcg.com/api/v2/full_json`) und der Download-
|
||||
// Starter (`ambientcg.com/get`, ein 302-Redirect) senden KEINE CORS-Header und
|
||||
// sind daher aus dem Browser NICHT direkt abrufbar. Sie laufen deshalb über
|
||||
// einen Proxy (Dev: Vite-Proxy `/ambientcg` → siehe vite.config.ts; Prod: eine
|
||||
// eigene Proxy-Route, konfigurierbar über VITE_AMBIENTCG_PROXY). Die Thumbnails
|
||||
// (acg-media.struffelproductions.com) senden `Access-Control-Allow-Origin: *`
|
||||
// und werden daher DIREKT geladen (kein Proxy nötig).
|
||||
//
|
||||
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||||
|
||||
import JSZip from "jszip";
|
||||
import type { ComponentMaterial } from "../model/types";
|
||||
|
||||
/**
|
||||
* Proxy-Basis für die CORS-behafteten ambientCG-Endpunkte (`/api/...` und
|
||||
* `/get`). Default `/ambientcg` — im Dev vom Vite-Proxy auf `https://
|
||||
* ambientcg.com` gemappt. Für Prod via `VITE_AMBIENTCG_PROXY` überschreibbar
|
||||
* (z. B. eine eigene Proxy-Route). Ohne trailing slash.
|
||||
*/
|
||||
// `import.meta.env` wird von Vite injiziert; da im Projekt keine vite/client-
|
||||
// Typen eingebunden sind, greifen wir defensiv typisiert darauf zu.
|
||||
const ENV = (import.meta as unknown as { env?: Record<string, string | undefined> })
|
||||
.env;
|
||||
const PROXY_BASE: string =
|
||||
ENV?.VITE_AMBIENTCG_PROXY?.replace(/\/$/, "") ?? "/ambientcg";
|
||||
|
||||
/** Ein Suchtreffer der ambientCG-Bibliothek (für das Thumbnail-Grid). */
|
||||
export interface AmbientMaterial {
|
||||
/** ambientCG-Asset-ID, z. B. „Wood095". */
|
||||
id: string;
|
||||
/** Anzeigename (aus der API; sonst die ID). */
|
||||
name: string;
|
||||
/** Kategorie-Anzeigename, z. B. „Wood" (kann leer sein). */
|
||||
category: string;
|
||||
/** Thumbnail-URL (direkt ladbar, CORS `*`). */
|
||||
thumbUrl: string;
|
||||
}
|
||||
|
||||
/** Eine Seite Suchtreffer plus Gesamtzahl (für Pagination/„mehr laden"). */
|
||||
export interface AmbientSearchResult {
|
||||
items: AmbientMaterial[];
|
||||
/** Gesamtzahl gefundener Assets (server-seitig gezählt). */
|
||||
total: number;
|
||||
/** Verwendeter Offset dieser Seite. */
|
||||
offset: number;
|
||||
/** Angeforderte Seitengröße. */
|
||||
limit: number;
|
||||
}
|
||||
|
||||
/** Verfügbare Textur-Auflösungen (ambientCG-Attribut-Präfix). */
|
||||
export type AmbientResolution = "1K" | "2K" | "4K";
|
||||
|
||||
/** Optionen für {@link searchMaterials}. */
|
||||
export interface SearchOptions {
|
||||
/** Freitext-Suche (leer = alle, nach Popularität sortiert). */
|
||||
query?: string;
|
||||
/** Optionaler Kategorie-Filter (ambientCG-Kategorie-Schlüssel). */
|
||||
category?: string;
|
||||
/** Seitengröße (Default 24). */
|
||||
limit?: number;
|
||||
/** Offset für Pagination (Default 0). */
|
||||
offset?: number;
|
||||
/** Abbruch-Signal (z. B. bei neuer Suche). */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
// ── Interne API-Typen (nur die genutzten Felder) ───────────────────────────
|
||||
|
||||
interface RawPreviewImage {
|
||||
[size: string]: string | undefined;
|
||||
}
|
||||
|
||||
interface RawAsset {
|
||||
assetId: string;
|
||||
displayName?: string;
|
||||
customDisplayName?: string;
|
||||
displayCategory?: string;
|
||||
category?: string | null;
|
||||
previewImage?: RawPreviewImage;
|
||||
}
|
||||
|
||||
interface RawFullJson {
|
||||
numberOfResults?: number;
|
||||
foundAssets?: RawAsset[];
|
||||
}
|
||||
|
||||
/** Baut eine Proxy-URL für einen ambientCG-Pfad (mit führendem `/`). */
|
||||
function proxyUrl(path: string): string {
|
||||
return `${PROXY_BASE}${path.startsWith("/") ? path : `/${path}`}`;
|
||||
}
|
||||
|
||||
/** Wählt das größte verfügbare Thumbnail aus dem previewImage-Objekt. */
|
||||
function pickThumb(preview: RawPreviewImage | undefined): string {
|
||||
if (!preview) return "";
|
||||
// Bevorzugt größere PNG-Thumbnails (bessere Vorschau im Grid).
|
||||
const order = ["512-PNG", "256-PNG", "128-PNG", "64-PNG"];
|
||||
for (const key of order) {
|
||||
const url = preview[key];
|
||||
if (url) return url;
|
||||
}
|
||||
// Fallback: irgendein vorhandener Wert.
|
||||
for (const v of Object.values(preview)) if (v) return v;
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Durchsucht die ambientCG-Materialbibliothek. Liefert eine Seite Treffer mit
|
||||
* Thumbnails plus die Gesamtzahl (für „mehr laden"). Läuft über den Proxy
|
||||
* (CORS). Wirft bei Netz-/CORS-Fehlern — der Aufrufer zeigt einen Hinweis.
|
||||
*/
|
||||
export async function searchMaterials(
|
||||
opts: SearchOptions = {},
|
||||
): Promise<AmbientSearchResult> {
|
||||
const { query, category, limit = 24, offset = 0, signal } = opts;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set("type", "Material");
|
||||
// Nur die für Grid + IDs nötigen Daten anfordern (kleinere Antwort).
|
||||
params.set("include", "imageData");
|
||||
params.set("limit", String(limit));
|
||||
params.set("offset", String(offset));
|
||||
if (query && query.trim()) params.set("q", query.trim());
|
||||
if (category && category.trim()) params.set("category", category.trim());
|
||||
// Ohne Freitext nach Popularität sortieren (sinnvolle Default-Reihenfolge).
|
||||
if (!query || !query.trim()) params.set("sort", "Popular");
|
||||
|
||||
const url = proxyUrl(`/api/v2/full_json?${params.toString()}`);
|
||||
const res = await fetch(url, { signal });
|
||||
if (!res.ok) {
|
||||
throw new Error(`ambientCG-API HTTP ${res.status}`);
|
||||
}
|
||||
const json = (await res.json()) as RawFullJson;
|
||||
|
||||
const items: AmbientMaterial[] = (json.foundAssets ?? []).map((a) => ({
|
||||
id: a.assetId,
|
||||
name: a.customDisplayName || a.displayName || a.assetId,
|
||||
category: a.displayCategory || a.category || "",
|
||||
thumbUrl: pickThumb(a.previewImage),
|
||||
}));
|
||||
|
||||
return {
|
||||
items,
|
||||
total: json.numberOfResults ?? items.length,
|
||||
offset,
|
||||
limit,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Kategorie-Liste (statisch, aus den ambientCG-Material-Kategorien) ───────
|
||||
// Die häufigsten Material-Kategorien für den Filter. Die API kennt weitere; das
|
||||
// deckt die BIM-relevanten Oberflächen ab. Wert = ambientCG-Kategorie-Schlüssel.
|
||||
export const AMBIENT_CATEGORIES: string[] = [
|
||||
"Wood",
|
||||
"WoodFloor",
|
||||
"Concrete",
|
||||
"Bricks",
|
||||
"Plaster",
|
||||
"Tiles",
|
||||
"Marble",
|
||||
"Rock",
|
||||
"PavingStones",
|
||||
"Metal",
|
||||
"Ground",
|
||||
"Gravel",
|
||||
"Grass",
|
||||
"Fabric",
|
||||
"Leather",
|
||||
"Asphalt",
|
||||
"Terrazzo",
|
||||
"Wallpaper",
|
||||
"Roof",
|
||||
"OfficeCeiling",
|
||||
];
|
||||
|
||||
// ── Karten-Download + Entpacken ────────────────────────────────────────────
|
||||
|
||||
/** Zuordnung ambientCG-Dateinamen-Bestandteile → ComponentMaterial-Karten. */
|
||||
const MAP_MATCHERS: { kind: keyof ComponentMaterial; needles: string[] }[] = [
|
||||
{ kind: "color", needles: ["_color", "_col", "_diffuse", "_albedo"] },
|
||||
{ kind: "normal", needles: ["_normalgl", "_normal", "_nrm", "_nor"] },
|
||||
{ kind: "roughness", needles: ["_roughness", "_rough", "_rgh"] },
|
||||
{ kind: "metalness", needles: ["_metalness", "_metallic", "_metal"] },
|
||||
{ kind: "displacement", needles: ["_displacement", "_disp", "_height"] },
|
||||
{ kind: "ao", needles: ["_ambientocclusion", "_ao", "_occlusion"] },
|
||||
];
|
||||
|
||||
/** Ordnet einen Zip-Eintragsnamen einer Karten-Art zu (oder null). */
|
||||
function classifyMap(fileName: string): keyof ComponentMaterial | null {
|
||||
const lower = fileName.toLowerCase();
|
||||
if (!/\.(jpg|jpeg|png)$/.test(lower)) return null;
|
||||
// ambientCG liefert die Normal-Map in zwei Konventionen: DirectX (…NormalDX)
|
||||
// und OpenGL (…NormalGL). three.js erwartet OpenGL — die DX-Variante daher
|
||||
// NICHT als Normal-Karte übernehmen (sonst kippt die Tiefe je nach Zip-
|
||||
// Reihenfolge in die falsche Richtung).
|
||||
if (lower.includes("_normaldx")) return null;
|
||||
for (const m of MAP_MATCHERS) {
|
||||
if (m.needles.some((n) => lower.includes(n))) return m.kind;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Ergebnis von {@link fetchMaterialMaps}: Karten als Blob-URLs + Metadaten. */
|
||||
export interface FetchedMaterial {
|
||||
material: ComponentMaterial;
|
||||
/** Alle erzeugten Blob-URLs (zum Freigeben via `revokeMaterial`). */
|
||||
blobUrls: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt das 1K-JPG-Zip eines ambientCG-Materials (über den Proxy, da `/get`
|
||||
* keine CORS-Header sendet), entpackt es mit jszip und erzeugt je erkannter
|
||||
* Karte (color/normal/roughness/metalness/displacement/ao) eine Blob-URL. Das
|
||||
* Ergebnis ist ein `ComponentMaterial`, das `MaterialRuntime` direkt laden kann.
|
||||
*
|
||||
* `resolution` wählt das Auflösungs-Attribut (1K/2K/4K). 1K ist der sinnvolle
|
||||
* Default (schnell, für Echtzeit-3D ausreichend).
|
||||
*/
|
||||
export async function fetchMaterialMaps(
|
||||
id: string,
|
||||
resolution: AmbientResolution = "1K",
|
||||
sizeM = 1.0,
|
||||
signal?: AbortSignal,
|
||||
): Promise<FetchedMaterial> {
|
||||
const file = `${id}_${resolution}-JPG.zip`;
|
||||
const url = proxyUrl(`/get?file=${encodeURIComponent(file)}`);
|
||||
const res = await fetch(url, { signal });
|
||||
if (!res.ok) {
|
||||
throw new Error(`ambientCG-Download HTTP ${res.status}`);
|
||||
}
|
||||
const buf = await res.arrayBuffer();
|
||||
const zip = await JSZip.loadAsync(buf);
|
||||
|
||||
const material: ComponentMaterial = { libraryId: id, sizeM };
|
||||
const blobUrls: string[] = [];
|
||||
|
||||
// Alle Bild-Einträge durchgehen, je Karten-Art die erste Übereinstimmung
|
||||
// übernehmen (ambientCG liefert je Art genau eine Datei).
|
||||
const entries = Object.values(zip.files).filter((f) => !f.dir);
|
||||
for (const entry of entries) {
|
||||
const kind = classifyMap(entry.name);
|
||||
if (!kind || material[kind]) continue;
|
||||
const blob = await entry.async("blob");
|
||||
// Korrekten Bild-MIME setzen, damit der Browser die Blob-URL als Bild lädt.
|
||||
const ext = entry.name.toLowerCase().endsWith(".png")
|
||||
? "image/png"
|
||||
: "image/jpeg";
|
||||
const typed = blob.type ? blob : new Blob([blob], { type: ext });
|
||||
const objUrl = URL.createObjectURL(typed);
|
||||
(material as Record<string, unknown>)[kind] = objUrl;
|
||||
blobUrls.push(objUrl);
|
||||
}
|
||||
|
||||
if (blobUrls.length === 0) {
|
||||
throw new Error("Keine Textur-Karten im Zip gefunden");
|
||||
}
|
||||
return { material, blobUrls };
|
||||
}
|
||||
|
||||
/** Gibt die Blob-URLs eines heruntergeladenen Materials frei. */
|
||||
export function revokeMaterial(blobUrls: string[]): void {
|
||||
for (const u of blobUrls) URL.revokeObjectURL(u);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// Auto-generated built-in material library (ambientCG, CC0 1.0).
|
||||
// Static asset paths under /public; loadable directly via three.js TextureLoader.
|
||||
// Source data mirrors public/assets/materials/manifest.json.
|
||||
|
||||
export type MaterialMapKind =
|
||||
| 'color'
|
||||
| 'normal'
|
||||
| 'roughness'
|
||||
| 'metalness'
|
||||
| 'displacement'
|
||||
| 'ao';
|
||||
|
||||
export interface MaterialAsset {
|
||||
/** ambientCG asset id, e.g. 'Concrete048'. */
|
||||
id: string;
|
||||
/** Localized display name (German). */
|
||||
name: string;
|
||||
/** ambientCG category key, e.g. 'Concrete'. */
|
||||
category: string;
|
||||
/** Absolute public paths to the available texture maps. */
|
||||
maps: Partial<Record<MaterialMapKind, string>>;
|
||||
}
|
||||
|
||||
export const MATERIAL_LIBRARY_SOURCE = "ambientCG (ambientcg.com)";
|
||||
export const MATERIAL_LIBRARY_LICENSE = "CC0 1.0 Universal (Public Domain)";
|
||||
export const MATERIAL_LIBRARY_RESOLUTION = "1K";
|
||||
|
||||
export const MATERIAL_LIBRARY: MaterialAsset[] = [
|
||||
{
|
||||
id: "Concrete048",
|
||||
name: "Beton",
|
||||
category: "Concrete",
|
||||
maps: {
|
||||
color: "/assets/materials/Concrete048/color.jpg",
|
||||
normal: "/assets/materials/Concrete048/normal.jpg",
|
||||
roughness: "/assets/materials/Concrete048/roughness.jpg",
|
||||
displacement: "/assets/materials/Concrete048/displacement.jpg",
|
||||
ao: "/assets/materials/Concrete048/ao.jpg",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "Plaster001",
|
||||
name: "Putz/Stuck",
|
||||
category: "Plaster",
|
||||
maps: {
|
||||
color: "/assets/materials/Plaster001/color.jpg",
|
||||
normal: "/assets/materials/Plaster001/normal.jpg",
|
||||
roughness: "/assets/materials/Plaster001/roughness.jpg",
|
||||
displacement: "/assets/materials/Plaster001/displacement.jpg",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "Wood095",
|
||||
name: "Holz-Diele",
|
||||
category: "Wood",
|
||||
maps: {
|
||||
color: "/assets/materials/Wood095/color.jpg",
|
||||
normal: "/assets/materials/Wood095/normal.jpg",
|
||||
roughness: "/assets/materials/Wood095/roughness.jpg",
|
||||
displacement: "/assets/materials/Wood095/displacement.jpg",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "WoodFloor051",
|
||||
name: "Holz-Parkett",
|
||||
category: "WoodFloor",
|
||||
maps: {
|
||||
color: "/assets/materials/WoodFloor051/color.jpg",
|
||||
normal: "/assets/materials/WoodFloor051/normal.jpg",
|
||||
roughness: "/assets/materials/WoodFloor051/roughness.jpg",
|
||||
displacement: "/assets/materials/WoodFloor051/displacement.jpg",
|
||||
ao: "/assets/materials/WoodFloor051/ao.jpg",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "Bricks104",
|
||||
name: "Backstein",
|
||||
category: "Bricks",
|
||||
maps: {
|
||||
color: "/assets/materials/Bricks104/color.jpg",
|
||||
normal: "/assets/materials/Bricks104/normal.jpg",
|
||||
roughness: "/assets/materials/Bricks104/roughness.jpg",
|
||||
displacement: "/assets/materials/Bricks104/displacement.jpg",
|
||||
ao: "/assets/materials/Bricks104/ao.jpg",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "Tiles141",
|
||||
name: "Bodenfliesen",
|
||||
category: "Tiles",
|
||||
maps: {
|
||||
color: "/assets/materials/Tiles141/color.jpg",
|
||||
normal: "/assets/materials/Tiles141/normal.jpg",
|
||||
roughness: "/assets/materials/Tiles141/roughness.jpg",
|
||||
displacement: "/assets/materials/Tiles141/displacement.jpg",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "Marble012",
|
||||
name: "Naturstein/Marmor",
|
||||
category: "Marble",
|
||||
maps: {
|
||||
color: "/assets/materials/Marble012/color.jpg",
|
||||
normal: "/assets/materials/Marble012/normal.jpg",
|
||||
roughness: "/assets/materials/Marble012/roughness.jpg",
|
||||
displacement: "/assets/materials/Marble012/displacement.jpg",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "PavingStones150",
|
||||
name: "Pflasterstein",
|
||||
category: "PavingStones",
|
||||
maps: {
|
||||
color: "/assets/materials/PavingStones150/color.jpg",
|
||||
normal: "/assets/materials/PavingStones150/normal.jpg",
|
||||
roughness: "/assets/materials/PavingStones150/roughness.jpg",
|
||||
displacement: "/assets/materials/PavingStones150/displacement.jpg",
|
||||
ao: "/assets/materials/PavingStones150/ao.jpg",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "Metal063",
|
||||
name: "Metall",
|
||||
category: "Metal",
|
||||
maps: {
|
||||
color: "/assets/materials/Metal063/color.jpg",
|
||||
normal: "/assets/materials/Metal063/normal.jpg",
|
||||
roughness: "/assets/materials/Metal063/roughness.jpg",
|
||||
metalness: "/assets/materials/Metal063/metalness.jpg",
|
||||
displacement: "/assets/materials/Metal063/displacement.jpg",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "Gravel043",
|
||||
name: "Kies/Schotter",
|
||||
category: "Gravel",
|
||||
maps: {
|
||||
color: "/assets/materials/Gravel043/color.jpg",
|
||||
normal: "/assets/materials/Gravel043/normal.jpg",
|
||||
roughness: "/assets/materials/Gravel043/roughness.jpg",
|
||||
displacement: "/assets/materials/Gravel043/displacement.jpg",
|
||||
ao: "/assets/materials/Gravel043/ao.jpg",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "Grass005",
|
||||
name: "Gras",
|
||||
category: "Grass",
|
||||
maps: {
|
||||
color: "/assets/materials/Grass005/color.jpg",
|
||||
normal: "/assets/materials/Grass005/normal.jpg",
|
||||
roughness: "/assets/materials/Grass005/roughness.jpg",
|
||||
displacement: "/assets/materials/Grass005/displacement.jpg",
|
||||
ao: "/assets/materials/Grass005/ao.jpg",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "Ground103",
|
||||
name: "Boden",
|
||||
category: "Ground",
|
||||
maps: {
|
||||
color: "/assets/materials/Ground103/color.jpg",
|
||||
normal: "/assets/materials/Ground103/normal.jpg",
|
||||
roughness: "/assets/materials/Ground103/roughness.jpg",
|
||||
displacement: "/assets/materials/Ground103/displacement.jpg",
|
||||
ao: "/assets/materials/Ground103/ao.jpg",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export default MATERIAL_LIBRARY;
|
||||
@@ -0,0 +1,169 @@
|
||||
// 3D-Material-Laufzeit: baut aus einem `ComponentMaterial` (Karten-URLs +
|
||||
// physische Kachelgröße) ein gecachtes three.js `MeshStandardMaterial`. Die
|
||||
// Texturen werden lazy über den `TextureLoader` geladen (asynchron; das
|
||||
// Material erscheint sofort, die Karten „poppen" nach dem Laden ein) und je
|
||||
// Karten-URL nur EINMAL dekodiert (geteilte Bilddaten). Die physische
|
||||
// Skalierung läuft über `texture.repeat`: ExtrudeGeometry erzeugt die Seiten-
|
||||
// wand-UVs bereits in WELT-Metern (U = Position entlang der Wand in Metern,
|
||||
// V = Höhe in Metern). Eine Kachel soll `sizeM` Meter messen → `repeat = 1/sizeM`
|
||||
// (uniform), unabhängig von der Flächengröße. Dadurch braucht es KEINE flächen-
|
||||
// spezifischen Material-Instanzen — alle Wandflächen eines Bauteils teilen sich
|
||||
// dasselbe Material; die Kacheln laufen über Flächengrenzen hinweg konsistent.
|
||||
//
|
||||
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||||
|
||||
import * as THREE from "three";
|
||||
import type { ComponentMaterial } from "../model/types";
|
||||
import type { MaterialAsset } from "./library";
|
||||
|
||||
/** Default-Kachelgröße in Metern, falls `sizeM` fehlt. */
|
||||
export const DEFAULT_TILE_SIZE_M = 1.0;
|
||||
|
||||
/**
|
||||
* Cache + Loader für PBR-Materialien. Lebt so lange wie der Viewport-Aufbau
|
||||
* (eine Instanz je Szenen-Aufbau) und gibt im `dispose()` alle Texturen und
|
||||
* Materialien frei. Bilddaten werden je URL geteilt (mehrere Bauteile, die
|
||||
* dieselbe Karte nutzen, dekodieren sie nur einmal); die je Material geklonten
|
||||
* Textur-Objekte tragen ihre eigene `repeat`-Transform.
|
||||
*/
|
||||
export class MaterialRuntime {
|
||||
private readonly loader = new THREE.TextureLoader();
|
||||
/** Geteilte Quell-Texturen je URL (Bilddaten-Quelle für Klone). */
|
||||
private readonly sources = new Map<string, THREE.Texture>();
|
||||
/** Klone je Quell-URL — werden beim Bild-Load gesammelt geflaggt. */
|
||||
private readonly clonesBySource = new Map<string, Set<THREE.Texture>>();
|
||||
/** Fertige Materialien je Cache-Schlüssel (stabile Signatur). */
|
||||
private readonly materials = new Map<string, THREE.MeshStandardMaterial>();
|
||||
/** Alle erzeugten Textur-Klone (für dispose). */
|
||||
private readonly textures = new Set<THREE.Texture>();
|
||||
|
||||
/**
|
||||
* Lädt (oder liefert aus dem Cache) die Quell-Textur für `url`. Beim ersten
|
||||
* Aufruf wird asynchron geladen; sobald das Bild da ist, werden ALLE Klone
|
||||
* dieser Quelle als aktualisierungsbedürftig markiert (sonst zeigen sie kein
|
||||
* Bild bzw. der Renderer meldet „no image data"). `srgb` markiert die Farb-/
|
||||
* Albedo-Karte als sRGB (korrekte Farbe), alle anderen Karten sind Linear-Daten.
|
||||
*/
|
||||
private source(url: string, srgb: boolean): THREE.Texture {
|
||||
const key = `${srgb ? "s" : "l"}|${url}`;
|
||||
let tex = this.sources.get(key);
|
||||
if (!tex) {
|
||||
tex = this.loader.load(url, () => {
|
||||
// Bild geladen → alle bereits erzeugten Klone neu hochladen lassen.
|
||||
for (const clone of this.clonesBySource.get(key) ?? []) {
|
||||
clone.needsUpdate = true;
|
||||
}
|
||||
});
|
||||
tex.colorSpace = srgb ? THREE.SRGBColorSpace : THREE.NoColorSpace;
|
||||
this.sources.set(key, tex);
|
||||
this.clonesBySource.set(key, new Set());
|
||||
}
|
||||
return tex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Material-Karte: klont die Quell-Textur (teilt deren Bilddaten via
|
||||
* `.image`/`.source`, kein erneutes Dekodieren), setzt RepeatWrapping und
|
||||
* `repeat = 1/sizeM` (physische Kachelung). Der Klon wird für dispose gemerkt.
|
||||
*/
|
||||
private map(url: string, srgb: boolean, sizeM: number): THREE.Texture {
|
||||
const key = `${srgb ? "s" : "l"}|${url}`;
|
||||
const src = this.source(url, srgb);
|
||||
const tex = src.clone();
|
||||
tex.wrapS = THREE.RepeatWrapping;
|
||||
tex.wrapT = THREE.RepeatWrapping;
|
||||
const r = 1 / Math.max(sizeM, 0.001);
|
||||
tex.repeat.set(r, r);
|
||||
// Ist das Bild bereits geladen, sofort flaggen; sonst übernimmt das der
|
||||
// Source-onLoad (s. source()), der alle Klone dieser Quelle aktualisiert.
|
||||
// (three.js gibt für asynchron geladene Klone vor dem Bild-Load eine
|
||||
// harmlose „no image data"-Konsolenmeldung aus — die Karten erscheinen
|
||||
// korrekt, sobald das Bild da ist.)
|
||||
if (src.image) tex.needsUpdate = true;
|
||||
this.clonesBySource.get(key)?.add(tex);
|
||||
this.textures.add(tex);
|
||||
return tex;
|
||||
}
|
||||
|
||||
/** Stabile Cache-Signatur eines Materials (alle Karten-URLs + Größe). */
|
||||
private static keyOf(m: ComponentMaterial): string {
|
||||
return [
|
||||
m.color ?? "",
|
||||
m.normal ?? "",
|
||||
m.roughness ?? "",
|
||||
m.metalness ?? "",
|
||||
m.displacement ?? "",
|
||||
m.ao ?? "",
|
||||
m.sizeM ?? DEFAULT_TILE_SIZE_M,
|
||||
].join("|");
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert das (gecachte) `MeshStandardMaterial` für ein `ComponentMaterial`
|
||||
* oder null, wenn keinerlei Karte gesetzt ist (dann gilt das matte Default-
|
||||
* Verhalten). Das Material gilt für ALLE Flächen des Bauteils (geteilte
|
||||
* Kachel-Skalierung), da die UVs in Welt-Metern liegen.
|
||||
*/
|
||||
get(m: ComponentMaterial | undefined): THREE.MeshStandardMaterial | null {
|
||||
if (!m) return null;
|
||||
const hasAnyMap =
|
||||
m.color || m.normal || m.roughness || m.metalness || m.displacement || m.ao;
|
||||
if (!hasAnyMap) return null;
|
||||
|
||||
const key = MaterialRuntime.keyOf(m);
|
||||
const cached = this.materials.get(key);
|
||||
if (cached) return cached;
|
||||
|
||||
const sizeM = m.sizeM ?? DEFAULT_TILE_SIZE_M;
|
||||
const mat = new THREE.MeshStandardMaterial({
|
||||
// Weiß als Albedo-Basis, damit die Farb-Karte unverfälscht erscheint;
|
||||
// ohne Farb-Karte ein neutrales Hellgrau (sichtbares Volumen).
|
||||
color: m.color ? 0xffffff : 0xcccccc,
|
||||
roughness: 1,
|
||||
metalness: m.metalness ? 1 : 0,
|
||||
});
|
||||
if (m.color) mat.map = this.map(m.color, true, sizeM);
|
||||
if (m.normal) mat.normalMap = this.map(m.normal, false, sizeM);
|
||||
if (m.roughness) mat.roughnessMap = this.map(m.roughness, false, sizeM);
|
||||
if (m.metalness) mat.metalnessMap = this.map(m.metalness, false, sizeM);
|
||||
if (m.ao) mat.aoMap = this.map(m.ao, false, sizeM);
|
||||
if (m.displacement) {
|
||||
mat.displacementMap = this.map(m.displacement, false, sizeM);
|
||||
// Sehr dezent — eine echte Verschiebung braucht Tessellation; hier nur ein
|
||||
// Hauch, damit die Silhouette nicht aufreißt (Tiefe kommt aus normalMap).
|
||||
mat.displacementScale = 0.01;
|
||||
}
|
||||
mat.needsUpdate = true;
|
||||
|
||||
this.materials.set(key, mat);
|
||||
return mat;
|
||||
}
|
||||
|
||||
/** Gibt alle Texturen und Materialien frei. */
|
||||
dispose(): void {
|
||||
for (const tex of this.sources.values()) tex.dispose();
|
||||
this.sources.clear();
|
||||
for (const tex of this.textures) tex.dispose();
|
||||
this.textures.clear();
|
||||
this.clonesBySource.clear();
|
||||
for (const mat of this.materials.values()) mat.dispose();
|
||||
this.materials.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/** Baut aus einem Bibliotheks-Asset ein `ComponentMaterial` (Karten + Größe). */
|
||||
export function materialFromAsset(
|
||||
asset: MaterialAsset,
|
||||
sizeM = DEFAULT_TILE_SIZE_M,
|
||||
): ComponentMaterial {
|
||||
return {
|
||||
libraryId: asset.id,
|
||||
color: asset.maps.color,
|
||||
normal: asset.maps.normal,
|
||||
roughness: asset.maps.roughness,
|
||||
metalness: asset.maps.metalness,
|
||||
displacement: asset.maps.displacement,
|
||||
ao: asset.maps.ao,
|
||||
sizeM,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
defaultRoomStamp,
|
||||
roomStampToDoc,
|
||||
roomStampExtraLines,
|
||||
roomDisplayName,
|
||||
} from "./roomStamp";
|
||||
import type { Room } from "./types";
|
||||
|
||||
function makeRoom(over: Partial<Room> = {}): Room {
|
||||
return {
|
||||
id: "r1",
|
||||
type: "room",
|
||||
floorId: "f1",
|
||||
categoryCode: "45",
|
||||
siaCategory: "HNF",
|
||||
name: "Wohnen",
|
||||
boundary: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 4, y: 0 },
|
||||
{ x: 4, y: 3 },
|
||||
{ x: 0, y: 3 },
|
||||
],
|
||||
color: "#123456",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("defaultRoomStamp", () => {
|
||||
it("leitet Name/Flags aus dem Raum ab", () => {
|
||||
const stamp = defaultRoomStamp(makeRoom());
|
||||
expect(stamp.name).toBe("Wohnen");
|
||||
expect(stamp.showFloorArea).toBe(true);
|
||||
expect(stamp.showUsage).toBe(true);
|
||||
expect(stamp.number).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("roomStampToDoc", () => {
|
||||
it("eine Zeile ohne nameLine2", () => {
|
||||
const doc = roomStampToDoc({ name: "Bad", showFloorArea: true, showUsage: true });
|
||||
expect(doc.paragraphs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("zwei Zeilen mit nameLine2", () => {
|
||||
const doc = roomStampToDoc({
|
||||
name: "Bad",
|
||||
nameLine2: "WC",
|
||||
showFloorArea: true,
|
||||
showUsage: true,
|
||||
});
|
||||
expect(doc.paragraphs).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("Nummer als Präfix-Run in Zeile 1", () => {
|
||||
const doc = roomStampToDoc({
|
||||
number: "1.01",
|
||||
name: "Bad",
|
||||
showFloorArea: true,
|
||||
showUsage: true,
|
||||
});
|
||||
expect(doc.paragraphs[0].runs).toHaveLength(2);
|
||||
expect(doc.paragraphs[0].runs[0].text).toBe("1.01 ");
|
||||
expect(doc.paragraphs[0].runs[1].text).toBe("Bad");
|
||||
});
|
||||
});
|
||||
|
||||
describe("roomStampExtraLines", () => {
|
||||
it("beachtet Flags und Präfix", () => {
|
||||
const lines = roomStampExtraLines(
|
||||
{ name: "Bad", showFloorArea: true, showUsage: true, floorAreaPrefix: "BF " },
|
||||
{ netArea: 12.345, siaCategory: "HNF", siaLabel: "Hauptnutzfläche" },
|
||||
);
|
||||
expect(lines[0]).toBe("BF 12.35 m²");
|
||||
expect(lines[1]).toBe("HNF · Hauptnutzfläche");
|
||||
});
|
||||
|
||||
it("blendet Zeilen aus, wenn Flags falsch", () => {
|
||||
const lines = roomStampExtraLines(
|
||||
{ name: "Bad", showFloorArea: false, showUsage: false },
|
||||
{ netArea: 12.345, siaCategory: "HNF", siaLabel: "Hauptnutzfläche" },
|
||||
);
|
||||
expect(lines).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("roomDisplayName", () => {
|
||||
it("faellt auf room.name zurück ohne Stempel", () => {
|
||||
expect(roomDisplayName(makeRoom())).toBe("Wohnen");
|
||||
});
|
||||
|
||||
it("zieht name + nameLine2 zusammen", () => {
|
||||
const room = makeRoom({
|
||||
stamp: { name: "Bad", nameLine2: "WC", showFloorArea: true, showUsage: true },
|
||||
});
|
||||
expect(roomDisplayName(room)).toBe("Bad WC");
|
||||
});
|
||||
|
||||
it("nutzt nur name ohne nameLine2", () => {
|
||||
const room = makeRoom({
|
||||
stamp: { name: "Küche", showFloorArea: true, showUsage: true },
|
||||
});
|
||||
expect(roomDisplayName(room)).toBe("Küche");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
// Reines Modul für den strukturierten Raum-Stempel (Feldmodell). App-frei und
|
||||
// unit-getestet: baut aus einem RoomStamp das gerenderte Rich-Text-Dokument
|
||||
// (roomStampToDoc) und die Live-Zeilen (roomStampExtraLines), und liefert den
|
||||
// Anzeigenamen für Listen (roomDisplayName). Keine React-/Store-Abhängigkeit.
|
||||
//
|
||||
// Folge-Arbeit: Fensterfläche (braucht Öffnung-in-Raum-Geometrie).
|
||||
|
||||
import type { Room, RoomStamp } from "./types";
|
||||
import type { SiaCategory } from "../geometry/roomArea";
|
||||
import type { RichTextDoc, Paragraph, TextRun } from "../text/richText";
|
||||
import { makeRun } from "../text/richText";
|
||||
|
||||
/** Grösse (pt) des Raumnamens (Zeile 1) relativ zur Stempel-Basis. */
|
||||
const NAME_PT = 12;
|
||||
/** Grösse (pt) der kleinen Raumnummer als Präfix. */
|
||||
const NUMBER_PT = 9;
|
||||
/** Grösse (pt) der zweiten Namenszeile. */
|
||||
const LINE2_PT = 9;
|
||||
|
||||
/**
|
||||
* Standard-Stempel aus einem Raum ableiten: Name = Raum-Name, Bodenfläche und
|
||||
* Nutzung angezeigt. Nummer/Zeile 2 bleiben leer (frei ergänzbar).
|
||||
*/
|
||||
export function defaultRoomStamp(room: Room): RoomStamp {
|
||||
return {
|
||||
name: room.name,
|
||||
showFloorArea: true,
|
||||
showUsage: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut das Rich-Text-Dokument des Stempels:
|
||||
* • Zeile 1: optionale Raumnummer (kleiner Präfix) + Raumname (grösser, fett).
|
||||
* • Zeile 2 (optional): nameLine2 (kursiv, kleiner).
|
||||
* Die Live-Zeilen (Fläche/Nutzung) werden separat als extraLines gesetzt.
|
||||
*/
|
||||
export function roomStampToDoc(stamp: RoomStamp): RichTextDoc {
|
||||
const line1: TextRun[] = [];
|
||||
const number = (stamp.number ?? "").trim();
|
||||
if (number) {
|
||||
line1.push(makeRun(`${number} `, { sizePt: NUMBER_PT }));
|
||||
}
|
||||
line1.push(makeRun(stamp.name, { bold: true, sizePt: NAME_PT }));
|
||||
|
||||
const paragraphs: Paragraph[] = [{ runs: line1 }];
|
||||
const line2 = (stamp.nameLine2 ?? "").trim();
|
||||
if (line2) {
|
||||
paragraphs.push({ runs: [makeRun(line2, { italic: true, sizePt: LINE2_PT })] });
|
||||
}
|
||||
return { version: 1, paragraphs };
|
||||
}
|
||||
|
||||
/** Kontext für die Live-Zeilen (nicht gespeichert, bei jedem Rendern frisch). */
|
||||
export interface RoomStampContext {
|
||||
netArea: number;
|
||||
siaCategory: SiaCategory;
|
||||
siaLabel: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Live-Zeilen unter dem Stempel: Bodenfläche (mit Präfix) wenn showFloorArea,
|
||||
* Nutzung (SIA-Kürzel · Bezeichnung) wenn showUsage.
|
||||
*/
|
||||
export function roomStampExtraLines(stamp: RoomStamp, ctx: RoomStampContext): string[] {
|
||||
const lines: string[] = [];
|
||||
if (stamp.showFloorArea) {
|
||||
const prefix = stamp.floorAreaPrefix ?? "";
|
||||
lines.push(`${prefix}${ctx.netArea.toFixed(2)} m²`);
|
||||
}
|
||||
if (stamp.showUsage) {
|
||||
lines.push(`${ctx.siaCategory} · ${ctx.siaLabel}`);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Anzeigename für Listen: strukturierter Name (name + optional nameLine2 zu
|
||||
* einem Namen zusammengezogen), sonst der Alt-Raumname.
|
||||
*/
|
||||
export function roomDisplayName(room: Room): string {
|
||||
const stamp = room.stamp;
|
||||
if (!stamp) return room.name;
|
||||
const line2 = (stamp.nameLine2 ?? "").trim();
|
||||
return line2 ? `${stamp.name} ${line2}` : stamp.name;
|
||||
}
|
||||
@@ -133,9 +133,10 @@ export const sampleProject: Project = {
|
||||
},
|
||||
{ code: "30", name: "Decken", color: "#605850", lw: 0.35, visible: true, locked: false },
|
||||
{ code: "31", name: "Dächer", color: "#7a4a3a", lw: 0.35, visible: true, locked: false },
|
||||
{ code: "40", name: "Treppen", color: "#4a6a58", lw: 0.35, visible: true, locked: false },
|
||||
{ code: "35", name: "Träger", color: "#a87858", lw: 0.5, visible: true, locked: false },
|
||||
{ code: "50", name: "Text", color: "#d0d0d0", lw: 0.13, visible: true, locked: false },
|
||||
{ code: "60", name: "Plangrafik", color: "#c0a040", lw: 0.13, visible: true, locked: false },
|
||||
{ code: "60", name: "Räume", color: "#5a7a9a", lw: 0.13, visible: true, locked: false },
|
||||
{ code: "90", name: "Referenzen", color: "#585860", lw: 0.13, visible: true, locked: false },
|
||||
{ code: "99", name: "Konstruktion", color: "#404048", lw: 0.13, visible: true, locked: false },
|
||||
],
|
||||
@@ -173,6 +174,73 @@ export const sampleProject: Project = {
|
||||
hinge: "start",
|
||||
},
|
||||
],
|
||||
// Öffnungen (Fenster/Türen) — ein Beispiel-Fenster in der EG-Südwand (W1,
|
||||
// frei von der Tür) und in der EG-Nordwand (W3). Kategorie „21" (Türen/Fenster).
|
||||
openings: [
|
||||
{
|
||||
id: "O1",
|
||||
type: "opening",
|
||||
hostWallId: "W1",
|
||||
categoryCode: "21",
|
||||
kind: "window",
|
||||
position: 3.6, // hinter der Tür (2.0–2.9), noch innerhalb der 5-m-Wand
|
||||
width: 1.0,
|
||||
height: 1.2,
|
||||
sillHeight: 0.9,
|
||||
},
|
||||
{
|
||||
id: "O2",
|
||||
type: "opening",
|
||||
hostWallId: "W3",
|
||||
categoryCode: "21",
|
||||
kind: "window",
|
||||
position: 1.8,
|
||||
width: 1.4,
|
||||
height: 1.3,
|
||||
sillHeight: 0.9,
|
||||
},
|
||||
],
|
||||
// Decken (Slabs) — anfangs leer; werden mit dem Decken-Werkzeug gefüllt.
|
||||
ceilings: [],
|
||||
// Treppen — eine gerade Beispieltreppe im EG entlang der Ostwand, steigt ins OG
|
||||
// (totalRise = Geschosshöhe). Kategorie „40 Treppen".
|
||||
stairs: [
|
||||
{
|
||||
id: "ST1",
|
||||
type: "stair",
|
||||
floorId: "eg",
|
||||
categoryCode: "40",
|
||||
shape: "straight",
|
||||
start: { x: 3.7, y: 0.4 },
|
||||
dir: { x: 0, y: 1 },
|
||||
runLength: 3.2,
|
||||
width: 1.0,
|
||||
totalRise: 2.6,
|
||||
stepCount: 15,
|
||||
up: true,
|
||||
},
|
||||
],
|
||||
// Räume (SIA-416-Flächen) — ein Beispielraum im EG (lichte Innenkontur des
|
||||
// 5×4-m-Umrisses, Aussenwände 0.2 m → Innenmaß 4.8×3.8 m ≈ 18.24 m²).
|
||||
// Kategorie „45 Räume", SIA-Blatt HNF (Hauptnutzfläche).
|
||||
rooms: [
|
||||
{
|
||||
id: "R1",
|
||||
type: "room",
|
||||
floorId: "eg",
|
||||
categoryCode: "60",
|
||||
siaCategory: "HNF",
|
||||
name: "Wohnen",
|
||||
boundary: [
|
||||
{ x: 0.1, y: 0.1 },
|
||||
{ x: 4.9, y: 0.1 },
|
||||
{ x: 4.9, y: 3.9 },
|
||||
{ x: 0.1, y: 3.9 },
|
||||
],
|
||||
color: "#5a7a9a",
|
||||
stampAnchor: { x: 2.5, y: 2.0 },
|
||||
},
|
||||
],
|
||||
// Freie 2D-Zeichengeometrie — anfangs leer; wird mit den Zeichenwerkzeugen
|
||||
// gefüllt (docs/design/drawing-tools.md).
|
||||
drawings2d: [],
|
||||
|
||||
@@ -635,6 +635,35 @@ export interface Room {
|
||||
* Text. Die LIVE-Flächenzeile wird beim Rendern separat darunter gesetzt.
|
||||
*/
|
||||
stampDoc?: RichTextDoc;
|
||||
/**
|
||||
* Strukturierter Raum-Stempel (Feldmodell). Ist er gesetzt, hat er Vorrang vor
|
||||
* `stampDoc`/`name`: der Stempel-Text wird aus den Feldern gebaut, die Live-
|
||||
* Zeilen (Bodenfläche/Nutzung) aus den Flags. Fehlt er, gilt der Alt-Pfad.
|
||||
*/
|
||||
stamp?: RoomStamp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Feldmodell des Raum-Stempels. Ersetzt den freien Text durch benannte Felder;
|
||||
* daraus baut roomStamp.ts sowohl das gerenderte Rich-Text-Dokument als auch die
|
||||
* Live-Zeilen. Minimal gehalten (MVP).
|
||||
*
|
||||
* Folge-Arbeit: Fensterfläche (braucht Öffnung-in-Raum-Geometrie) — hier bewusst
|
||||
* NICHT enthalten.
|
||||
*/
|
||||
export interface RoomStamp {
|
||||
/** Raumnummer (kleiner Präfix in Zeile 1). */
|
||||
number?: string;
|
||||
/** Raumname (Zeile 1). */
|
||||
name: string;
|
||||
/** Raumname Zeile 2 (in Listen mit `name` zu einem Namen zusammengezogen). */
|
||||
nameLine2?: string;
|
||||
/** Bodenfläche anzeigen (Live-Zeile). */
|
||||
showFloorArea: boolean;
|
||||
/** Präfix vor der Bodenfläche, z. B. "BF " (Default leer). */
|
||||
floorAreaPrefix?: string;
|
||||
/** Nutzung (HNF/… · Bezeichnung) anzeigen (Live-Zeile). */
|
||||
showUsage: boolean;
|
||||
}
|
||||
|
||||
/** Eine Tür, gehostet in einer Wand. Ihr Geschoss ergibt sich aus der Wand. */
|
||||
|
||||
+57
-3
@@ -3,10 +3,10 @@
|
||||
// und die vertikale Ausdehnung (UK..OK). Alle Defaults entsprechen exakt dem
|
||||
// bisherigen Verhalten, sodass bestehende Wände unverändert rendern.
|
||||
//
|
||||
// Bezeichner englisch, Kommentare deutsch (CLAUDE.md).
|
||||
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||||
|
||||
import type { Project, VerticalAnchor, Wall } from "./types";
|
||||
import { getFloor } from "./types";
|
||||
import type { Ceiling, Project, Stair, VerticalAnchor, Wall } from "./types";
|
||||
import { ceilingThickness, getFloor } from "./types";
|
||||
|
||||
/**
|
||||
* Versatz der Schichtstapelung entlang der linken Normalen `+n` (leftNormal),
|
||||
@@ -70,6 +70,60 @@ export function wallVerticalExtent(
|
||||
return { zBottom, zTop };
|
||||
}
|
||||
|
||||
/**
|
||||
* Vertikale Ausdehnung einer Decke (absolute Z-Werte in Metern).
|
||||
* • zTop: aus `ceiling.top`, sonst Oberkante des Geschosses
|
||||
* (baseElevation + floorHeight) — also bündig mit dem Wandkopf.
|
||||
* • zBottom: zTop − Deckendicke (die Decke wächst nach UNTEN).
|
||||
* Fehlt das Geschoss, dient 0 als sicherer Rückfall für die Oberkante.
|
||||
*/
|
||||
export function ceilingVerticalExtent(
|
||||
project: Project,
|
||||
ceiling: Ceiling,
|
||||
): { zBottom: number; zTop: number } {
|
||||
let floorTop = 0;
|
||||
try {
|
||||
const floor = getFloor(project, ceiling.floorId);
|
||||
floorTop = (floor.baseElevation ?? 0) + (floor.floorHeight ?? 0);
|
||||
} catch {
|
||||
floorTop = 0;
|
||||
}
|
||||
const zTop = resolveAnchor(project, ceiling.top) ?? floorTop;
|
||||
const zBottom = zTop - ceilingThickness(project, ceiling);
|
||||
return { zBottom, zTop };
|
||||
}
|
||||
|
||||
/**
|
||||
* Vertikale Ausdehnung einer Treppe (absolute Z-Werte in Metern).
|
||||
* • zBottom: OKFF (baseElevation) des zugehörigen Geschosses.
|
||||
* • zTop: zBottom + Gesamt-Steighöhe. Fehlt `stair.totalRise`, gilt die
|
||||
* Geschosshöhe (floorHeight) des Geschosses — so steigt die Treppe genau ins
|
||||
* nächste Geschoss (OKFF → OKFF).
|
||||
* Fehlt das Geschoss, dient 0 als sicherer Rückfall für die Unterkante.
|
||||
*/
|
||||
export function stairVerticalExtent(
|
||||
project: Project,
|
||||
stair: Stair,
|
||||
): { zBottom: number; zTop: number } {
|
||||
let base = 0;
|
||||
let floorHeight = 0;
|
||||
try {
|
||||
const floor = getFloor(project, stair.floorId);
|
||||
base = floor.baseElevation ?? 0;
|
||||
floorHeight = floor.floorHeight ?? 0;
|
||||
} catch {
|
||||
base = 0;
|
||||
floorHeight = 0;
|
||||
}
|
||||
const rise =
|
||||
stair.totalRise != null && stair.totalRise > 0
|
||||
? stair.totalRise
|
||||
: floorHeight > 0
|
||||
? floorHeight
|
||||
: 2.6;
|
||||
return { zBottom: base, zTop: base + rise };
|
||||
}
|
||||
|
||||
/**
|
||||
* Das Geschoss DIREKT ÜBER dem Wand-Geschoss (in Dokumentreihenfolge der
|
||||
* "floor"-Ebenen) oder `undefined`, wenn die Wand im obersten Geschoss liegt.
|
||||
|
||||
@@ -17,10 +17,22 @@
|
||||
import { useState } from "react";
|
||||
import { t } from "../i18n";
|
||||
import { formatM } from "../model/types";
|
||||
import type { VerticalAnchor, WallReferenceLine } from "../model/types";
|
||||
import type {
|
||||
SiaCategory,
|
||||
StairShape,
|
||||
VerticalAnchor,
|
||||
WallReferenceLine,
|
||||
} from "../model/types";
|
||||
import { SIA_CATEGORIES, siaLabelFull } from "../geometry/roomArea";
|
||||
import { Dropdown } from "../ui/Dropdown";
|
||||
import { usePanelHost } from "./host";
|
||||
import type { WallInfo } from "../state/selectionInfo";
|
||||
import type {
|
||||
CeilingInfo,
|
||||
OpeningInfo,
|
||||
RoomInfo,
|
||||
StairInfo,
|
||||
WallInfo,
|
||||
} from "../state/selectionInfo";
|
||||
|
||||
// ── Bezugspunkt-Raster ───────────────────────────────────────────────────────
|
||||
// Neun Anker als 3×3-Raster. Zeile 0 = oben (fy=0), Spalte 0 = links (fx=0).
|
||||
@@ -71,6 +83,10 @@ export function ObjectInfoPanel() {
|
||||
}
|
||||
|
||||
const wall = sel.wall;
|
||||
const ceiling = sel.ceiling;
|
||||
const opening = sel.opening;
|
||||
const stair = sel.stair;
|
||||
const room = sel.room;
|
||||
|
||||
return (
|
||||
<div className="objinfo-panel">
|
||||
@@ -161,10 +177,441 @@ export function ObjectInfoPanel() {
|
||||
|
||||
{/* ── Wand-Abschnitt (nur bei genau einer Wand) ────────────────────── */}
|
||||
{wall && <WallSection wall={wall} host={host} />}
|
||||
|
||||
{/* ── Decken-Abschnitt (nur bei genau einer Decke) ─────────────────── */}
|
||||
{ceiling && <CeilingSection ceiling={ceiling} host={host} />}
|
||||
|
||||
{/* ── Öffnungs-Abschnitt (nur bei genau einer Öffnung) ─────────────── */}
|
||||
{opening && <OpeningSection opening={opening} host={host} />}
|
||||
|
||||
{/* ── Treppen-Abschnitt (nur bei genau einer Treppe) ───────────────── */}
|
||||
{stair && <StairSection stair={stair} host={host} />}
|
||||
|
||||
{/* ── Raum-Abschnitt (nur bei genau einem Raum) ────────────────────── */}
|
||||
{room && <RoomSection room={room} roomId={sel.id} host={host} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Raum-Attribut-Abschnitt ──────────────────────────────────────────────────
|
||||
// Editierbarer Name, SIA-416-Kategorie (5 Blatt-Kategorien), abgeleitete Fläche
|
||||
// + Umfang (read-only), Referenzgeschoss, sowie ein Button, der den Rich-Text-
|
||||
// Stempel-Editor öffnet. Alle Werte/Setter über den Host.
|
||||
function RoomSection({
|
||||
room,
|
||||
roomId,
|
||||
host,
|
||||
}: {
|
||||
room: RoomInfo;
|
||||
roomId: string;
|
||||
host: ReturnType<typeof usePanelHost>;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="objinfo-sep" />
|
||||
<div className="objinfo-section-label">{t("objinfo.room.section")}</div>
|
||||
|
||||
{/* Name (editierbar). */}
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.room.name")}</span>
|
||||
<input
|
||||
type="text"
|
||||
className="objinfo-text"
|
||||
value={room.name}
|
||||
onChange={(e) => host.onSetRoomName(e.target.value)}
|
||||
title={t("objinfo.room.name")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* SIA-416-Kategorie (5 Blatt-Kategorien). */}
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.room.sia")}</span>
|
||||
<Dropdown
|
||||
value={room.siaCategory}
|
||||
onChange={(v) => host.onSetRoomSia(v as SiaCategory)}
|
||||
options={SIA_CATEGORIES.map((c) => ({
|
||||
value: c,
|
||||
label: siaLabelFull(c),
|
||||
}))}
|
||||
title={t("objinfo.room.sia")}
|
||||
width={170}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Fläche + Umfang (read-only, abgeleitet). */}
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.room.area")}</span>
|
||||
<span className="objinfo-fval">{room.area.toFixed(2)} m²</span>
|
||||
</div>
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.room.perimeter")}</span>
|
||||
<span className="objinfo-fval">{formatM(room.perimeter)}</span>
|
||||
</div>
|
||||
|
||||
{/* Referenzgeschoss (read-only). */}
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.room.refFloor")}</span>
|
||||
<span className="objinfo-fval">{room.floorName}</span>
|
||||
</div>
|
||||
|
||||
{/* Stempel bearbeiten (öffnet den Rich-Text-Editor). */}
|
||||
<button
|
||||
type="button"
|
||||
className="objinfo-btn"
|
||||
onClick={() => host.onEditRoomStamp(roomId)}
|
||||
>
|
||||
{t("objinfo.room.editStamp")}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Treppen-Attribut-Abschnitt ───────────────────────────────────────────────
|
||||
// Grundform (gerade/L/Wendel), Laufbreite, Stufenanzahl, Steighöhe (+ abgeleitete
|
||||
// Steigungshöhe/Auftrittstiefe), Laufrichtung; Referenzgeschoss + UK/OK read-only.
|
||||
function StairSection({
|
||||
stair,
|
||||
host,
|
||||
}: {
|
||||
stair: StairInfo;
|
||||
host: ReturnType<typeof usePanelHost>;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="objinfo-sep" />
|
||||
<div className="objinfo-section-label">{t("objinfo.stair.section")}</div>
|
||||
|
||||
{/* Grundform-Dropdown. */}
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.stair.shape")}</span>
|
||||
<Dropdown
|
||||
value={stair.shape}
|
||||
onChange={(v) => host.onSetStairShape(v as StairShape)}
|
||||
options={[
|
||||
{ value: "straight", label: t("objinfo.stair.shape.straight") },
|
||||
{ value: "L", label: t("objinfo.stair.shape.L") },
|
||||
{ value: "spiral", label: t("objinfo.stair.shape.spiral") },
|
||||
]}
|
||||
title={t("objinfo.stair.shape")}
|
||||
width={130}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Laufrichtung (aufwärts/abwärts). */}
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.stair.direction")}</span>
|
||||
<div className="objinfo-segment">
|
||||
<button
|
||||
type="button"
|
||||
className={"objinfo-seg-btn" + (stair.up ? " active" : "")}
|
||||
onClick={() => host.onSetStairUp(true)}
|
||||
>
|
||||
{t("objinfo.stair.direction.up")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={"objinfo-seg-btn" + (!stair.up ? " active" : "")}
|
||||
onClick={() => host.onSetStairUp(false)}
|
||||
>
|
||||
{t("objinfo.stair.direction.down")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Editierbare Maße. */}
|
||||
<DimensionField
|
||||
label={t("objinfo.stair.width")}
|
||||
value={stair.width}
|
||||
onCommit={(v) => host.onSetStairWidth(v)}
|
||||
/>
|
||||
<DimensionField
|
||||
label={t("objinfo.stair.steps")}
|
||||
value={stair.stepCount}
|
||||
onCommit={(v) => host.onSetStairSteps(v)}
|
||||
/>
|
||||
<DimensionField
|
||||
label={t("objinfo.stair.rise")}
|
||||
value={stair.totalRise}
|
||||
onCommit={(v) => host.onSetStairRise(v)}
|
||||
/>
|
||||
|
||||
{/* Abgeleitete Werte (read-only). */}
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.stair.riser")}</span>
|
||||
<span className="objinfo-fval">{formatM(stair.riserHeight)}</span>
|
||||
</div>
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.stair.tread")}</span>
|
||||
<span className="objinfo-fval">{formatM(stair.treadDepth)}</span>
|
||||
</div>
|
||||
|
||||
{/* Referenzgeschoss (read-only). */}
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.stair.refFloor")}</span>
|
||||
<span className="objinfo-fval">{stair.floorName}</span>
|
||||
</div>
|
||||
|
||||
<div className="objinfo-sep" />
|
||||
|
||||
{/* UK/OK (read-only). */}
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.stair.bottom")}</span>
|
||||
<span className="objinfo-fval">{formatM(stair.zBottom)}</span>
|
||||
</div>
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.stair.top")}</span>
|
||||
<span className="objinfo-fval">{formatM(stair.zTop)}</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Öffnungs-Attribut-Abschnitt ──────────────────────────────────────────────
|
||||
// Art (Fenster/Tür), Wirts-Wand, Position/Breite/Höhe/Brüstung, bei Türen
|
||||
// zusätzlich Anschlag/Aufschlag/Richtung/Winkel; UK/OK read-only.
|
||||
function OpeningSection({
|
||||
opening,
|
||||
host,
|
||||
}: {
|
||||
opening: OpeningInfo;
|
||||
host: ReturnType<typeof usePanelHost>;
|
||||
}) {
|
||||
const isDoor = opening.kind === "door";
|
||||
return (
|
||||
<>
|
||||
<div className="objinfo-sep" />
|
||||
<div className="objinfo-section-label">{t("objinfo.opening.section")}</div>
|
||||
|
||||
{/* Art-Segment Fenster/Tür. */}
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.opening.kind")}</span>
|
||||
<div className="objinfo-segment">
|
||||
<button
|
||||
type="button"
|
||||
className={"objinfo-seg-btn" + (!isDoor ? " active" : "")}
|
||||
onClick={() => host.onSetOpeningKind("window")}
|
||||
>
|
||||
{t("objinfo.opening.kind.window")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={"objinfo-seg-btn" + (isDoor ? " active" : "")}
|
||||
onClick={() => host.onSetOpeningKind("door")}
|
||||
>
|
||||
{t("objinfo.opening.kind.door")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Wirts-Wand (read-only Anzeige des Geschosses/Wand-Bezugs). */}
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.opening.hostWall")}</span>
|
||||
<span className="objinfo-fval">{opening.hostWallName}</span>
|
||||
</div>
|
||||
|
||||
{/* Editierbare Maße. */}
|
||||
<DimensionField
|
||||
label={t("objinfo.opening.position")}
|
||||
value={opening.position}
|
||||
onCommit={(v) => host.onSetOpeningPosition(v)}
|
||||
/>
|
||||
<DimensionField
|
||||
label={t("objinfo.opening.width")}
|
||||
value={opening.width}
|
||||
onCommit={(v) => host.onSetOpeningWidth(v)}
|
||||
/>
|
||||
<DimensionField
|
||||
label={t("objinfo.opening.height")}
|
||||
value={opening.height}
|
||||
onCommit={(v) => host.onSetOpeningHeight(v)}
|
||||
/>
|
||||
{!isDoor && (
|
||||
<DimensionField
|
||||
label={t("objinfo.opening.sill")}
|
||||
value={opening.sillHeight}
|
||||
onCommit={(v) => host.onSetOpeningSill(v)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tür-spezifisch: Anschlag / Aufschlagseite / Richtung / Winkel. */}
|
||||
{isDoor && (
|
||||
<>
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.opening.hinge")}</span>
|
||||
<Dropdown
|
||||
value={opening.hinge ?? "start"}
|
||||
onChange={(v) => host.onSetOpeningHinge(v as "start" | "end")}
|
||||
options={[
|
||||
{ value: "start", label: t("objinfo.opening.hinge.start") },
|
||||
{ value: "end", label: t("objinfo.opening.hinge.end") },
|
||||
]}
|
||||
title={t("objinfo.opening.hinge")}
|
||||
width={130}
|
||||
/>
|
||||
</div>
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.opening.swing")}</span>
|
||||
<Dropdown
|
||||
value={opening.swing ?? "left"}
|
||||
onChange={(v) => host.onSetOpeningSwing(v as "left" | "right")}
|
||||
options={[
|
||||
{ value: "left", label: t("objinfo.opening.swing.left") },
|
||||
{ value: "right", label: t("objinfo.opening.swing.right") },
|
||||
]}
|
||||
title={t("objinfo.opening.swing")}
|
||||
width={130}
|
||||
/>
|
||||
</div>
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.opening.dir")}</span>
|
||||
<Dropdown
|
||||
value={opening.openingDir ?? "in"}
|
||||
onChange={(v) => host.onSetOpeningDir(v as "in" | "out")}
|
||||
options={[
|
||||
{ value: "in", label: t("objinfo.opening.dir.in") },
|
||||
{ value: "out", label: t("objinfo.opening.dir.out") },
|
||||
]}
|
||||
title={t("objinfo.opening.dir")}
|
||||
width={130}
|
||||
/>
|
||||
</div>
|
||||
<DimensionField
|
||||
label={t("objinfo.opening.swingAngle")}
|
||||
value={opening.swingAngle ?? 90}
|
||||
min={1}
|
||||
onCommit={(v) => host.onSetOpeningSwingAngle(v)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="objinfo-sep" />
|
||||
|
||||
{/* UK/OK (read-only, aus der Wand-UK + Brüstung/Höhe abgeleitet). */}
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.opening.bottom")}</span>
|
||||
<span className="objinfo-fval">{formatM(opening.zBottom)}</span>
|
||||
</div>
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.opening.top")}</span>
|
||||
<span className="objinfo-fval">{formatM(opening.zTop)}</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Decken-Attribut-Abschnitt ────────────────────────────────────────────────
|
||||
// Aufbau-Typ (einschichtig/mehrschichtig), Dicke/Preset, Referenzgeschoss +
|
||||
// OK-Bindung, Fläche. Alle Werte/Setter über den Host.
|
||||
function CeilingSection({
|
||||
ceiling,
|
||||
host,
|
||||
}: {
|
||||
ceiling: CeilingInfo;
|
||||
host: ReturnType<typeof usePanelHost>;
|
||||
}) {
|
||||
const isSingle = ceiling.singleLayer;
|
||||
const multiPresets = ceiling.wallTypes.filter((wt) => wt.layerCount > 1);
|
||||
|
||||
function chooseSingle() {
|
||||
if (isSingle) return;
|
||||
host.onSetCeilingThickness(ceiling.thickness);
|
||||
}
|
||||
function chooseMulti() {
|
||||
if (!isSingle) return;
|
||||
const first = multiPresets[0];
|
||||
if (first) host.onSetCeilingType(first.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="objinfo-sep" />
|
||||
<div className="objinfo-section-label">{t("objinfo.ceiling.section")}</div>
|
||||
|
||||
{/* Aufbau-Typ-Segment. */}
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.ceiling.buildup")}</span>
|
||||
<div className="objinfo-segment">
|
||||
<button
|
||||
type="button"
|
||||
className={"objinfo-seg-btn" + (isSingle ? " active" : "")}
|
||||
onClick={chooseSingle}
|
||||
>
|
||||
{t("objinfo.wall.single")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={"objinfo-seg-btn" + (!isSingle ? " active" : "")}
|
||||
onClick={chooseMulti}
|
||||
disabled={isSingle && multiPresets.length === 0}
|
||||
>
|
||||
{t("objinfo.wall.multi")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Einschichtig → Dicke-Zahlfeld. Mehrschichtig → Preset-Dropdown. */}
|
||||
{isSingle ? (
|
||||
<DimensionField
|
||||
label={t("objinfo.ceiling.thickness")}
|
||||
value={ceiling.thickness}
|
||||
onCommit={(v) => host.onSetCeilingThickness(v)}
|
||||
/>
|
||||
) : (
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.ceiling.preset")}</span>
|
||||
<Dropdown
|
||||
value={ceiling.wallTypeId}
|
||||
onChange={(id) => host.onSetCeilingType(id)}
|
||||
options={ceiling.wallTypes.map((wt) => ({
|
||||
value: wt.id,
|
||||
label: t("objinfo.wall.presetLabel", {
|
||||
name: wt.name,
|
||||
thickness: wt.thickness.toFixed(2),
|
||||
}),
|
||||
}))}
|
||||
title={t("objinfo.ceiling.preset")}
|
||||
width={150}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Referenzgeschoss (read-only Anzeige). */}
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.ceiling.refFloor")}</span>
|
||||
<span className="objinfo-fval">{ceiling.floorName}</span>
|
||||
</div>
|
||||
|
||||
{/* Fläche (read-only). */}
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.ceiling.area")}</span>
|
||||
<span className="objinfo-fval">{ceiling.area.toFixed(2)} m²</span>
|
||||
</div>
|
||||
|
||||
<div className="objinfo-sep" />
|
||||
|
||||
{/* OK der Decke: an Geschoss gebunden ODER eigene Höhe. */}
|
||||
<VerticalAnchorRow
|
||||
label={t("objinfo.ceiling.top")}
|
||||
anchor={ceiling.top}
|
||||
defaultZ={ceiling.zTop}
|
||||
floors={ceiling.floors}
|
||||
defaultFloorId={ceiling.floorId}
|
||||
onChange={(a) => host.onSetCeilingTop(a)}
|
||||
/>
|
||||
|
||||
{/* UK = OK − Dicke (abgeleitet, read-only). */}
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.ceiling.bottom")}</span>
|
||||
<span className="objinfo-fval">{formatM(ceiling.zBottom)}</span>
|
||||
</div>
|
||||
<div className="objinfo-field">
|
||||
<span className="objinfo-flabel">{t("objinfo.ceiling.height")}</span>
|
||||
<span className="objinfo-fval">{formatM(ceiling.zTop - ceiling.zBottom)}</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Wand-Attribut-Abschnitt ──────────────────────────────────────────────────
|
||||
// Aufbau-Typ (einschichtig/mehrschichtig), Dicke/Preset, Referenzgeschoss +
|
||||
// Oberverknüpfung, UK/OK je Modus-Umschalter. Alle Werte/Setter über den Host.
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
// Inhalts-Panel „Flächenbilanz" (SIA 416) — listet alle Räume gruppiert nach
|
||||
// ihrer SIA-416-Blatt-Kategorie mit Zwischen-/Gesamtsummen und bietet einen
|
||||
// CSV-Export. Fläche/Bilanz werden LIVE aus den Raum-Umrissen abgeleitet
|
||||
// (geometry/roomArea: evaluateRoom/balance/roomsToCsv) — nie gespeichert.
|
||||
//
|
||||
// Daten kommen über usePanelHost (project.rooms). Bezeichner englisch, UI-Text
|
||||
// deutsch via t() (CONVENTIONS.md).
|
||||
|
||||
import { t } from "../i18n";
|
||||
import { usePanelHost } from "./host";
|
||||
import {
|
||||
balance,
|
||||
evaluateRoom,
|
||||
roomsToCsv,
|
||||
siaLabelFull,
|
||||
} from "../geometry/roomArea";
|
||||
import type { Room } from "../model/types";
|
||||
import { roomDisplayName } from "../model/roomStamp";
|
||||
|
||||
/** Löst je Raum die anrechenbare (Netto-)Fläche + Kategorie auf. */
|
||||
function roomAreas(rooms: Room[]): { category: Room["siaCategory"]; area: number }[] {
|
||||
return rooms.map((r) => ({
|
||||
category: r.siaCategory,
|
||||
area: evaluateRoom(r.boundary, r.siaCategory).netArea,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Löst den Anzeigenamen eines Geschosses (oder die ID) auf. */
|
||||
function floorNameOf(
|
||||
levels: { id: string; name: string }[],
|
||||
floorId: string,
|
||||
): string {
|
||||
return levels.find((z) => z.id === floorId)?.name ?? floorId;
|
||||
}
|
||||
|
||||
/** Stösst den Download einer CSV-Datei an (reiner Client, Blob-URL). */
|
||||
function downloadCsv(fileName: string, content: string): void {
|
||||
// BOM voranstellen, damit Excel UTF-8 (Umlaute/„m²") korrekt liest.
|
||||
const blob = new Blob(["" + content], {
|
||||
type: "text/csv;charset=utf-8;",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = fileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function RoomBalancePanel() {
|
||||
const host = usePanelHost();
|
||||
const rooms = host.project.rooms ?? [];
|
||||
const levels = host.project.drawingLevels;
|
||||
|
||||
if (rooms.length === 0) {
|
||||
return (
|
||||
<div className="balance-panel">
|
||||
<div className="balance-empty">{t("balance.empty")}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const bal = balance(roomAreas(rooms));
|
||||
|
||||
const onExport = () => {
|
||||
const csv = roomsToCsv(
|
||||
rooms.map((r) => ({
|
||||
number: r.stamp?.number ?? r.id,
|
||||
name: roomDisplayName(r),
|
||||
level: floorNameOf(levels, r.floorId),
|
||||
category: r.siaCategory,
|
||||
area: evaluateRoom(r.boundary, r.siaCategory).netArea,
|
||||
})),
|
||||
);
|
||||
downloadCsv("flaechenbilanz.csv", csv);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="balance-panel">
|
||||
<div className="balance-head">
|
||||
<span className="balance-title">{t("balance.title")}</span>
|
||||
<button type="button" className="balance-export" onClick={onExport}>
|
||||
{t("balance.export")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Raumliste (je Raum: Name · Geschoss · SIA · Fläche). */}
|
||||
<div className="balance-section-label">{t("balance.rooms")}</div>
|
||||
<div className="balance-table">
|
||||
{rooms.map((r) => {
|
||||
const res = evaluateRoom(r.boundary, r.siaCategory);
|
||||
return (
|
||||
<div key={r.id} className="balance-row">
|
||||
<span className="balance-name">{roomDisplayName(r)}</span>
|
||||
<span className="balance-cat">{r.siaCategory}</span>
|
||||
<span className="balance-area">{res.netArea.toFixed(2)} m²</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* SIA-416-Bilanz (Blatt-Kategorien + Zwischensummen NF/NGF + Gesamt GF). */}
|
||||
<div className="balance-section-label">{t("balance.summary")}</div>
|
||||
<div className="balance-table">
|
||||
{bal.rows.map((row) => (
|
||||
<div
|
||||
key={row.key}
|
||||
className={"balance-row" + (row.aggregate ? " aggregate" : "")}
|
||||
title={siaLabelFull(row.key)}
|
||||
>
|
||||
<span className="balance-name">
|
||||
{row.key} · {row.label}
|
||||
</span>
|
||||
<span className="balance-count">{row.count}</span>
|
||||
<span className="balance-area">{row.area.toFixed(2)} m²</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
// Schwebender Dialog zum Bearbeiten des strukturierten Raum-Stempels (Feldmodell):
|
||||
// Raumnummer, Raumname (+ Zeile 2), Bodenfläche (mit Präfix) und Nutzung. Ersetzt
|
||||
// für neue Räume den freien Text-Editor. Kontrolliert (value/onApply/onCancel);
|
||||
// arbeitet auf einem lokalen Entwurf, erst „Übernehmen" schreibt zurück.
|
||||
// Bezeichner englisch, UI-Text über t().
|
||||
//
|
||||
// Folge-Arbeit: Zeilen-Layout-Editor (welches Feld auf welche Zeile),
|
||||
// Per-Feld-Typografie, Fensterfläche.
|
||||
|
||||
import { useState } from "react";
|
||||
import { t } from "../i18n";
|
||||
import type { RoomStamp } from "../model/types";
|
||||
|
||||
export interface RoomStampEditorProps {
|
||||
/** Ausgangsstempel (wird beim Öffnen kopiert; Editor arbeitet lokal). */
|
||||
value: RoomStamp;
|
||||
/** „Übernehmen" — den bearbeiteten Stempel zurückschreiben. */
|
||||
onApply: (stamp: RoomStamp) => void;
|
||||
/** „Abbrechen"/Schliessen — ohne Übernahme. */
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function RoomStampEditor({ value, onApply, onCancel }: RoomStampEditorProps) {
|
||||
const [draft, setDraft] = useState<RoomStamp>(value);
|
||||
|
||||
const patch = (p: Partial<RoomStamp>) => setDraft((d) => ({ ...d, ...p }));
|
||||
|
||||
return (
|
||||
<div
|
||||
className="texteditor-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("roomStamp.title")}
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) onCancel();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") onCancel();
|
||||
}}
|
||||
>
|
||||
<div className="texteditor-dialog roomstamp-dialog">
|
||||
<div className="texteditor-head">
|
||||
<span className="texteditor-title">{t("roomStamp.title")}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="texteditor-x"
|
||||
onClick={onCancel}
|
||||
title={t("text.cancel")}
|
||||
aria-label={t("text.cancel")}
|
||||
>
|
||||
<span className="material-symbols-outlined" aria-hidden="true">
|
||||
close
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="texteditor-body roomstamp-body">
|
||||
<label className="roomstamp-field">
|
||||
<span className="roomstamp-label">{t("roomStamp.number")}</span>
|
||||
<input
|
||||
type="text"
|
||||
className="roomstamp-input"
|
||||
value={draft.number ?? ""}
|
||||
onChange={(e) => patch({ number: e.target.value })}
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="roomstamp-field">
|
||||
<span className="roomstamp-label">{t("roomStamp.name")}</span>
|
||||
<input
|
||||
type="text"
|
||||
className="roomstamp-input"
|
||||
value={draft.name}
|
||||
onChange={(e) => patch({ name: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="roomstamp-field">
|
||||
<span className="roomstamp-label">{t("roomStamp.nameLine2")}</span>
|
||||
<input
|
||||
type="text"
|
||||
className="roomstamp-input"
|
||||
value={draft.nameLine2 ?? ""}
|
||||
onChange={(e) => patch({ nameLine2: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="roomstamp-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.showFloorArea}
|
||||
onChange={(e) => patch({ showFloorArea: e.target.checked })}
|
||||
/>
|
||||
<span>{t("roomStamp.showFloorArea")}</span>
|
||||
</label>
|
||||
|
||||
<label className="roomstamp-field">
|
||||
<span className="roomstamp-label">{t("roomStamp.floorAreaPrefix")}</span>
|
||||
<input
|
||||
type="text"
|
||||
className="roomstamp-input"
|
||||
value={draft.floorAreaPrefix ?? ""}
|
||||
disabled={!draft.showFloorArea}
|
||||
onChange={(e) => patch({ floorAreaPrefix: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="roomstamp-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.showUsage}
|
||||
onChange={(e) => patch({ showUsage: e.target.checked })}
|
||||
/>
|
||||
<span>{t("roomStamp.showUsage")}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="texteditor-foot">
|
||||
<button type="button" className="texteditor-btn" onClick={onCancel}>
|
||||
{t("text.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="texteditor-btn texteditor-btn-primary"
|
||||
onClick={() => onApply(draft)}
|
||||
>
|
||||
{t("text.apply")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default RoomStampEditor;
|
||||
@@ -16,6 +16,7 @@ import { t } from "../i18n";
|
||||
import type { TranslationKey } from "../i18n";
|
||||
import { usePanelHost } from "./host";
|
||||
import type { ContextObject } from "../model/types";
|
||||
import { ContextImportDialog } from "../ui/ContextImportDialog";
|
||||
|
||||
/** Typ-spezifisches Badge-Label (i18n-Key) je Kontext-Objekt-Art. */
|
||||
function typeBadgeKey(obj: ContextObject): TranslationKey {
|
||||
@@ -34,6 +35,9 @@ export function SitePanel() {
|
||||
const objects = host.contextObjects;
|
||||
const contourSets = objects.filter((o) => o.type === "contourSet");
|
||||
|
||||
// Standort-Import-Dialog (geo.admin/OSM) offen?
|
||||
const [ctxImportOpen, setCtxImportOpen] = useState(false);
|
||||
|
||||
// Gewählter Kontur-Satz für „Gelände erzeugen" (default: erster vorhandener).
|
||||
const [selectedSet, setSelectedSet] = useState<string>("");
|
||||
const effectiveSet =
|
||||
@@ -62,8 +66,22 @@ export function SitePanel() {
|
||||
>
|
||||
{t("site.import")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="site-btn"
|
||||
onClick={() => setCtxImportOpen(true)}
|
||||
>
|
||||
{t("site.importLocation")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{ctxImportOpen && (
|
||||
<ContextImportDialog
|
||||
onImport={(objs) => host.onAddContextObjects(objs)}
|
||||
onClose={() => setCtxImportOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{contourSets.length > 0 && (
|
||||
<div className="site-terrain-row">
|
||||
{contourSets.length > 1 ? (
|
||||
|
||||
@@ -45,6 +45,50 @@ function ToolIcon({ id }: { id: ToolId }) {
|
||||
<line x1="2" y1="10.5" x2="14" y2="10.5" />
|
||||
</svg>
|
||||
);
|
||||
case "ceiling":
|
||||
// Deckenplatte: gefülltes Rechteck mit Diagonal-Schraffur (Slab-Symbol).
|
||||
return (
|
||||
<svg {...common}>
|
||||
<rect x="2.5" y="4" width="11" height="8" />
|
||||
<line x1="4" y1="12" x2="8" y2="4" />
|
||||
<line x1="8" y1="12" x2="12" y2="4" />
|
||||
</svg>
|
||||
);
|
||||
case "window":
|
||||
// Fenster: Rahmen-Rechteck mit Mittelsprosse (Verglasung).
|
||||
return (
|
||||
<svg {...common}>
|
||||
<rect x="2.5" y="4" width="11" height="8" />
|
||||
<line x1="8" y1="4" x2="8" y2="12" />
|
||||
</svg>
|
||||
);
|
||||
case "door":
|
||||
// Tür: Pfosten + Türblatt mit Schwenkbogen.
|
||||
return (
|
||||
<svg {...common}>
|
||||
<line x1="3" y1="13" x2="3" y2="4" />
|
||||
<line x1="3" y1="4" x2="11" y2="4" />
|
||||
<path d="M3 13 A9 9 0 0 0 11 4" />
|
||||
</svg>
|
||||
);
|
||||
case "stair":
|
||||
// Treppensymbol: gestufte Linie (Tritte) + Lauflinie mit Pfeil.
|
||||
return (
|
||||
<svg {...common}>
|
||||
<polyline points="2,14 2,11 6,11 6,8 10,8 10,5 14,5 14,2" />
|
||||
<line x1="4" y1="12.5" x2="12" y2="3.5" />
|
||||
<polyline points="10.5,3 12,3.5 11.5,5" />
|
||||
</svg>
|
||||
);
|
||||
case "room":
|
||||
// Raum: umschlossene Fläche (Rechteck) mit Flächen-Marke (kleines Kreuz).
|
||||
return (
|
||||
<svg {...common}>
|
||||
<rect x="2.5" y="3.5" width="11" height="9" />
|
||||
<line x1="6" y1="8" x2="10" y2="8" />
|
||||
<line x1="8" y1="6" x2="8" y2="10" />
|
||||
</svg>
|
||||
);
|
||||
case "line":
|
||||
// Einzelne Diagonale.
|
||||
return (
|
||||
@@ -126,6 +170,27 @@ export function ToolsPanel() {
|
||||
|
||||
{host.activeTool === "wall" && <div className="tools-sep" />}
|
||||
|
||||
{/* Deckentyp-Auswahl — nur sichtbar, wenn das Decken-Werkzeug aktiv ist.
|
||||
Nutzt denselben Aufbau-Typ (WallType) wie die Wand. */}
|
||||
{host.activeTool === "ceiling" && (
|
||||
<label className="tools-field">
|
||||
<span>{t("tool.ceilingType")}</span>
|
||||
<select
|
||||
value={host.activeWallTypeId}
|
||||
disabled={!host.toolsEnabled}
|
||||
onChange={(e) => host.onActiveWallTypeId(e.target.value)}
|
||||
>
|
||||
{host.project.wallTypes.map((wt) => (
|
||||
<option key={wt.id} value={wt.id}>
|
||||
{wt.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{host.activeTool === "ceiling" && <div className="tools-sep" />}
|
||||
|
||||
{/* Fang-Abschnitt. Master-Checkbox steuert die übrigen Fang-Optionen. */}
|
||||
<label className="tools-snap-row">
|
||||
<input
|
||||
|
||||
@@ -21,6 +21,7 @@ import { ToolsPanel } from "./ToolsPanel";
|
||||
import { AttributesPanel } from "./AttributesPanel";
|
||||
import { ObjectInfoPanel } from "./ObjectInfoPanel";
|
||||
import { SitePanel } from "./SitePanel";
|
||||
import { RoomBalancePanel } from "./RoomBalancePanel";
|
||||
|
||||
/** IDs der eingebauten Dock-Panels (auch für defaultLayout/Filter nutzbar). */
|
||||
export const BUILTIN_PANEL_IDS = {
|
||||
@@ -30,6 +31,7 @@ export const BUILTIN_PANEL_IDS = {
|
||||
attributes: "attributes",
|
||||
objectInfo: "object-info",
|
||||
site: "site",
|
||||
roomBalance: "room-balance",
|
||||
} as const;
|
||||
|
||||
// Werkzeug-Palette (Symbol + Name je Werkzeug, ArchiCAD/Vectorworks-Stil).
|
||||
@@ -79,3 +81,11 @@ registerPanel({
|
||||
hasDisplayMode: false,
|
||||
render: () => <SitePanel />,
|
||||
});
|
||||
|
||||
// Flächenbilanz (SIA 416) — Raumliste + Kategorie-Summen + CSV-Export.
|
||||
registerPanel({
|
||||
id: BUILTIN_PANEL_IDS.roomBalance,
|
||||
title: "nav.roomBalance",
|
||||
hasDisplayMode: false,
|
||||
render: () => <RoomBalancePanel />,
|
||||
});
|
||||
|
||||
@@ -22,6 +22,8 @@ import type {
|
||||
LayerCategory,
|
||||
LineStyle,
|
||||
Project,
|
||||
SiaCategory,
|
||||
StairShape,
|
||||
VerticalAnchor,
|
||||
WallReferenceLine,
|
||||
} from "../model/types";
|
||||
@@ -156,6 +158,54 @@ export interface PanelHostValue {
|
||||
/** Setzt/entfernt die OK-Bindung (`null` = UK + height). */
|
||||
onSetWallTop: (anchor: VerticalAnchor | null) => void;
|
||||
|
||||
// ── Decken-Attribute (Object-Info-Panel; wirken NUR auf die selektierte Decke) ─
|
||||
/** Weist der Decke einen Aufbau-Typ-Preset zu (wie WallType). */
|
||||
onSetCeilingType: (wallTypeId: string) => void;
|
||||
/** Setzt die Gesamtdicke der Decke (Meter, thickness-Übersteuerung). */
|
||||
onSetCeilingThickness: (thickness: number) => void;
|
||||
/** Setzt/entfernt die OK-Bindung der Decke (`null` = Geschoss-Oberkante). */
|
||||
onSetCeilingTop: (anchor: VerticalAnchor | null) => void;
|
||||
|
||||
// ── Öffnungs-Attribute (Object-Info-Panel; nur die selektierte Öffnung) ─
|
||||
/** Wechselt die Art (Fenster/Tür); setzt bei Tür die Tür-Defaults. */
|
||||
onSetOpeningKind: (kind: "window" | "door") => void;
|
||||
/** Setzt die Öffnungsbreite (Meter). */
|
||||
onSetOpeningWidth: (width: number) => void;
|
||||
/** Setzt die Öffnungshöhe (Meter). */
|
||||
onSetOpeningHeight: (height: number) => void;
|
||||
/** Setzt die Brüstungshöhe (Meter; nur Fenster sinnvoll). */
|
||||
onSetOpeningSill: (sillHeight: number) => void;
|
||||
/** Setzt die Position entlang der Wandachse (Meter ab Wandanfang). */
|
||||
onSetOpeningPosition: (position: number) => void;
|
||||
/** Setzt den Türöffnungswinkel (Grad). */
|
||||
onSetOpeningSwingAngle: (swingAngle: number) => void;
|
||||
/** Setzt den Anschlag-Pfosten der Tür. */
|
||||
onSetOpeningHinge: (hinge: "start" | "end") => void;
|
||||
/** Setzt die Aufschlagseite der Tür. */
|
||||
onSetOpeningSwing: (swing: "left" | "right") => void;
|
||||
/** Setzt die Aufschlagrichtung der Tür (innen/außen). */
|
||||
onSetOpeningDir: (dir: "in" | "out") => void;
|
||||
|
||||
// ── Treppen-Attribute (Object-Info-Panel; nur die selektierte Treppe) ───
|
||||
/** Setzt die Grundform (gerade/L/Wendel). */
|
||||
onSetStairShape: (shape: StairShape) => void;
|
||||
/** Setzt die Laufbreite (Meter). */
|
||||
onSetStairWidth: (width: number) => void;
|
||||
/** Setzt die Stufenanzahl (Setzstufen, ≥ 2). */
|
||||
onSetStairSteps: (stepCount: number) => void;
|
||||
/** Setzt die Gesamt-Steighöhe (Meter, OKFF → OKFF). */
|
||||
onSetStairRise: (totalRise: number) => void;
|
||||
/** Setzt die Laufrichtung (aufwärts/abwärts). */
|
||||
onSetStairUp: (up: boolean) => void;
|
||||
|
||||
// ── Raum-Attribute (Object-Info-Panel; nur der selektierte Raum) ────────
|
||||
/** Setzt den Raum-Namen. */
|
||||
onSetRoomName: (name: string) => void;
|
||||
/** Setzt die SIA-416-Blatt-Kategorie des Raums. */
|
||||
onSetRoomSia: (category: SiaCategory) => void;
|
||||
/** Öffnet den Rich-Text-Editor des Raum-Stempels (per ID). */
|
||||
onEditRoomStamp: (roomId: string) => void;
|
||||
|
||||
// ── Site-/Kontext-Schicht (SitePanel) ───────────────────────────────────
|
||||
/** Aktuelle Kontext-Objekte (Meshes/Konturen/Gelände) aus `project.context`. */
|
||||
contextObjects: ContextObject[];
|
||||
|
||||
@@ -60,7 +60,7 @@ const DEFAULT_LEFT_GROUPS: DockGroup[] = [
|
||||
{ tabs: ["attributes"], activeTab: "attributes", weight: 1.1 },
|
||||
];
|
||||
const DEFAULT_RIGHT_GROUPS: DockGroup[] = [
|
||||
{ tabs: ["object-info"], activeTab: "object-info", weight: 1 },
|
||||
{ tabs: ["object-info", "room-balance"], activeTab: "object-info", weight: 1 },
|
||||
{
|
||||
tabs: ["drawing-levels", "layers", "site"],
|
||||
activeTab: "drawing-levels",
|
||||
|
||||
+1108
-146
File diff suppressed because it is too large
Load Diff
+498
-11
@@ -6,22 +6,36 @@
|
||||
// Öffnungen) — exakt, schnell, sauber.
|
||||
|
||||
import type {
|
||||
Ceiling,
|
||||
Drawing2D,
|
||||
HatchPattern,
|
||||
LayerCategory,
|
||||
Opening,
|
||||
Project,
|
||||
Room,
|
||||
Stair,
|
||||
Vec2,
|
||||
Wall,
|
||||
} from "../model/types";
|
||||
import {
|
||||
flattenCategories,
|
||||
getCeilingType,
|
||||
getComponent,
|
||||
getHatch,
|
||||
getLineStyle,
|
||||
getWallType,
|
||||
openingsOfWall,
|
||||
roomsOfFloor,
|
||||
stairsOfFloor,
|
||||
wallTypeThickness,
|
||||
} from "../model/types";
|
||||
import { wallReferenceOffset } from "../model/wall";
|
||||
import { evaluateRoom, siaLabel } from "../geometry/roomArea";
|
||||
import type { RichTextDoc } from "../text/richText";
|
||||
import { docFromText } from "../text/richText";
|
||||
import { roomStampToDoc, roomStampExtraLines } from "../model/roomStamp";
|
||||
import { stairVerticalExtent, wallReferenceOffset } from "../model/wall";
|
||||
import { stairGeometry, stairCut } from "../geometry/stair";
|
||||
import { doorSymbol, openingInterval, windowSymbol } from "../geometry/opening";
|
||||
import {
|
||||
add,
|
||||
along,
|
||||
@@ -140,6 +154,17 @@ export type Primitive =
|
||||
* geschlossene 2D-Form ist (für die Links-Klick-Auswahl der Fläche).
|
||||
*/
|
||||
drawingId?: string;
|
||||
/**
|
||||
* ID der Decke (Ceiling), falls dieses Polygon eine Decken-Fläche/-Umriss
|
||||
* ist (für die Links-Klick-Auswahl der Decke).
|
||||
*/
|
||||
ceilingId?: string;
|
||||
/** ID der Öffnung (Fenster/Tür), z. B. der Fensterrahmen (Auswahl). */
|
||||
openingId?: string;
|
||||
/** ID der Treppe (Stair), falls dieses Polygon ein Tritt/Podest ist (Auswahl). */
|
||||
stairId?: string;
|
||||
/** ID des Raums (Room), falls dieses Polygon eine Raum-Fläche ist (Auswahl). */
|
||||
roomId?: string;
|
||||
}
|
||||
| {
|
||||
kind: "line";
|
||||
@@ -154,6 +179,10 @@ export type Primitive =
|
||||
color?: string;
|
||||
/** ID des 2D-Zeichenelements (für Links-Klick-Auswahl von Drawing2D). */
|
||||
drawingId?: string;
|
||||
/** ID der Öffnung (für Links-Klick-Auswahl von Fenster/Tür-Symbolen). */
|
||||
openingId?: string;
|
||||
/** ID der Treppe (für Links-Klick-Auswahl von Treppen-Symbollinien). */
|
||||
stairId?: string;
|
||||
greyed?: boolean;
|
||||
}
|
||||
| {
|
||||
@@ -167,6 +196,30 @@ export type Primitive =
|
||||
weightMm: number;
|
||||
/** Strichmuster in mm Papier; null/undefined = durchgezogen. */
|
||||
dash?: number[] | null;
|
||||
/** ID der Öffnung (für Links-Klick-Auswahl von Tür-Schwenkbögen). */
|
||||
openingId?: string;
|
||||
greyed?: boolean;
|
||||
}
|
||||
| {
|
||||
kind: "text";
|
||||
/** Ankerpunkt im Modell (Meter). */
|
||||
at: Vec2;
|
||||
/**
|
||||
* Frei editierbarer Stempel-Text (Rich-Text). Wird zuerst gezeichnet;
|
||||
* `align` je Absatz steuert die Ausrichtung.
|
||||
*/
|
||||
doc: RichTextDoc;
|
||||
/**
|
||||
* LIVE-Zusatzzeilen (z. B. Fläche „24.30 m²" + SIA-Tag), UNTER dem Doc
|
||||
* gezeichnet — unabhängig vom editierten Text, stets aktuell.
|
||||
*/
|
||||
extraLines: string[];
|
||||
/** Basis-Schriftgrösse in PUNKT (für Runs ohne eigene Grösse + Zusatzzeilen). */
|
||||
basePt: number;
|
||||
/** Textfarbe (hex) als Default. */
|
||||
color: string;
|
||||
/** ID des Raums (Room), zu dem dieser Stempel gehört (Auswahl). */
|
||||
roomId?: string;
|
||||
greyed?: boolean;
|
||||
};
|
||||
|
||||
@@ -211,6 +264,8 @@ export function generatePlan(
|
||||
mono = false,
|
||||
): Plan {
|
||||
const primitives: Primitive[] = [];
|
||||
// Aktives Geschoss (für die Grundriss-Schnitthöhe der Treppen).
|
||||
const floor = project.drawingLevels.find((z) => z.id === floorId);
|
||||
// Sichtbarkeit wie bisher; der Darstellungsmodus verfeinert sie zusätzlich:
|
||||
// render=false blendet die Wand aus, greyed=true dimmt sie.
|
||||
const walls = project.walls.filter(
|
||||
@@ -235,20 +290,52 @@ export function generatePlan(
|
||||
categoryDisplay(d.categoryCode).render,
|
||||
);
|
||||
|
||||
// Decken dieses Geschosses (wie Wände nach sichtbaren Kategorien +
|
||||
// Darstellungsmodus gefiltert). Eine Decke liegt (als Slab) über der
|
||||
// Schnittebene → im Grundriss als gefüllter/schraffierter Umriss dargestellt,
|
||||
// UNTER der Wand-Poché (damit die Wände darüber lesbar bleiben).
|
||||
const ceilings = (project.ceilings ?? []).filter(
|
||||
(c) =>
|
||||
c.floorId === floorId &&
|
||||
visibleCodes.has(c.categoryCode) &&
|
||||
categoryDisplay(c.categoryCode).render,
|
||||
);
|
||||
for (const ceiling of ceilings) {
|
||||
const greyed = categoryDisplay(ceiling.categoryCode).greyed;
|
||||
const lwMm = lwByCode.get(ceiling.categoryCode) ?? WALL_FALLBACK_MM;
|
||||
addCeilingPoche(primitives, project, ceiling, greyed, detail, lwMm);
|
||||
}
|
||||
|
||||
// Räume (SIA-416-Flächen) dieses Geschosses: transluzente Farbfüllung + Stempel
|
||||
// (Name + Fläche + SIA-Tag). UNTER der Wand-Poché gezeichnet, damit die Wände
|
||||
// lesbar bleiben. Der Stempel (Text) entfällt beim Detailgrad „grob".
|
||||
const rooms = roomsOfFloor(project, floorId).filter(
|
||||
(r) =>
|
||||
visibleCodes.has(r.categoryCode) && categoryDisplay(r.categoryCode).render,
|
||||
);
|
||||
for (const room of rooms) {
|
||||
const greyed = categoryDisplay(room.categoryCode).greyed;
|
||||
addRoomArea(primitives, room, greyed, detail);
|
||||
}
|
||||
|
||||
// Gehrungs-Schnittlinien einmal pro Plan berechnen (auf der gefilterten Menge).
|
||||
const joins = computeJoins(project, walls);
|
||||
|
||||
for (const wall of walls) {
|
||||
const wallGreyed = categoryDisplay(wall.categoryCode).greyed;
|
||||
const wallLwMm = lwByCode.get(wall.categoryCode) ?? WALL_FALLBACK_MM;
|
||||
// Türen am Wandhost: die Öffnung bestimmt immer die Wandgeometrie.
|
||||
// Türen (Legacy) + Öffnungen (Fenster/Türen) am Wandhost: BEIDE sparen die
|
||||
// Wand-Poché aus. Aus beiden wird eine gemeinsame Lücken-Intervall-Liste.
|
||||
const doors = project.doors.filter((d) => d.hostWallId === wall.id);
|
||||
const openings = openingsOfWall(project, wall.id);
|
||||
const gaps = wallGaps(wall, doors, openings);
|
||||
const cuts = joins.get(wall.id) ?? { startCut: null, endCut: null };
|
||||
addWallPoche(primitives, project, wall, doors, cuts, wallGreyed, detail, wallLwMm);
|
||||
addWallPoche(primitives, project, wall, gaps, cuts, wallGreyed, detail, wallLwMm);
|
||||
// Referenzlinie: dünne gestrichelte Wand-Achslinie (von der Oberleiste
|
||||
// umschaltbar). Wird über der Poché gezeichnet, damit sie sichtbar bleibt.
|
||||
if (showReferenceLines) addReferenceLine(primitives, wall, wallGreyed);
|
||||
// Tür-Symbol nur, wenn die Tür-Kategorie sichtbar ist und der Modus sie zeigt.
|
||||
// Tür-Symbol (Legacy-Door) nur, wenn die Kategorie sichtbar ist und der Modus
|
||||
// sie zeigt.
|
||||
for (const door of doors) {
|
||||
const d = categoryDisplay(door.categoryCode);
|
||||
if (visibleCodes.has(door.categoryCode) && d.render) {
|
||||
@@ -256,6 +343,28 @@ export function generatePlan(
|
||||
addDoorSymbol(primitives, wall, door, d.greyed, detail, doorLwMm);
|
||||
}
|
||||
}
|
||||
// Öffnungs-Symbole (Fenster/Tür) je Detailgrad + Auswahl-taggbar.
|
||||
for (const o of openings) {
|
||||
const d = categoryDisplay(o.categoryCode);
|
||||
if (visibleCodes.has(o.categoryCode) && d.render) {
|
||||
const oLwMm = lwByCode.get(o.categoryCode) ?? LAYER_LINE_MM;
|
||||
addOpeningSymbol(primitives, project, wall, o, d.greyed, detail, oLwMm);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Treppen dieses Geschosses: Tritte + Lauflinie + Auf-/Abpfeil, mit dem
|
||||
// Grundriss-Schnitt (Tritte unter der Schnitthöhe durchgezogen, darüber
|
||||
// gestrichelt/verdeckt + SIA-Bruchlinie). Über der Wand-Poché gezeichnet.
|
||||
const cutHeight = floor?.cutHeight ?? 1.0;
|
||||
const stairs = stairsOfFloor(project, floorId).filter(
|
||||
(s) =>
|
||||
visibleCodes.has(s.categoryCode) && categoryDisplay(s.categoryCode).render,
|
||||
);
|
||||
for (const stair of stairs) {
|
||||
const greyed = categoryDisplay(stair.categoryCode).greyed;
|
||||
const lwMm = lwByCode.get(stair.categoryCode) ?? WALL_FALLBACK_MM;
|
||||
addStairSymbol(primitives, project, stair, greyed, lwMm, cutHeight);
|
||||
}
|
||||
|
||||
// 2D-Zeichengeometrie zuletzt (über der Poché).
|
||||
@@ -274,7 +383,10 @@ export function generatePlan(
|
||||
// Musterlinien/Striche schwarz — so entsteht ein reiner S/W-Plan.
|
||||
if (mono) toMono(primitives);
|
||||
|
||||
return { primitives, bounds: computeBounds(project, walls, drawings) };
|
||||
return {
|
||||
primitives,
|
||||
bounds: computeBounds(project, walls, drawings, ceilings, stairs, rooms),
|
||||
};
|
||||
}
|
||||
|
||||
/** Dezente Plan-Farbe der Kontext-Konturen (gedämpftes Grau). */
|
||||
@@ -332,6 +444,8 @@ function toMono(primitives: Primitive[]): void {
|
||||
p.stroke = MONO_INK;
|
||||
} else if (p.kind === "line") {
|
||||
p.color = MONO_INK;
|
||||
} else if (p.kind === "text") {
|
||||
p.color = MONO_INK;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -452,11 +566,40 @@ function addDrawing2D(
|
||||
* joinPriority) mit dicker Umrisslinie. Bewusst vereinfacht (≙ DOSSIER
|
||||
* „Einfach").
|
||||
*/
|
||||
/**
|
||||
* Ein Öffnungs-Intervall entlang der Wandachse (from..to in Metern). `headHeight`
|
||||
* = Sturzhöhe (OK der Öffnung über der Wand-UK) für die 3D-Aussparung; im Plan
|
||||
* ungenutzt, aber Teil des gemeinsamen Vertrags mit dem 3D-Pfad.
|
||||
*/
|
||||
export interface WallGap {
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vereint Legacy-Türen (`Door`) und Öffnungen (`Opening`) einer Wand zu einer
|
||||
* sortierten Lücken-Intervall-Liste entlang der Wandachse. Entartete Intervalle
|
||||
* (Breite ≤ 0 oder außerhalb der Achse) werden verworfen.
|
||||
*/
|
||||
export function wallGaps(
|
||||
wall: Wall,
|
||||
doors: Project["doors"],
|
||||
openings: Opening[],
|
||||
): WallGap[] {
|
||||
const gaps: WallGap[] = [];
|
||||
for (const d of doors) gaps.push({ from: d.position, to: d.position + d.width });
|
||||
for (const o of openings) {
|
||||
const iv = openingInterval(wall, o);
|
||||
if (iv) gaps.push(iv);
|
||||
}
|
||||
return gaps.sort((a, b) => a.from - b.from);
|
||||
}
|
||||
|
||||
function addWallPoche(
|
||||
out: Primitive[],
|
||||
project: Project,
|
||||
wall: Wall,
|
||||
doors: Project["doors"],
|
||||
gaps: WallGap[],
|
||||
cuts: WallCuts,
|
||||
greyed: boolean,
|
||||
detail: DetailLevel,
|
||||
@@ -477,10 +620,8 @@ function addWallPoche(
|
||||
// Schichtfugen), nicht die Schicht-Füllfarben/Schraffuren.
|
||||
const stroke = wall.color ?? POCHE_STROKE;
|
||||
|
||||
// Öffnungs-Intervalle entlang der Wandachse, sortiert.
|
||||
const openings = doors
|
||||
.map((d) => ({ from: d.position, to: d.position + d.width }))
|
||||
.sort((a, b) => a.from - b.from);
|
||||
// Öffnungs-Intervalle entlang der Wandachse, sortiert (bereits vereint).
|
||||
const openings = gaps;
|
||||
|
||||
// Wand in Segmente zwischen den Öffnungen zerlegen.
|
||||
let cursor = 0;
|
||||
@@ -634,6 +775,105 @@ function addDoorSymbol(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Öffnungs-Symbol (Fenster/Tür) je Detailgrad. Anders als das Legacy-`addDoorSymbol`
|
||||
* arbeitet diese Funktion über die reinen Geometrie-Helfer (opening.ts) und taggt
|
||||
* ihre Primitive mit `openingId`, sodass die Öffnung im Plan selektierbar ist.
|
||||
*
|
||||
* Tür:
|
||||
* • grob — gerade Türblatt-Linie (die Lücke kommt aus der ausgesparten Poché).
|
||||
* • mittel — + Schwenkbogen (Öffnungswinkel).
|
||||
* • fein — + Anschlag-Striche an beiden Pfosten.
|
||||
* Fenster:
|
||||
* • grob — eine einzelne Glaslinie über die Lücke.
|
||||
* • mittel — Rahmen-Rechteck (Umriss) + eine Glaslinie.
|
||||
* • fein — Rahmen + zwei Glaslinien (Doppelverglasung angedeutet).
|
||||
*/
|
||||
function addOpeningSymbol(
|
||||
out: Primitive[],
|
||||
project: Project,
|
||||
wall: Wall,
|
||||
o: Opening,
|
||||
greyed: boolean,
|
||||
detail: DetailLevel,
|
||||
lwMm: number,
|
||||
): void {
|
||||
if (o.kind === "door") {
|
||||
const sym = doorSymbol(wall, o);
|
||||
if (!sym) return;
|
||||
// Türblatt (alle Stufen).
|
||||
out.push({
|
||||
kind: "line",
|
||||
a: sym.hinge,
|
||||
b: sym.openEnd,
|
||||
cls: "door-leaf",
|
||||
weightMm: lwMm,
|
||||
greyed,
|
||||
openingId: o.id,
|
||||
});
|
||||
// mittel + fein: Schwenkbogen von geschlossen → offen.
|
||||
if (detail !== "grob") {
|
||||
out.push({
|
||||
kind: "arc",
|
||||
center: sym.hinge,
|
||||
from: sym.closedEnd,
|
||||
to: sym.openEnd,
|
||||
r: sym.radius,
|
||||
cls: "door-swing",
|
||||
weightMm: lwMm * 0.6,
|
||||
dash: [0.06, 0.04],
|
||||
greyed,
|
||||
openingId: o.id,
|
||||
});
|
||||
}
|
||||
// fein: Anschlag-Striche an beiden Pfosten (quer zur Wand).
|
||||
if (detail === "fein") {
|
||||
const reveal = 0.06;
|
||||
for (const jamb of [sym.jambStart, sym.jambEnd]) {
|
||||
out.push({
|
||||
kind: "line",
|
||||
a: add(jamb, scale(sym.normal, reveal)),
|
||||
b: add(jamb, scale(sym.normal, -reveal)),
|
||||
cls: "door-frame",
|
||||
weightMm: lwMm,
|
||||
greyed,
|
||||
openingId: o.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Fenster: Rahmen (mittel/fein) + Glaslinie(n) je Detailgrad.
|
||||
const glassCount = detail === "fein" ? 2 : 1;
|
||||
const sym = windowSymbol(project, wall, o, glassCount);
|
||||
if (!sym) return;
|
||||
if (detail !== "grob") {
|
||||
// Rahmen-Rechteck als reine Umrisslinie (Poché-Stroke, keine Füllung).
|
||||
out.push({
|
||||
kind: "polygon",
|
||||
pts: sym.frame,
|
||||
fill: "none",
|
||||
stroke: o.color ?? POCHE_STROKE,
|
||||
strokeWidthMm: lwMm,
|
||||
hatch: NO_HATCH,
|
||||
greyed,
|
||||
openingId: o.id,
|
||||
});
|
||||
}
|
||||
for (const [a, b] of sym.glassLines) {
|
||||
out.push({
|
||||
kind: "line",
|
||||
a,
|
||||
b,
|
||||
cls: "window-glass",
|
||||
weightMm: lwMm * 0.7,
|
||||
greyed,
|
||||
openingId: o.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Schraffur-Platzhalter „ohne" (für reine Umriss-/Sammelflächen). */
|
||||
const NO_HATCH: HatchRender = {
|
||||
pattern: "none",
|
||||
@@ -660,9 +900,248 @@ function addReferenceLine(out: Primitive[], wall: Wall, greyed: boolean): void {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Decken-Poché im Grundriss: die Decke liegt als Slab ÜBER der Schnittebene und
|
||||
* wird daher als geschlossener Umriss ihres Grundriss-Polygons gezeichnet — eine
|
||||
* gefüllte + schraffierte Fläche plus eine kräftige Umrisslinie. Fill/Schraffur
|
||||
* stammen aus dem BAUTEIL der ersten (obersten) Schicht des Aufbau-Typs (analog
|
||||
* zur Wand-Schichtauflösung); die Umrissfarbe aus der optionalen Decken-Farbe
|
||||
* bzw. dem Poché-Strich. Jedes Polygon trägt die `ceilingId` für die Auswahl.
|
||||
*
|
||||
* Im Detailgrad „grob" entfällt die Schraffur (nur Fläche + Umriss); „mittel"/
|
||||
* „fein" zeigen die Component-Schraffur. `lwMm` ist die Kategorie-Strichstärke.
|
||||
*/
|
||||
function addCeilingPoche(
|
||||
out: Primitive[],
|
||||
project: Project,
|
||||
ceiling: Ceiling,
|
||||
greyed: boolean,
|
||||
detail: DetailLevel,
|
||||
lwMm: number,
|
||||
): void {
|
||||
const pts = ceiling.outline;
|
||||
if (pts.length < 3) return;
|
||||
const wt = getCeilingType(project, ceiling);
|
||||
const comp = wt.layers.length > 0 ? getComponent(project, wt.layers[0].componentId) : null;
|
||||
const stroke = ceiling.color ?? POCHE_STROKE;
|
||||
const outlineMm = lwMm * OUTLINE_DETAIL_FACTOR[detail];
|
||||
const ceilingId = ceiling.id;
|
||||
|
||||
// Gefüllte + schraffierte Fläche (Component-Poché). Im Detailgrad „grob" ohne
|
||||
// Schraffur (reine Füllung), sonst die Bauteil-Schraffur.
|
||||
out.push({
|
||||
kind: "polygon",
|
||||
pts,
|
||||
fill: comp ? comp.color : "none",
|
||||
stroke,
|
||||
strokeWidthMm: LAYER_LINE_MM * LAYER_DETAIL_FACTOR[detail],
|
||||
hatch: comp && detail !== "grob" ? resolveHatch(project, comp.hatchId) : NO_HATCH,
|
||||
greyed,
|
||||
ceilingId,
|
||||
});
|
||||
// Kräftige Umrisslinie über die volle Decke (nur Kontur, keine Füllung).
|
||||
out.push({
|
||||
kind: "polygon",
|
||||
pts,
|
||||
fill: "none",
|
||||
stroke,
|
||||
strokeWidthMm: outlineMm,
|
||||
hatch: NO_HATCH,
|
||||
greyed,
|
||||
ceilingId,
|
||||
});
|
||||
}
|
||||
|
||||
/** Deckkraft (0..255) der transluzenten Raum-Füllung → 2-stelliges Hex-Suffix. */
|
||||
const ROOM_FILL_ALPHA = "33"; // ~20 % Deckkraft
|
||||
/** Basis-Schriftgrösse des Raum-Stempels in Punkt (pt). */
|
||||
const ROOM_STAMP_PT = 10;
|
||||
|
||||
/** Farbe mit Alpha-Suffix versehen (nur, wenn `color` ein 6-stelliges Hex ist). */
|
||||
function withAlpha(color: string, alphaHex: string): string {
|
||||
return /^#[0-9a-fA-F]{6}$/.test(color) ? `${color}${alphaHex}` : color;
|
||||
}
|
||||
|
||||
/**
|
||||
* Raum (SIA-416-Fläche) im Grundriss: eine transluzente farbige Füllfläche über
|
||||
* dem lichten Umriss + eine dünne Umrisslinie (beide `roomId`-getaggt für die
|
||||
* Auswahl) + ein Raum-Stempel (Text) am Anker (Centroid oder `stampAnchor`) mit
|
||||
* Name, Fläche „24.30 m²" und SIA-Kürzel. Der Stempel entfällt beim Detailgrad
|
||||
* „grob"; Fläche/Umfang/Schwerpunkt werden aus `boundary` abgeleitet (nie
|
||||
* gespeichert). Wird UNTER der Wand-Poché gezeichnet.
|
||||
*/
|
||||
function addRoomArea(
|
||||
out: Primitive[],
|
||||
room: Room,
|
||||
greyed: boolean,
|
||||
detail: DetailLevel,
|
||||
): void {
|
||||
const pts = room.boundary;
|
||||
if (pts.length < 3) return;
|
||||
const res = evaluateRoom(pts, room.siaCategory);
|
||||
const stroke = room.color;
|
||||
|
||||
// Transluzente Farbfüllung.
|
||||
out.push({
|
||||
kind: "polygon",
|
||||
pts,
|
||||
fill: withAlpha(room.color, ROOM_FILL_ALPHA),
|
||||
stroke,
|
||||
strokeWidthMm: LAYER_LINE_MM * LAYER_DETAIL_FACTOR[detail],
|
||||
hatch: NO_HATCH,
|
||||
greyed,
|
||||
roomId: room.id,
|
||||
});
|
||||
|
||||
// Raum-Stempel: frei editierbarer Rich-Text (Name/Notizen) + LIVE-Flächenzeile
|
||||
// + SIA-Kürzel. Anker fix (stampAnchor, sonst Centroid). Nur ab Detailgrad
|
||||
// „mittel". Die Flächenzeile aktualisiert sich unabhängig vom editierten Text.
|
||||
if (detail !== "grob") {
|
||||
const at = room.stampAnchor ?? res.centroid;
|
||||
// Strukturierter Stempel (Feldmodell) hat Vorrang; sonst Alt-Pfad (Rich-Text
|
||||
// bzw. Raum-Name als einfacher Text).
|
||||
const doc = room.stamp
|
||||
? roomStampToDoc(room.stamp)
|
||||
: room.stampDoc ?? docFromText(room.name);
|
||||
const extraLines = room.stamp
|
||||
? roomStampExtraLines(room.stamp, {
|
||||
netArea: res.netArea,
|
||||
siaCategory: room.siaCategory,
|
||||
siaLabel: siaLabel(room.siaCategory),
|
||||
})
|
||||
: [
|
||||
`${res.netArea.toFixed(2)} m²`,
|
||||
`${room.siaCategory} · ${siaLabel(room.siaCategory)}`,
|
||||
];
|
||||
out.push({
|
||||
kind: "text",
|
||||
at,
|
||||
doc,
|
||||
extraLines,
|
||||
basePt: ROOM_STAMP_PT,
|
||||
color: stroke,
|
||||
roomId: room.id,
|
||||
greyed,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Treppen-Symbol im Grundriss (DOSSIER/SIA):
|
||||
* • Tritte als Rechtecke: die Tritt-Trennlinien werden gezeichnet — unter der
|
||||
* Schnittebene DURCHGEZOGEN (sichtbar), darüber GESTRICHELT (verdeckt).
|
||||
* • Der erste Tritt oberhalb der Schnittebene trägt die diagonale SIA-Bruchlinie
|
||||
* (Doppelstrich quer über den Lauf).
|
||||
* • Zwischenpodest (L/Wendel) als Umriss-Polygon.
|
||||
* • Lauflinie (Lauflinie) mit Auf-/Abpfeil (Auf-/Abpfeil) und Stufenanzahl-Label.
|
||||
* Alle Primitive tragen `stairId` für die Auswahl.
|
||||
*/
|
||||
function addStairSymbol(
|
||||
out: Primitive[],
|
||||
project: Project,
|
||||
stair: Stair,
|
||||
greyed: boolean,
|
||||
lwMm: number,
|
||||
cutHeight: number,
|
||||
): void {
|
||||
const { zBottom, zTop } = stairVerticalExtent(project, stair);
|
||||
const totalRise = zTop - zBottom;
|
||||
const geo = stairGeometry(stair, totalRise);
|
||||
const cut = stairCut(geo, cutHeight);
|
||||
const below = new Set(cut.belowIndices);
|
||||
const stroke = stair.color ?? POCHE_STROKE;
|
||||
const stairId = stair.id;
|
||||
|
||||
// Umriss + Tritt-Trennlinien je Stufe. Der letzte Punkt jedes Tritt-Rechtecks
|
||||
// liefert die vordere Kante (Auftritt); wir zeichnen jede Tritt-Kante.
|
||||
for (const tr of geo.treads) {
|
||||
const isBelow = below.has(tr.index);
|
||||
// Tritt-Rechteck als reine Umrisslinie (keine Füllung), durchgezogen wenn
|
||||
// unter der Schnittebene, sonst gestrichelt (verdeckt).
|
||||
out.push({
|
||||
kind: "polygon",
|
||||
pts: tr.pts,
|
||||
fill: "none",
|
||||
stroke,
|
||||
strokeWidthMm: lwMm * 0.7,
|
||||
hatch: NO_HATCH,
|
||||
greyed,
|
||||
stairId,
|
||||
});
|
||||
// Zusätzliche Auftrittskante (vordere Kante der Stufe) etwas kräftiger.
|
||||
const front: [Vec2, Vec2] = [tr.pts[1], tr.pts[2]];
|
||||
out.push({
|
||||
kind: "line",
|
||||
a: front[0],
|
||||
b: front[1],
|
||||
cls: "stair-tread",
|
||||
weightMm: lwMm * (isBelow ? 1.0 : 0.6),
|
||||
dash: isBelow ? null : [0.12, 0.08],
|
||||
color: stroke,
|
||||
greyed,
|
||||
stairId,
|
||||
});
|
||||
}
|
||||
|
||||
// Zwischenpodest (L/Wendel-Auge): Umriss-Polygon.
|
||||
if (geo.landing && geo.landing.length >= 3) {
|
||||
out.push({
|
||||
kind: "polygon",
|
||||
pts: geo.landing,
|
||||
fill: "none",
|
||||
stroke,
|
||||
strokeWidthMm: lwMm * 0.7,
|
||||
hatch: NO_HATCH,
|
||||
greyed,
|
||||
stairId,
|
||||
});
|
||||
}
|
||||
|
||||
// SIA-Bruchlinie (Doppelstrich diagonal über den Schnitt-Tritt).
|
||||
if (cut.breakLine) {
|
||||
for (const [a, b] of cut.breakLine) {
|
||||
out.push({
|
||||
kind: "line",
|
||||
a,
|
||||
b,
|
||||
cls: "stair-break",
|
||||
weightMm: lwMm,
|
||||
color: stroke,
|
||||
greyed,
|
||||
stairId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Lauflinie (Lauflinie) — kräftig, durchgezogen.
|
||||
for (let i = 0; i < geo.runLine.length - 1; i++) {
|
||||
out.push({
|
||||
kind: "line",
|
||||
a: geo.runLine[i],
|
||||
b: geo.runLine[i + 1],
|
||||
cls: "stair-run",
|
||||
weightMm: lwMm,
|
||||
color: stroke,
|
||||
greyed,
|
||||
stairId,
|
||||
});
|
||||
}
|
||||
// Auf-/Abpfeil an der Spitze der Lauflinie.
|
||||
const [h1, tip, h2] = geo.arrow.head;
|
||||
out.push({ kind: "line", a: h1, b: tip, cls: "stair-arrow", weightMm: lwMm, color: stroke, greyed, stairId });
|
||||
out.push({ kind: "line", a: h2, b: tip, cls: "stair-arrow", weightMm: lwMm, color: stroke, greyed, stairId });
|
||||
}
|
||||
|
||||
const length = (w: Wall): number => Math.hypot(w.end.x - w.start.x, w.end.y - w.start.y);
|
||||
|
||||
function computeBounds(project: Project, walls: Wall[], drawings: Drawing2D[] = []) {
|
||||
function computeBounds(
|
||||
project: Project,
|
||||
walls: Wall[],
|
||||
drawings: Drawing2D[] = [],
|
||||
ceilings: Ceiling[] = [],
|
||||
stairs: Stair[] = [],
|
||||
rooms: Room[] = [],
|
||||
) {
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
const acc = (p: Vec2) => {
|
||||
minX = Math.min(minX, p.x); minY = Math.min(minY, p.y);
|
||||
@@ -672,6 +1151,14 @@ function computeBounds(project: Project, walls: Wall[], drawings: Drawing2D[] =
|
||||
const t = wallTypeThickness(getWallType(project, w));
|
||||
for (const c of wallCorners(w.start, w.end, t)) acc(c);
|
||||
}
|
||||
for (const c of ceilings) for (const p of c.outline) acc(p);
|
||||
for (const r of rooms) for (const p of r.boundary) acc(p);
|
||||
for (const s of stairs) {
|
||||
const { zBottom, zTop } = stairVerticalExtent(project, s);
|
||||
const geo = stairGeometry(s, zTop - zBottom);
|
||||
for (const tr of geo.treads) for (const p of tr.pts) acc(p);
|
||||
if (geo.landing) for (const p of geo.landing) acc(p);
|
||||
}
|
||||
for (const d of drawings) {
|
||||
const g = d.geom;
|
||||
if (g.shape === "line") { acc(g.a); acc(g.b); }
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Unit-Tests für die GPU-Tessellierung: Ear-Clipping (konvex + konkav) und
|
||||
* Bildschirm-Raum-Abbildung.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { triangulate } from './glPlanCompile';
|
||||
import { computeOrthoMatrix } from './glPlanRender';
|
||||
import type { Vec2 } from '../../model/types';
|
||||
|
||||
/** Summierte Dreiecksfläche (Betrag) aus Indizes über pts. */
|
||||
function triArea(pts: Vec2[], idx: number[]): number {
|
||||
let area = 0;
|
||||
for (let i = 0; i < idx.length; i += 3) {
|
||||
const a = pts[idx[i]];
|
||||
const b = pts[idx[i + 1]];
|
||||
const c = pts[idx[i + 2]];
|
||||
area += Math.abs((b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x)) / 2;
|
||||
}
|
||||
return area;
|
||||
}
|
||||
|
||||
/** Polygon-Fläche (Shoelace, Betrag). */
|
||||
function polyArea(pts: Vec2[]): number {
|
||||
let a = 0;
|
||||
for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
|
||||
a += (pts[j].x + pts[i].x) * (pts[j].y - pts[i].y);
|
||||
}
|
||||
return Math.abs(a) / 2;
|
||||
}
|
||||
|
||||
describe('triangulate', () => {
|
||||
it('trianguliert ein Quadrat (2 Dreiecke, volle Fläche)', () => {
|
||||
const sq: Vec2[] = [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 4, y: 0 },
|
||||
{ x: 4, y: 4 },
|
||||
{ x: 0, y: 4 },
|
||||
];
|
||||
const idx = triangulate(sq);
|
||||
expect(idx.length).toBe(6); // 2 Dreiecke
|
||||
expect(triArea(sq, idx)).toBeCloseTo(polyArea(sq), 6);
|
||||
});
|
||||
|
||||
it('trianguliert ein KONKAVES L-Polygon flächentreu (Fan wäre falsch)', () => {
|
||||
// L-Form: konkave Ecke bei (2,2).
|
||||
const L: Vec2[] = [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 4, y: 0 },
|
||||
{ x: 4, y: 2 },
|
||||
{ x: 2, y: 2 },
|
||||
{ x: 2, y: 4 },
|
||||
{ x: 0, y: 4 },
|
||||
];
|
||||
const idx = triangulate(L);
|
||||
// 6 Ecken → 4 Dreiecke.
|
||||
expect(idx.length).toBe(12);
|
||||
// Flächentreu: Summe der Dreiecke == Polygonfläche (12). Ein Fan über die
|
||||
// konkave Ecke würde deutlich mehr Fläche (außerhalb des L) erzeugen.
|
||||
expect(triArea(L, idx)).toBeCloseTo(polyArea(L), 6);
|
||||
expect(triArea(L, idx)).toBeCloseTo(12, 6);
|
||||
});
|
||||
|
||||
it('liefert [] bei <3 Ecken', () => {
|
||||
expect(triangulate([])).toEqual([]);
|
||||
expect(triangulate([{ x: 0, y: 0 }])).toEqual([]);
|
||||
expect(triangulate([{ x: 0, y: 0 }, { x: 1, y: 1 }])).toEqual([]);
|
||||
});
|
||||
|
||||
it('behandelt beide Wicklungsrichtungen (CW + CCW) gleich', () => {
|
||||
const cw: Vec2[] = [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 0, y: 4 },
|
||||
{ x: 4, y: 4 },
|
||||
{ x: 4, y: 0 },
|
||||
];
|
||||
const idx = triangulate(cw);
|
||||
expect(idx.length).toBe(6);
|
||||
expect(triArea(cw, idx)).toBeCloseTo(polyArea(cw), 6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeOrthoMatrix', () => {
|
||||
it('liefert eine 4×4-Matrix', () => {
|
||||
const m = computeOrthoMatrix({ x: 0, y: 0, w: 100, h: 100 }, 100, 100);
|
||||
expect(m).toBeInstanceOf(Float32Array);
|
||||
expect(m.length).toBe(16);
|
||||
});
|
||||
|
||||
it('bildet die viewBox-Ecken bei passendem Aspekt auf Clip [-1,1] ab', () => {
|
||||
// Quadratischer viewBox + quadratisches Canvas → keine Aspekt-Dehnung.
|
||||
const m = computeOrthoMatrix({ x: 0, y: 0, w: 100, h: 100 }, 100, 100);
|
||||
// Bildschirm-Punkt (0,0) = obere-linke Ecke → Clip (-1, +1).
|
||||
const clip = (x: number, y: number) => ({
|
||||
x: m[0] * x + m[4] * y + m[12],
|
||||
y: m[1] * x + m[5] * y + m[13],
|
||||
});
|
||||
const tl = clip(0, 0);
|
||||
expect(tl.x).toBeCloseTo(-1, 5);
|
||||
expect(tl.y).toBeCloseTo(1, 5);
|
||||
const br = clip(100, 100);
|
||||
expect(br.x).toBeCloseTo(1, 5);
|
||||
expect(br.y).toBeCloseTo(-1, 5);
|
||||
});
|
||||
|
||||
it('verschiedene viewBoxes → verschiedene Matrizen', () => {
|
||||
const a = computeOrthoMatrix({ x: 0, y: 0, w: 100, h: 100 }, 100, 100);
|
||||
const b = computeOrthoMatrix({ x: 50, y: 50, w: 200, h: 200 }, 100, 100);
|
||||
expect([...a].every((v, i) => v === b[i])).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* Tessellierung: wandelt Plan-`Primitive` in GPU-fertige Puffer.
|
||||
* • Polygone → echtes Ear-Clipping (konkav-fähig), Bildschirm-Raum-Dreiecke
|
||||
* • Linien → Quad mit Normale + Seiten-Flag (bildschirmkonstante Breite)
|
||||
*
|
||||
* Koordinaten: alles wird in BILDSCHIRM-Raum abgelegt (wie `toScreen`):
|
||||
* sx = mx·PX_PER_M, sy = -my·PX_PER_M.
|
||||
* Damit ist die Projektion eine reine viewBox-Orthografie (siehe glPlanRender).
|
||||
*/
|
||||
|
||||
import type { Primitive } from '../generatePlan';
|
||||
import type { Vec2 } from '../../model/types';
|
||||
import type { GpuGeometry, Rgba } from './glPlanTypes';
|
||||
|
||||
const PX_PER_M = 90;
|
||||
|
||||
/** Modell-Meter → Bildschirm-Raum (identisch zu PlanView.toScreen). */
|
||||
function toScreen(p: Vec2): Vec2 {
|
||||
return { x: p.x * PX_PER_M, y: -p.y * PX_PER_M };
|
||||
}
|
||||
|
||||
/** Signierte Fläche (Shoelace); >0 = CCW (Modell-Y nach oben). */
|
||||
function signedArea(pts: Vec2[]): number {
|
||||
let a = 0;
|
||||
for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
|
||||
a += pts[j].x * pts[i].y - pts[i].x * pts[j].y;
|
||||
}
|
||||
return a / 2;
|
||||
}
|
||||
|
||||
/** Kreuzprodukt (b-a)×(c-a). */
|
||||
function cross(a: Vec2, b: Vec2, c: Vec2): number {
|
||||
return (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
|
||||
}
|
||||
|
||||
/** Liegt p im (a,b,c)-Dreieck? (CCW-orientiert). */
|
||||
function pointInTri(a: Vec2, b: Vec2, c: Vec2, p: Vec2): boolean {
|
||||
const d1 = cross(a, b, p);
|
||||
const d2 = cross(b, c, p);
|
||||
const d3 = cross(c, a, p);
|
||||
const hasNeg = d1 < 0 || d2 < 0 || d3 < 0;
|
||||
const hasPos = d1 > 0 || d2 > 0 || d3 > 0;
|
||||
return !(hasNeg && hasPos);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ear-Clipping-Triangulierung eines einfachen (lochfreien) Polygons. Robust für
|
||||
* konvexe UND konkave Ringe. O(n²) — für Plan-Polygone (wenige Ecken) völlig
|
||||
* ausreichend. Gibt Dreiecks-Indizes (0-basiert auf `pts`) zurück; [] bei <3
|
||||
* Ecken oder Degeneration.
|
||||
*/
|
||||
export function triangulate(pts: Vec2[]): number[] {
|
||||
const n = pts.length;
|
||||
if (n < 3) return [];
|
||||
|
||||
// Ohr-Test unten nutzt cross>0 = konvex, was CCW voraussetzt. Bei CW-Polygonen
|
||||
// die Index-Reihenfolge umdrehen (Triangulierung ist raum-affin-invariant, die
|
||||
// Indizes gelten danach auch für die Bildschirm-Raum-Vertices).
|
||||
const idx: number[] = [];
|
||||
for (let i = 0; i < n; i++) idx.push(i);
|
||||
if (signedArea(pts) < 0) idx.reverse(); // <0 = CW → auf CCW drehen
|
||||
|
||||
const tris: number[] = [];
|
||||
let guard = 0;
|
||||
const maxGuard = n * n + 16;
|
||||
|
||||
while (idx.length > 3 && guard++ < maxGuard) {
|
||||
let clipped = false;
|
||||
for (let i = 0; i < idx.length; i++) {
|
||||
const iPrev = idx[(i + idx.length - 1) % idx.length];
|
||||
const iCur = idx[i];
|
||||
const iNext = idx[(i + 1) % idx.length];
|
||||
const a = pts[iPrev];
|
||||
const b = pts[iCur];
|
||||
const c = pts[iNext];
|
||||
|
||||
// Konvexe Ecke? (bei CCW: cross > 0)
|
||||
if (cross(a, b, c) <= 0) continue;
|
||||
|
||||
// Kein anderer Vertex im Ohr?
|
||||
let contains = false;
|
||||
for (let k = 0; k < idx.length; k++) {
|
||||
const vi = idx[k];
|
||||
if (vi === iPrev || vi === iCur || vi === iNext) continue;
|
||||
if (pointInTri(a, b, c, pts[vi])) {
|
||||
contains = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (contains) continue;
|
||||
|
||||
// Ohr abschneiden.
|
||||
tris.push(iPrev, iCur, iNext);
|
||||
idx.splice(i, 1);
|
||||
clipped = true;
|
||||
break;
|
||||
}
|
||||
if (!clipped) break; // Degeneriert → abbrechen (kein Absturz).
|
||||
}
|
||||
if (idx.length === 3) tris.push(idx[0], idx[1], idx[2]);
|
||||
return tris;
|
||||
}
|
||||
|
||||
/**
|
||||
* Farbe → RGBA[0..1]; ungültig/"none"/"transparent" → null.
|
||||
* Unterstützt "#rgb", "#rrggbb", "#rrggbbaa" und "rgb()/rgba()".
|
||||
*/
|
||||
function parseColor(hex: string | undefined): Rgba | null {
|
||||
if (!hex) return null;
|
||||
const s = hex.trim().toLowerCase();
|
||||
if (s === 'none' || s === 'transparent') return null;
|
||||
|
||||
if (s.startsWith('rgb')) {
|
||||
const m = s.match(/[\d.]+/g);
|
||||
if (!m || m.length < 3) return null;
|
||||
const a = m.length >= 4 ? parseFloat(m[3]) : 1;
|
||||
return [+m[0] / 255, +m[1] / 255, +m[2] / 255, a > 1 ? a / 255 : a];
|
||||
}
|
||||
|
||||
let h = s[0] === '#' ? s.slice(1) : s;
|
||||
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
|
||||
if (h.length !== 6 && h.length !== 8) return null;
|
||||
const r = parseInt(h.slice(0, 2), 16);
|
||||
const g = parseInt(h.slice(2, 4), 16);
|
||||
const b = parseInt(h.slice(4, 6), 16);
|
||||
const a = h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1;
|
||||
if (isNaN(r) || isNaN(g) || isNaN(b)) return null;
|
||||
return [r / 255, g / 255, b / 255, a];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Tessellliert Plan-Primitive zu GPU-Geometrie (MVP: gefüllte Polygone +
|
||||
* bildschirmkonstante Striche). Text/Bögen bleiben im SVG-Overlay.
|
||||
*/
|
||||
export function compilePrimitivesToGpu(
|
||||
gl: WebGL2RenderingContext,
|
||||
primitives: Primitive[],
|
||||
): GpuGeometry {
|
||||
// Füll-Puffer (Bildschirm-Raum Positionen + Indizes).
|
||||
const fillPos: number[] = [];
|
||||
const fillIdx: number[] = [];
|
||||
const fillBatches: GpuGeometry['fill']['batches'] = [];
|
||||
|
||||
// Linien-Puffer (interleaved [x,y, nx,ny, side]).
|
||||
const lineVerts: number[] = [];
|
||||
const lineIdx: number[] = [];
|
||||
const lineBatches: GpuGeometry['line']['batches'] = [];
|
||||
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
const track = (mx: number, my: number) => {
|
||||
if (mx < minX) minX = mx;
|
||||
if (my < minY) minY = my;
|
||||
if (mx > maxX) maxX = mx;
|
||||
if (my > maxY) maxY = my;
|
||||
};
|
||||
|
||||
const sameRgba = (a: Rgba, b: Rgba): boolean =>
|
||||
a[0] === b[0] && a[1] === b[1] && a[2] === b[2] && a[3] === b[3];
|
||||
|
||||
// Ordnungserhaltendes Batch-Merging: aufeinanderfolgende Indizes gleicher Farbe
|
||||
// (+ Breite bei Linien) werden zu EINEM Draw-Call zusammengefasst (Reihenfolge
|
||||
// bleibt exakt → korrekte Z-/Alpha-Überlagerung, nur viel weniger State-Wechsel).
|
||||
const addFillBatch = (count: number, color: Rgba) => {
|
||||
const last = fillBatches[fillBatches.length - 1];
|
||||
if (last && sameRgba(last.color, color)) last.indexCount += count;
|
||||
else fillBatches.push({ startIndex: fillIdx.length - count, indexCount: count, color });
|
||||
};
|
||||
const addLineBatch = (count: number, color: Rgba, strokeMm: number) => {
|
||||
const last = lineBatches[lineBatches.length - 1];
|
||||
if (last && last.strokeMm === strokeMm && sameRgba(last.color, color))
|
||||
last.indexCount += count;
|
||||
else lineBatches.push({ startIndex: lineIdx.length - count, indexCount: count, color, strokeMm });
|
||||
};
|
||||
|
||||
/** Maximaler Miter-Längenfaktor; darüber wird geklemmt (kein Spike an spitzen Ecken). */
|
||||
const MITER_LIMIT = 4;
|
||||
|
||||
/**
|
||||
* Zeichnet einen zusammenhängenden Linienzug (Bildschirm-Raum) als EINEN
|
||||
* gehrten Streifen: an jedem Stützpunkt wird der Versatz entlang des Miter-
|
||||
* Bisektors verlängert (1/cos(θ/2)), sodass benachbarte Segmente bündig
|
||||
* verschmelzen → gehrte Ecke statt Butt-Cap-Stufe. `closed` schließt den Ring
|
||||
* (letzter↔erster Punkt). Vertex-Layout: [x,y, bx,by, side, miter].
|
||||
*/
|
||||
const strokePolyline = (ptsM: Vec2[], closed: boolean, color: Rgba, strokeMm: number) => {
|
||||
// Auf Bildschirm-Raum abbilden + aufeinanderfolgende Duplikate entfernen.
|
||||
const S: Vec2[] = [];
|
||||
for (const p of ptsM) {
|
||||
const s = toScreen(p);
|
||||
if (S.length && Math.abs(S[S.length - 1].x - s.x) < 1e-6 && Math.abs(S[S.length - 1].y - s.y) < 1e-6)
|
||||
continue;
|
||||
S.push(s);
|
||||
track(p.x, p.y);
|
||||
}
|
||||
if (closed && S.length > 1) {
|
||||
const f = S[0], l = S[S.length - 1];
|
||||
if (Math.abs(f.x - l.x) < 1e-6 && Math.abs(f.y - l.y) < 1e-6) S.pop();
|
||||
}
|
||||
const k = S.length;
|
||||
if (k < 2) return;
|
||||
|
||||
const leftNormal = (from: Vec2, to: Vec2): Vec2 | null => {
|
||||
const dx = to.x - from.x, dy = to.y - from.y;
|
||||
const len = Math.hypot(dx, dy);
|
||||
return len < 1e-6 ? null : { x: -dy / len, y: dx / len };
|
||||
};
|
||||
|
||||
const base = lineVerts.length / 6;
|
||||
for (let i = 0; i < k; i++) {
|
||||
const hasIn = closed || i > 0;
|
||||
const hasOut = closed || i < k - 1;
|
||||
const nIn = hasIn ? leftNormal(S[(i - 1 + k) % k], S[i]) : null;
|
||||
const nOut = hasOut ? leftNormal(S[i], S[(i + 1) % k]) : null;
|
||||
|
||||
let bx: number, by: number, miter: number;
|
||||
if (nIn && nOut) {
|
||||
let sx = nIn.x + nOut.x, sy = nIn.y + nOut.y;
|
||||
const slen = Math.hypot(sx, sy);
|
||||
if (slen < 1e-3) {
|
||||
// ~180°-Umkehr → kein sinnvoller Bisektor, gerade weiterlaufen.
|
||||
bx = nOut.x; by = nOut.y; miter = 1;
|
||||
} else {
|
||||
bx = sx / slen; by = sy / slen;
|
||||
const denom = bx * nOut.x + by * nOut.y; // cos(θ/2)
|
||||
miter = denom > 1e-3 ? Math.min(1 / denom, MITER_LIMIT) : 1;
|
||||
}
|
||||
} else {
|
||||
const n = nIn ?? nOut!;
|
||||
bx = n.x; by = n.y; miter = 1;
|
||||
}
|
||||
lineVerts.push(S[i].x, S[i].y, bx, by, +1, miter);
|
||||
lineVerts.push(S[i].x, S[i].y, bx, by, -1, miter);
|
||||
}
|
||||
|
||||
const segs = closed ? k : k - 1;
|
||||
for (let i = 0; i < segs; i++) {
|
||||
const a = base + 2 * i;
|
||||
const b = base + 2 * ((i + 1) % k);
|
||||
lineIdx.push(a, a + 1, b, a + 1, b + 1, b);
|
||||
}
|
||||
addLineBatch(segs * 6, color, strokeMm);
|
||||
};
|
||||
|
||||
/** Einzelnes Segment als (ungehrter) 2-Punkt-Zug. */
|
||||
const pushLine = (aM: Vec2, bM: Vec2, color: Rgba, strokeMm: number) =>
|
||||
strokePolyline([aM, bM], false, color, strokeMm);
|
||||
|
||||
for (const prim of primitives) {
|
||||
if (prim.kind === 'polygon') {
|
||||
// Füllung (falls vorhanden).
|
||||
const fill = parseColor(prim.fill);
|
||||
if (fill) {
|
||||
const tris = triangulate(prim.pts);
|
||||
if (tris.length) {
|
||||
const base = fillPos.length / 2;
|
||||
for (const pt of prim.pts) {
|
||||
const s = toScreen(pt);
|
||||
fillPos.push(s.x, s.y);
|
||||
track(pt.x, pt.y);
|
||||
}
|
||||
for (const t of tris) fillIdx.push(base + t);
|
||||
addFillBatch(tris.length, fill);
|
||||
}
|
||||
}
|
||||
// Umriss (crispe Kante) — geschlossener Ring, GEHRT (kein Stufen-Cap an
|
||||
// Ecken). Breite = ECHTE Papier-mm; der Renderer rechnet massstabs-/
|
||||
// zoomrichtig in px (wie SVG-printStrokeVb → GL == SVG).
|
||||
const stroke = parseColor(prim.stroke);
|
||||
if (stroke && prim.strokeWidthMm > 0 && prim.pts.length >= 2) {
|
||||
strokePolyline(prim.pts, true, stroke, prim.strokeWidthMm);
|
||||
}
|
||||
} else if (prim.kind === 'line') {
|
||||
const color = parseColor(prim.color) ?? [0.1, 0.1, 0.1, 1];
|
||||
pushLine(prim.a, prim.b, color, prim.weightMm || 0.18);
|
||||
} else if (prim.kind === 'arc') {
|
||||
// Bogen → ein gehrter Polylinienzug (glatt, keine Segment-Stufen).
|
||||
const color = parseColor((prim as { color?: string }).color) ?? [0.1, 0.1, 0.1, 1];
|
||||
const strokeMm = prim.weightMm || 0.18;
|
||||
const c = prim.center;
|
||||
const a0 = Math.atan2(prim.from.y - c.y, prim.from.x - c.x);
|
||||
const a1 = Math.atan2(prim.to.y - c.y, prim.to.x - c.x);
|
||||
// Kürzeste Drehrichtung (Delta nach (-π, π]).
|
||||
let d = a1 - a0;
|
||||
while (d <= -Math.PI) d += 2 * Math.PI;
|
||||
while (d > Math.PI) d -= 2 * Math.PI;
|
||||
const segs = Math.max(4, Math.ceil((Math.abs(d) / (Math.PI / 2)) * 16));
|
||||
const arcPts: Vec2[] = [prim.from];
|
||||
for (let i = 1; i <= segs; i++) {
|
||||
const t = a0 + (d * i) / segs;
|
||||
arcPts.push({ x: c.x + prim.r * Math.cos(t), y: c.y + prim.r * Math.sin(t) });
|
||||
}
|
||||
strokePolyline(arcPts, false, color, strokeMm);
|
||||
}
|
||||
// Text: bleibt im SVG-Overlay (scharfe Schrift, DOM-Hit-Test).
|
||||
}
|
||||
|
||||
// Puffer hochladen.
|
||||
const uploadArray = (data: number[]): WebGLBuffer | null => {
|
||||
if (!data.length) return null;
|
||||
const buf = gl.createBuffer();
|
||||
if (!buf) return null;
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(data), gl.STATIC_DRAW);
|
||||
return buf;
|
||||
};
|
||||
const uploadIndex = (data: number[]): WebGLBuffer | null => {
|
||||
if (!data.length) return null;
|
||||
const buf = gl.createBuffer();
|
||||
if (!buf) return null;
|
||||
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, buf);
|
||||
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint32Array(data), gl.STATIC_DRAW);
|
||||
return buf;
|
||||
};
|
||||
|
||||
return {
|
||||
fill: {
|
||||
positionBuffer: uploadArray(fillPos),
|
||||
indexBuffer: uploadIndex(fillIdx),
|
||||
batches: fillBatches,
|
||||
},
|
||||
line: {
|
||||
vertexBuffer: uploadArray(lineVerts),
|
||||
indexBuffer: uploadIndex(lineIdx),
|
||||
batches: lineBatches,
|
||||
},
|
||||
bounds: {
|
||||
minX: isFinite(minX) ? minX : 0,
|
||||
minY: isFinite(minY) ? minY : 0,
|
||||
maxX: isFinite(maxX) ? maxX : 1,
|
||||
maxY: isFinite(maxY) ? maxY : 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* WebGL2-Render-Loop des GPU-Grundrisses.
|
||||
*
|
||||
* Zeichnet zwischengespeicherte GPU-Geometrie (aus glPlanCompile) und aktualisiert
|
||||
* beim Pan/Zoom NUR die Projektionsmatrix (kein Re-Tessellieren). Getrieben vom
|
||||
* Aufrufer (PlanView-Pan/Zoom-Handler), nicht per requestAnimationFrame.
|
||||
*/
|
||||
|
||||
import type { GpuGeometry, ShaderProgram, ViewBox } from './glPlanTypes';
|
||||
|
||||
/** viewBox-Einheiten je Meter (identisch zu PlanView/toScreen). */
|
||||
const PX_PER_M = 90;
|
||||
|
||||
/** 4×4-Einheitsmatrix (Spalten-Major, WebGL-Konvention). */
|
||||
function mat4Identity(): Float32Array {
|
||||
const m = new Float32Array(16);
|
||||
m[0] = 1;
|
||||
m[5] = 1;
|
||||
m[10] = 1;
|
||||
m[15] = 1;
|
||||
return m;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orthografische Projektion [left,right]×[bottom,top] → Clip [-1,1].
|
||||
* Spalten-Major.
|
||||
*/
|
||||
function mat4Ortho(
|
||||
left: number,
|
||||
right: number,
|
||||
bottom: number,
|
||||
top: number,
|
||||
): Float32Array {
|
||||
const m = mat4Identity();
|
||||
const w = right - left;
|
||||
const h = top - bottom;
|
||||
m[0] = 2 / w;
|
||||
m[5] = 2 / h;
|
||||
m[10] = -1;
|
||||
m[12] = -(right + left) / w;
|
||||
m[13] = -(top + bottom) / h;
|
||||
return m;
|
||||
}
|
||||
|
||||
/** viewBox-Gleichheit (Cache-Invalidierung). */
|
||||
function viewBoxEqual(a: ViewBox, b: ViewBox): boolean {
|
||||
return a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h;
|
||||
}
|
||||
|
||||
/**
|
||||
* Projektionsmatrix für einen viewBox, aspekt-korrigiert wie SVG
|
||||
* `preserveAspectRatio="xMidYMid meet"`: der viewBox wird auf das
|
||||
* Canvas-Seitenverhältnis GEDEHNT (zentriert), damit die Skalierung in X und Y
|
||||
* gleich ist (keine Verzerrung, Perpendikel bleiben senkrecht).
|
||||
*
|
||||
* Eingabe-Positionen sind bereits im BILDSCHIRM-Raum (toScreen), daher keine
|
||||
* zusätzliche Meter-Skalierung. Bildschirm-Y zeigt nach unten → top=vb.y,
|
||||
* bottom=vb.y+vb.h (kleineres Y oben → Clip +1).
|
||||
*/
|
||||
export function computeOrthoMatrix(
|
||||
viewBox: ViewBox,
|
||||
canvasW: number,
|
||||
canvasH: number,
|
||||
): Float32Array {
|
||||
let { x, y, w, h } = viewBox;
|
||||
if (canvasW > 0 && canvasH > 0 && w > 0 && h > 0) {
|
||||
const cAspect = canvasW / canvasH;
|
||||
const vAspect = w / h;
|
||||
if (cAspect > vAspect) {
|
||||
// Canvas breiter → viewBox in der Breite dehnen, X zentrieren.
|
||||
const nw = h * cAspect;
|
||||
x -= (nw - w) / 2;
|
||||
w = nw;
|
||||
} else {
|
||||
// Canvas höher → viewBox in der Höhe dehnen, Y zentrieren.
|
||||
const nh = w / cAspect;
|
||||
y -= (nh - h) / 2;
|
||||
h = nh;
|
||||
}
|
||||
}
|
||||
// top = y (Bildschirm-Y oben, kleiner), bottom = y + h.
|
||||
return mat4Ortho(x, x + w, y + h, y);
|
||||
}
|
||||
|
||||
/**
|
||||
* WebGL2-GPU-Renderer. Verwaltet Shader-Programme + Projektionsmatrix, zeichnet
|
||||
* Füll- und Linien-Batches effizient über Matrix-Uniforms.
|
||||
*/
|
||||
export class PlanGpuRenderer {
|
||||
private gl: WebGL2RenderingContext;
|
||||
private fillProgram: ShaderProgram;
|
||||
private lineProgram: ShaderProgram;
|
||||
private lastViewBox: ViewBox | null = null;
|
||||
private lastCanvas: { w: number; h: number } = { w: 0, h: 0 };
|
||||
private projMatrix: Float32Array;
|
||||
|
||||
constructor(
|
||||
gl: WebGL2RenderingContext,
|
||||
fillProg: ShaderProgram,
|
||||
lineProg: ShaderProgram,
|
||||
) {
|
||||
this.gl = gl;
|
||||
this.fillProgram = fillProg;
|
||||
this.lineProgram = lineProg;
|
||||
this.projMatrix = mat4Identity();
|
||||
|
||||
// Zeichenblatt-Hintergrund (hell, Default #f0f0f0 = --sheet), keine Tiefe;
|
||||
// Alpha-Blending für weiche Kanten/transluzente Räume.
|
||||
gl.clearColor(0.941, 0.941, 0.941, 1);
|
||||
gl.disable(gl.DEPTH_TEST);
|
||||
gl.enable(gl.BLEND);
|
||||
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
||||
}
|
||||
|
||||
/** Löschfarbe (Zeichenblatt) setzen — theme-sicher aus --sheet. */
|
||||
setClearColor(r: number, g: number, b: number): void {
|
||||
this.gl.clearColor(r, g, b, 1);
|
||||
}
|
||||
|
||||
/** Projektionsmatrix nur bei viewBox-/Canvas-Änderung neu berechnen. */
|
||||
private updateProjection(viewBox: ViewBox, canvasW: number, canvasH: number): void {
|
||||
if (
|
||||
!this.lastViewBox ||
|
||||
!viewBoxEqual(viewBox, this.lastViewBox) ||
|
||||
this.lastCanvas.w !== canvasW ||
|
||||
this.lastCanvas.h !== canvasH
|
||||
) {
|
||||
this.projMatrix = computeOrthoMatrix(viewBox, canvasW, canvasH);
|
||||
this.lastViewBox = { ...viewBox };
|
||||
this.lastCanvas = { w: canvasW, h: canvasH };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeichnet einen Frame.
|
||||
* @param geometry GPU-Geometrie (Fill + Line); null → nur löschen.
|
||||
* @param viewBox Aktueller Ausschnitt (Bildschirm-Einheiten).
|
||||
* @param viewport Zeichenpuffer-Größe in Geräte-Pixeln.
|
||||
* @param paperScaleN Papier-Massstab-Nenner (1:N) für echte mm-Strichbreiten.
|
||||
*/
|
||||
render(
|
||||
geometry: GpuGeometry | null,
|
||||
viewBox: ViewBox,
|
||||
viewport: { width: number; height: number },
|
||||
paperScaleN = 100,
|
||||
): void {
|
||||
const gl = this.gl;
|
||||
gl.viewport(0, 0, viewport.width, viewport.height);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
if (!geometry) return;
|
||||
|
||||
this.updateProjection(viewBox, viewport.width, viewport.height);
|
||||
|
||||
// Umrechnung Papier-mm → Geräte-px, EXAKT wie der SVG-printStrokeVb-Pfad:
|
||||
// Breite_vb = mm·N/1000·PX_PER_M (viewBox-Einheiten, skaliert mit Zoom)
|
||||
// Breite_px = Breite_vb · meetSkala (Geräte-px je viewBox-Einheit)
|
||||
// → mm·(N/1000·PX_PER_M·meet). meet nutzt die Geräte-px-Viewport-Größe, enthält
|
||||
// damit dpr. So gilt: 0.35 mm bei 1:100 = echte 0.35 mm Papier (== SVG).
|
||||
const meet = Math.min(viewport.width / viewBox.w, viewport.height / viewBox.h);
|
||||
const mmToDevicePx = (paperScaleN / 1000) * PX_PER_M * meet;
|
||||
|
||||
this.drawFills(geometry);
|
||||
this.drawLines(geometry, viewport, mmToDevicePx);
|
||||
}
|
||||
|
||||
/** Gefüllte Polygone (Poché). */
|
||||
private drawFills(geometry: GpuGeometry): void {
|
||||
const gl = this.gl;
|
||||
const { positionBuffer, indexBuffer, batches } = geometry.fill;
|
||||
if (!positionBuffer || !indexBuffer || !batches.length) return;
|
||||
const prog = this.fillProgram;
|
||||
if (!prog?.program) return;
|
||||
|
||||
gl.useProgram(prog.program);
|
||||
if (prog.uniforms.viewProj) {
|
||||
gl.uniformMatrix4fv(prog.uniforms.viewProj, false, this.projMatrix);
|
||||
}
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
|
||||
const pos = prog.attribs.position;
|
||||
if (pos !== undefined && pos >= 0) {
|
||||
gl.enableVertexAttribArray(pos);
|
||||
gl.vertexAttribPointer(pos, 2, gl.FLOAT, false, 8, 0);
|
||||
}
|
||||
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
|
||||
|
||||
for (const b of batches) {
|
||||
if (prog.uniforms.color) gl.uniform4fv(prog.uniforms.color, b.color);
|
||||
gl.drawElements(gl.TRIANGLES, b.indexCount, gl.UNSIGNED_INT, b.startIndex * 4);
|
||||
}
|
||||
if (pos !== undefined && pos >= 0) gl.disableVertexAttribArray(pos);
|
||||
}
|
||||
|
||||
/** Striche mit echter Papier-mm-Breite (massstabs- + zoomrichtig). */
|
||||
private drawLines(
|
||||
geometry: GpuGeometry,
|
||||
viewport: { width: number; height: number },
|
||||
mmToDevicePx: number,
|
||||
): void {
|
||||
const gl = this.gl;
|
||||
const { vertexBuffer, indexBuffer, batches } = geometry.line;
|
||||
if (!vertexBuffer || !indexBuffer || !batches.length) return;
|
||||
const prog = this.lineProgram;
|
||||
if (!prog?.program) return;
|
||||
|
||||
gl.useProgram(prog.program);
|
||||
if (prog.uniforms.viewProj) {
|
||||
gl.uniformMatrix4fv(prog.uniforms.viewProj, false, this.projMatrix);
|
||||
}
|
||||
if (prog.uniforms.viewportPx) {
|
||||
gl.uniform2f(prog.uniforms.viewportPx, viewport.width, viewport.height);
|
||||
}
|
||||
// strokeScale = mm → Geräte-px; die Basis-Breite (strokePx) trägt die mm.
|
||||
if (prog.uniforms.strokeScale) {
|
||||
gl.uniform1f(prog.uniforms.strokeScale, mmToDevicePx);
|
||||
}
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
|
||||
const stride = 6 * 4; // [x,y, nx,ny, side, miter]
|
||||
const pos = prog.attribs.position;
|
||||
const nrm = prog.attribs.normal;
|
||||
const side = prog.attribs.side;
|
||||
const miter = prog.attribs.miter;
|
||||
if (pos !== undefined && pos >= 0) {
|
||||
gl.enableVertexAttribArray(pos);
|
||||
gl.vertexAttribPointer(pos, 2, gl.FLOAT, false, stride, 0);
|
||||
}
|
||||
if (nrm !== undefined && nrm >= 0) {
|
||||
gl.enableVertexAttribArray(nrm);
|
||||
gl.vertexAttribPointer(nrm, 2, gl.FLOAT, false, stride, 2 * 4);
|
||||
}
|
||||
if (side !== undefined && side >= 0) {
|
||||
gl.enableVertexAttribArray(side);
|
||||
gl.vertexAttribPointer(side, 1, gl.FLOAT, false, stride, 4 * 4);
|
||||
}
|
||||
if (miter !== undefined && miter >= 0) {
|
||||
gl.enableVertexAttribArray(miter);
|
||||
gl.vertexAttribPointer(miter, 1, gl.FLOAT, false, stride, 5 * 4);
|
||||
}
|
||||
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
|
||||
|
||||
// strokePx-Uniform trägt die ECHTE Papier-mm-Breite; der Shader multipliziert
|
||||
// mit strokeScale (= mm→Geräte-px) und klemmt bei ~0.6 px (Haarlinie sichtbar,
|
||||
// MSAA via antialias:true), damit sehr feine Gewichte beim Rauszoomen nicht
|
||||
// verschwinden.
|
||||
for (const b of batches) {
|
||||
if (prog.uniforms.color) gl.uniform4fv(prog.uniforms.color, b.color);
|
||||
if (prog.uniforms.strokePx) gl.uniform1f(prog.uniforms.strokePx, b.strokeMm);
|
||||
gl.drawElements(gl.TRIANGLES, b.indexCount, gl.UNSIGNED_INT, b.startIndex * 4);
|
||||
}
|
||||
if (pos !== undefined && pos >= 0) gl.disableVertexAttribArray(pos);
|
||||
if (nrm !== undefined && nrm >= 0) gl.disableVertexAttribArray(nrm);
|
||||
if (side !== undefined && side >= 0) gl.disableVertexAttribArray(side);
|
||||
if (miter !== undefined && miter >= 0) gl.disableVertexAttribArray(miter);
|
||||
}
|
||||
|
||||
/** Cache invalidieren (z. B. nach Größenänderung). */
|
||||
invalidateViewBox(): void {
|
||||
this.lastViewBox = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* WebGL2-Shader-Programme für den GPU-Grundriss-Renderer.
|
||||
* Kompilierung + Fehlerbehandlung (Warnung + null, keine Exceptions).
|
||||
*/
|
||||
|
||||
import type { ShaderProgram } from './glPlanTypes';
|
||||
|
||||
/**
|
||||
* Füll-Vertex-Shader: bildet Bildschirm-Raum-Positionen über die viewProj-Matrix
|
||||
* in den Clip-Raum ab. Für gefüllte Polygone (Wand-Poché, Räume, Decken).
|
||||
*/
|
||||
export const FILL_VERT = `#version 300 es
|
||||
precision highp float;
|
||||
|
||||
uniform mat4 viewProj;
|
||||
|
||||
in vec2 position;
|
||||
|
||||
void main() {
|
||||
gl_Position = viewProj * vec4(position, 0.0, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
/** Füll-Fragment-Shader: einheitliche Farbe (MVP, noch keine Schraffur). */
|
||||
export const FILL_FRAG = `#version 300 es
|
||||
precision mediump float;
|
||||
|
||||
uniform vec4 color;
|
||||
|
||||
out vec4 outColor;
|
||||
|
||||
void main() {
|
||||
outColor = color;
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Linien-Vertex-Shader mit BILDSCHIRMKONSTANTER Breite.
|
||||
*
|
||||
* Idee: sowohl der Endpunkt als auch (Endpunkt + Normale) werden durch viewProj
|
||||
* transformiert; ihre Differenz im Clip-Raum (in Pixel skaliert) ergibt die
|
||||
* Perpendikel-Richtung. Diese wird auf `strokePx/2` Pixel normiert und — je nach
|
||||
* `side` — auf die passende Seite versetzt, dann px→Clip zurückgerechnet. So ist
|
||||
* die Strichbreite unabhängig vom Zoom (keine Re-Tessellierung nötig).
|
||||
*/
|
||||
export const LINE_VERT = `#version 300 es
|
||||
precision highp float;
|
||||
|
||||
uniform mat4 viewProj;
|
||||
uniform float strokePx; // Basis-Breite in Geräte-px (bei strokeScale=1)
|
||||
uniform float strokeScale; // Zoom-Skalierung: >1 = reingezoomt → dicker (Papier)
|
||||
uniform vec2 viewportPx; // Zeichenpuffer-Größe [w,h] in Geräte-px
|
||||
|
||||
in vec2 position; // Bildschirm-Raum-Stützpunkt
|
||||
in vec2 normal; // Bildschirm-Raum-Einheits-Miter-Bisektor (Richtung des Versatzes)
|
||||
in float side; // +1.0 oder -1.0
|
||||
in float miter; // Miter-Längenfaktor 1/cos(θ/2) (1.0 = gerade, keine Ecke)
|
||||
|
||||
void main() {
|
||||
vec4 clipP = viewProj * vec4(position, 0.0, 1.0);
|
||||
vec4 clipN = viewProj * vec4(position + normal, 0.0, 1.0);
|
||||
|
||||
// Bisektor-Richtung im Pixel-Raum (Clip-Differenz → px).
|
||||
vec2 dirPx = (clipN.xy - clipP.xy) * viewportPx * 0.5;
|
||||
float len = length(dirPx);
|
||||
vec2 unitPx = len > 1e-6 ? dirPx / len : vec2(0.0);
|
||||
|
||||
// Echte Papierbreite in Geräte-px = mm(strokePx) · (mm→px)(strokeScale); mind.
|
||||
// 0.6 px. Der Miter-Faktor verlängert den Versatz an Ecken, sodass benachbarte
|
||||
// Segmente bündig verschmelzen (gehrte Ecke statt Butt-Cap-Stufe).
|
||||
float widthPx = max(0.6, strokePx * strokeScale) * miter;
|
||||
vec2 offsetPx = unitPx * (widthPx * 0.5) * side;
|
||||
clipP.xy += offsetPx / (viewportPx * 0.5);
|
||||
|
||||
gl_Position = clipP;
|
||||
}
|
||||
`;
|
||||
|
||||
/** Linien-Fragment-Shader: einheitliche Farbe. */
|
||||
export const LINE_FRAG = `#version 300 es
|
||||
precision mediump float;
|
||||
|
||||
uniform vec4 color;
|
||||
|
||||
out vec4 outColor;
|
||||
|
||||
void main() {
|
||||
outColor = color;
|
||||
}
|
||||
`;
|
||||
|
||||
/** Kompiliert einen einzelnen Shader; null + Warnung bei Fehler. */
|
||||
function compileShader(
|
||||
gl: WebGL2RenderingContext,
|
||||
type: number,
|
||||
source: string,
|
||||
): WebGLShader | null {
|
||||
const shader = gl.createShader(type);
|
||||
if (!shader) {
|
||||
console.warn('WebGL: Shader-Objekt konnte nicht erstellt werden');
|
||||
return null;
|
||||
}
|
||||
gl.shaderSource(shader, source);
|
||||
gl.compileShader(shader);
|
||||
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
||||
console.warn(`WebGL: Shader-Kompilierung fehlgeschlagen:\n${gl.getShaderInfoLog(shader)}`);
|
||||
gl.deleteShader(shader);
|
||||
return null;
|
||||
}
|
||||
return shader;
|
||||
}
|
||||
|
||||
/** Verlinkt Vertex + Fragment zu einem Programm; null + Warnung bei Fehler. */
|
||||
function linkProgram(
|
||||
gl: WebGL2RenderingContext,
|
||||
vert: WebGLShader,
|
||||
frag: WebGLShader,
|
||||
): WebGLProgram | null {
|
||||
const program = gl.createProgram();
|
||||
if (!program) {
|
||||
console.warn('WebGL: Programm konnte nicht erstellt werden');
|
||||
return null;
|
||||
}
|
||||
gl.attachShader(program, vert);
|
||||
gl.attachShader(program, frag);
|
||||
gl.linkProgram(program);
|
||||
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
||||
console.warn(`WebGL: Programm-Verlinkung fehlgeschlagen:\n${gl.getProgramInfoLog(program)}`);
|
||||
gl.deleteProgram(program);
|
||||
return null;
|
||||
}
|
||||
return program;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kompiliert + verlinkt ein vollständiges Shader-Programm und ermittelt alle
|
||||
* Attribut-/Uniform-Orte. Gibt null zurück (mit Warnung) statt zu werfen.
|
||||
*/
|
||||
export function compileShaderProgram(
|
||||
gl: WebGL2RenderingContext,
|
||||
vertSrc: string,
|
||||
fragSrc: string,
|
||||
): ShaderProgram | null {
|
||||
const vert = compileShader(gl, gl.VERTEX_SHADER, vertSrc);
|
||||
if (!vert) return null;
|
||||
const frag = compileShader(gl, gl.FRAGMENT_SHADER, fragSrc);
|
||||
if (!frag) {
|
||||
gl.deleteShader(vert);
|
||||
return null;
|
||||
}
|
||||
const program = linkProgram(gl, vert, frag);
|
||||
gl.deleteShader(vert);
|
||||
gl.deleteShader(frag);
|
||||
if (!program) return null;
|
||||
|
||||
const loc = (name: string): GLint => gl.getAttribLocation(program, name);
|
||||
const uni = (name: string) => gl.getUniformLocation(program, name);
|
||||
|
||||
return {
|
||||
program,
|
||||
attribs: {
|
||||
position: loc('position'),
|
||||
normal: loc('normal'),
|
||||
side: loc('side'),
|
||||
miter: loc('miter'),
|
||||
},
|
||||
uniforms: {
|
||||
viewProj: uni('viewProj'),
|
||||
color: uni('color'),
|
||||
strokePx: uni('strokePx'),
|
||||
strokeScale: uni('strokeScale'),
|
||||
viewportPx: uni('viewportPx'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Füll-Programm (gefüllte Polygone). */
|
||||
export function createFillProgram(gl: WebGL2RenderingContext): ShaderProgram | null {
|
||||
return compileShaderProgram(gl, FILL_VERT, FILL_FRAG);
|
||||
}
|
||||
|
||||
/** Linien-Programm (bildschirmkonstante Striche). */
|
||||
export function createLineProgram(gl: WebGL2RenderingContext): ShaderProgram | null {
|
||||
return compileShaderProgram(gl, LINE_VERT, LINE_FRAG);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Typen des WebGL2-GPU-Plan-Renderers.
|
||||
* Gemeinsamer Vertrag zwischen Shader-Kompilierung, Tessellierung und Render-Loop.
|
||||
*
|
||||
* Koordinaten-Konvention (WICHTIG, muss überall gleich sein):
|
||||
* • Die Tessellierung liefert BILDSCHIRM-Raum-Koordinaten exakt wie `toScreen`
|
||||
* in PlanView: `sx = mx·PX_PER_M`, `sy = -my·PX_PER_M` (Modell-Y zeigt nach
|
||||
* oben, Bildschirm-Y nach unten). So ist die Projektion eine reine viewBox-
|
||||
* Orthografie — kein zusätzlicher Skalen-/Spiegel-Schritt, deckungsgleich zum
|
||||
* SVG-Renderer.
|
||||
* • Linienbreiten sind BILDSCHIRM-KONSTANT (Papier-px): die Geometrie trägt eine
|
||||
* Normale + Seiten-Flag, der Vertex-Shader expandiert erst im Clip-Raum um
|
||||
* `strokePx` — unabhängig vom Zoom, ohne Re-Tessellierung.
|
||||
*/
|
||||
|
||||
/** RGBA-Farbe, Komponenten in [0,1]. */
|
||||
export type Rgba = [number, number, number, number];
|
||||
|
||||
/** Shader-Programm mit kompiliertem Programm + Attribut-/Uniform-Orten. */
|
||||
export interface ShaderProgram {
|
||||
program: WebGLProgram;
|
||||
attribs: {
|
||||
position?: GLint;
|
||||
/** Bildschirm-Raum-Miter-Bisektor (Einheits-Versatzrichtung) für Linien. */
|
||||
normal?: GLint;
|
||||
/** Seite der Linie: +1 oder -1. */
|
||||
side?: GLint;
|
||||
/** Miter-Längenfaktor 1/cos(θ/2) (1 = gerade). */
|
||||
miter?: GLint;
|
||||
};
|
||||
uniforms: {
|
||||
viewProj?: WebGLUniformLocation | null;
|
||||
color?: WebGLUniformLocation | null;
|
||||
/** Basis-Strichbreite in Geräte-Pixeln (bei strokeScale=1). */
|
||||
strokePx?: WebGLUniformLocation | null;
|
||||
/** Zoom-Skalierung der Strichbreite (>1 = reingezoomt → dicker, Papier). */
|
||||
strokeScale?: WebGLUniformLocation | null;
|
||||
/** Zeichenpuffer-Größe in Geräte-Pixeln [w,h] (px→Clip-Umrechnung). */
|
||||
viewportPx?: WebGLUniformLocation | null;
|
||||
};
|
||||
}
|
||||
|
||||
/** Ein Zeichen-Batch: zusammenhängender Index-Bereich mit einer Farbe. */
|
||||
export interface FillBatch {
|
||||
startIndex: number;
|
||||
indexCount: number;
|
||||
color: Rgba;
|
||||
}
|
||||
|
||||
/** Ein Linien-Batch: Index-Bereich mit Farbe + echter Papier-Strichbreite. */
|
||||
export interface LineBatch {
|
||||
startIndex: number;
|
||||
indexCount: number;
|
||||
color: Rgba;
|
||||
/**
|
||||
* ECHTE Strichbreite in Papier-Millimeter. Der Renderer rechnet sie über den
|
||||
* Massstab (1:N) + die aktuelle meet-Skala in Pixel — genau wie der SVG-
|
||||
* `printStrokeVb`-Pfad, sodass GL == SVG (0.35 mm bei 1:100 = 0.35 mm Papier).
|
||||
*/
|
||||
strokeMm: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* GPU-fertige Geometrie: getrennte Puffer für gefüllte Flächen (Poché) und
|
||||
* Striche (bildschirmkonstante Breite). Einmal je Plan tessellieren, danach
|
||||
* treibt nur die Projektionsmatrix Pan/Zoom.
|
||||
*/
|
||||
export interface GpuGeometry {
|
||||
/** Gefüllte Polygone (Poché/Räume/Decken) — Bildschirm-Raum. */
|
||||
fill: {
|
||||
/** Interleaved [x,y, x,y, …] in Bildschirm-Raum. */
|
||||
positionBuffer: WebGLBuffer | null;
|
||||
indexBuffer: WebGLBuffer | null;
|
||||
batches: FillBatch[];
|
||||
};
|
||||
/** Striche — Bildschirm-Raum-Position + Normale + Seiten-Flag. */
|
||||
line: {
|
||||
/** Interleaved [x,y, nx,ny, side, …] in Bildschirm-Raum. */
|
||||
vertexBuffer: WebGLBuffer | null;
|
||||
indexBuffer: WebGLBuffer | null;
|
||||
batches: LineBatch[];
|
||||
};
|
||||
/** Modell-Bounds (Meter) für Debug/Einpassen; nicht render-kritisch. */
|
||||
bounds: { minX: number; minY: number; maxX: number; maxY: number };
|
||||
}
|
||||
|
||||
/** Rechteckiger Ausschnitt in Bildschirm-Einheiten (viewBox). */
|
||||
export interface ViewBox {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* WebGL2-GPU-Plan-Renderer — öffentliche API.
|
||||
*/
|
||||
|
||||
export * from './glPlanTypes';
|
||||
export {
|
||||
compileShaderProgram,
|
||||
createFillProgram,
|
||||
createLineProgram,
|
||||
} from './glPlanShaders';
|
||||
export { compilePrimitivesToGpu, triangulate } from './glPlanCompile';
|
||||
export { PlanGpuRenderer, computeOrthoMatrix } from './glPlanRender';
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* React-Hook für den WebGL2-GPU-Plan-Renderer.
|
||||
* Verwaltet GL-Kontext, Shader, Geometrie-Cache und Render-Aufrufe.
|
||||
*
|
||||
* Fällt bei fehlendem WebGL2/Shader-Fehler still auf null zurück; der Aufrufer
|
||||
* (PlanView) behält dann den SVG-Pfad (Parität-Fallback).
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { Plan } from './generatePlan';
|
||||
import {
|
||||
compilePrimitivesToGpu,
|
||||
createFillProgram,
|
||||
createLineProgram,
|
||||
PlanGpuRenderer,
|
||||
type GpuGeometry,
|
||||
type ViewBox,
|
||||
} from './glPlan';
|
||||
|
||||
/** Liest eine CSS-Custom-Property (z. B. "--sheet") als RGB[0..1] oder null. */
|
||||
function readCssColor(varName: string): [number, number, number] | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const raw = getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
|
||||
let h = raw.startsWith('#') ? raw.slice(1) : raw;
|
||||
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
|
||||
if (h.length < 6) return null;
|
||||
const r = parseInt(h.slice(0, 2), 16);
|
||||
const g = parseInt(h.slice(2, 4), 16);
|
||||
const b = parseInt(h.slice(4, 6), 16);
|
||||
if (isNaN(r) || isNaN(g) || isNaN(b)) return null;
|
||||
return [r / 255, g / 255, b / 255];
|
||||
}
|
||||
|
||||
export function useGlPlanRenderer(
|
||||
canvasRef: React.RefObject<HTMLCanvasElement | null>,
|
||||
enabled: boolean,
|
||||
) {
|
||||
const glRef = useRef<WebGL2RenderingContext | null>(null);
|
||||
const rendererRef = useRef<PlanGpuRenderer | null>(null);
|
||||
const geomRef = useRef<GpuGeometry | null>(null);
|
||||
// GL WIRKLICH aktiv (Kontext + Shader ok)? Steuert den SVG-Fallback: bei false
|
||||
// rendert der Aufrufer den vollen SVG-Pfad, bei true übernimmt die GL-Ebene.
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
// GL-Kontext + Shader einmal aufsetzen.
|
||||
useEffect(() => {
|
||||
if (!enabled || !canvasRef.current) {
|
||||
setReady(false);
|
||||
return;
|
||||
}
|
||||
let disposed = false;
|
||||
try {
|
||||
const gl = canvasRef.current.getContext('webgl2', {
|
||||
antialias: true,
|
||||
alpha: false,
|
||||
premultipliedAlpha: false,
|
||||
});
|
||||
if (!gl) {
|
||||
console.warn('WebGL2 nicht verfügbar → SVG-Fallback');
|
||||
return;
|
||||
}
|
||||
const fillProg = createFillProgram(gl);
|
||||
const lineProg = createLineProgram(gl);
|
||||
if (!fillProg || !lineProg) {
|
||||
console.warn('Shader-Kompilierung fehlgeschlagen → SVG-Fallback');
|
||||
return;
|
||||
}
|
||||
if (disposed) return;
|
||||
glRef.current = gl;
|
||||
const renderer = new PlanGpuRenderer(gl, fillProg, lineProg);
|
||||
// Löschfarbe theme-sicher aus --sheet (Zeichenblatt ist immer „Papier").
|
||||
const sheet = readCssColor('--sheet');
|
||||
if (sheet) renderer.setClearColor(sheet[0], sheet[1], sheet[2]);
|
||||
rendererRef.current = renderer;
|
||||
setReady(true);
|
||||
} catch (e) {
|
||||
console.warn('WebGL2-Init fehlgeschlagen:', e);
|
||||
glRef.current = null;
|
||||
rendererRef.current = null;
|
||||
setReady(false);
|
||||
}
|
||||
return () => {
|
||||
disposed = true;
|
||||
setReady(false);
|
||||
};
|
||||
}, [enabled, canvasRef]);
|
||||
|
||||
/** Geometrie beim Plan-Wechsel neu tessellieren (einmalig, gecacht). */
|
||||
const updateGeometry = useCallback((plan: Plan) => {
|
||||
const gl = glRef.current;
|
||||
if (!gl || !plan) return;
|
||||
try {
|
||||
geomRef.current = compilePrimitivesToGpu(gl, plan.primitives);
|
||||
} catch (e) {
|
||||
console.warn('Tessellierung fehlgeschlagen:', e);
|
||||
geomRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/** Einen Frame zeichnen (Canvas-Größe + dpr hier abgleichen). */
|
||||
const render = useCallback(
|
||||
(viewBox: ViewBox, paperScaleN = 100) => {
|
||||
const gl = glRef.current;
|
||||
const renderer = rendererRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
if (!gl || !renderer || !canvas) return;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const w = Math.max(1, Math.round(rect.width * dpr));
|
||||
const h = Math.max(1, Math.round(rect.height * dpr));
|
||||
if (canvas.width !== w || canvas.height !== h) {
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
}
|
||||
renderer.render(geomRef.current, viewBox, { width: w, height: h }, paperScaleN);
|
||||
},
|
||||
[canvasRef],
|
||||
);
|
||||
|
||||
return { render, updateGeometry, ready };
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
// Hidden-Line-Removal (HLR) über OCCT-WASM — Welle-C-Spike.
|
||||
//
|
||||
// Ziel: aus einfachen Volumenkörpern (Wände, Decke) eine 2D-Vektor-Projektion
|
||||
// (Schnitt/Ansicht) mit ENTFERNTEN verdeckten Kanten erzeugen — die Grundlage,
|
||||
// aus der später die bestehende SVG-Plan-Pipeline Schnitte/Ansichten zeichnet.
|
||||
//
|
||||
// Kernbefund des Spikes (siehe Report): der opencascade.js-Vollbuild 1.1.1
|
||||
// exportiert die klassischen HLR-Klassen (`HLRBRep_Algo`/`HLRBRep_HLRToShape`,
|
||||
// `HLRBRep_PolyAlgo`/`HLRBRep_PolyHLRToShape`) NICHT als konstruierbare Klassen,
|
||||
// sondern nur deren `Handle_…`-Wrapper. Konstruierbar ist aber der High-Level-
|
||||
// Wrapper `HLRAppli_ReflectLines`, der intern GENAU den exakten HLR-Algorithmus
|
||||
// (`HLRBRep_Algo`) fährt und sichtbare/verdeckte Kanten getrennt liefert —
|
||||
// aufgeteilt nach Kanten-Typ (Sharp/OutLine/…). Das ist der hier genutzte Pfad.
|
||||
//
|
||||
// Identifier englisch, Kommentare deutsch (CONVENTIONS.md). Einheiten: METER.
|
||||
|
||||
import {
|
||||
loadOcct,
|
||||
type GpPnt,
|
||||
type OpenCascadeInstance,
|
||||
type TopoDS_Shape,
|
||||
} from "./occt";
|
||||
|
||||
/** 2D-Punkt in der Projektionsebene (Meter). */
|
||||
export interface Pt2 {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/** Achsparalleler Quader: Ursprung (min-Ecke) + Größe (Meter). */
|
||||
export interface BoxSpec {
|
||||
origin: [number, number, number];
|
||||
size: [number, number, number];
|
||||
}
|
||||
|
||||
/**
|
||||
* Orthographische Blickrichtung. `dir` = Blickrichtung (worauf die Kamera
|
||||
* schaut), `up` = Hoch-Achse der Projektionsebene. Voreinstellungen decken die
|
||||
* Ansichtstypen der Roadmap ab (Grundriss = Top, Schnitt/Ansicht = Front/…).
|
||||
*/
|
||||
export interface ViewDir {
|
||||
dir: [number, number, number];
|
||||
up: [number, number, number];
|
||||
}
|
||||
|
||||
/** Häufige Projektionen (rechtshändig, Z = oben). */
|
||||
export const VIEWS = {
|
||||
/** Grundriss: von oben (−Z), Hoch-Achse = +Y. */
|
||||
top: { dir: [0, 0, -1], up: [0, 1, 0] } as ViewDir,
|
||||
/** Ansicht/Schnitt von vorne (−Y), Hoch-Achse = +Z. */
|
||||
front: { dir: [0, -1, 0], up: [0, 0, 1] } as ViewDir,
|
||||
/** Ansicht/Schnitt von der Seite (−X), Hoch-Achse = +Z. */
|
||||
side: { dir: [-1, 0, 0], up: [0, 0, 1] } as ViewDir,
|
||||
} as const;
|
||||
|
||||
/** Eine projizierte Polylinie (2D) mit Sichtbarkeits-Flag. */
|
||||
export interface HlrPolyline {
|
||||
pts: Pt2[];
|
||||
/** `true` = sichtbare Kante, `false` = verdeckte Kante. */
|
||||
visible: boolean;
|
||||
/** Kanten-Herkunft (für spätere Stil-/Linien-Zuordnung). */
|
||||
kind: "sharp" | "outline";
|
||||
}
|
||||
|
||||
/** Ergebnis eines HLR-Laufs. */
|
||||
export interface HlrResult {
|
||||
visible: HlrPolyline[];
|
||||
hidden: HlrPolyline[];
|
||||
/** Reine Rechenzeit des HLR (ohne WASM-Init), Millisekunden. */
|
||||
hlrMs: number;
|
||||
}
|
||||
|
||||
export interface HlrOptions {
|
||||
/** Verdeckte Kanten mitberechnen (Default: true). */
|
||||
includeHidden?: boolean;
|
||||
/** Silhouetten-/OutLine-Kanten mitnehmen (Default: true). */
|
||||
includeOutline?: boolean;
|
||||
}
|
||||
|
||||
// ── Geometrie-Aufbau ─────────────────────────────────────────────────────────
|
||||
|
||||
function makeBox(oc: OpenCascadeInstance, spec: BoxSpec): TopoDS_Shape {
|
||||
const [ox, oy, oz] = spec.origin;
|
||||
const [sx, sy, sz] = spec.size;
|
||||
const p0 = new oc.gp_Pnt_3(ox, oy, oz);
|
||||
const p1 = new oc.gp_Pnt_3(ox + sx, oy + sy, oz + sz);
|
||||
return new oc.BRepPrimAPI_MakeBox_3(p0, p1).Shape();
|
||||
}
|
||||
|
||||
/** Mehrere Boxen zu EINEM Solid verschmelzen (BRepAlgoAPI_Fuse, sequenziell). */
|
||||
function fuseBoxes(oc: OpenCascadeInstance, specs: BoxSpec[]): TopoDS_Shape {
|
||||
if (specs.length === 0) throw new Error("hlr: keine Box-Spezifikation");
|
||||
let acc = makeBox(oc, specs[0]);
|
||||
for (let i = 1; i < specs.length; i++) {
|
||||
const next = makeBox(oc, specs[i]);
|
||||
const op = new oc.BRepAlgoAPI_Fuse_3(acc, next);
|
||||
op.Build?.();
|
||||
acc = op.Shape();
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
// ── Kanten-Extraktion ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Eine (bereits 2D-projizierte) OCCT-Kante als Polylinie ausgeben. Die HLR-
|
||||
* Kanten sind gerade Segmente (Box-Fusion), daher genügen Start-/Endpunkt der
|
||||
* Kurve; für spätere gekrümmte Geometrie hier zusätzlich tessellieren.
|
||||
*/
|
||||
function edgeToPolyline(
|
||||
oc: OpenCascadeInstance,
|
||||
edgeShape: TopoDS_Shape,
|
||||
): Pt2[] {
|
||||
const edge = oc.TopoDS.Edge_1(edgeShape);
|
||||
const adaptor = new oc.BRepAdaptor_Curve_2(edge);
|
||||
const u0 = adaptor.FirstParameter();
|
||||
const u1 = adaptor.LastParameter();
|
||||
const a: GpPnt = adaptor.Value(u0);
|
||||
const b: GpPnt = adaptor.Value(u1);
|
||||
// 2D-Projektion: X = horizontal, Y = vertikal (Z≈0 in der Projektionsebene).
|
||||
return [
|
||||
{ x: a.X(), y: a.Y() },
|
||||
{ x: b.X(), y: b.Y() },
|
||||
];
|
||||
}
|
||||
|
||||
function collectEdges(
|
||||
oc: OpenCascadeInstance,
|
||||
compound: TopoDS_Shape,
|
||||
visible: boolean,
|
||||
kind: "sharp" | "outline",
|
||||
): HlrPolyline[] {
|
||||
const out: HlrPolyline[] = [];
|
||||
const exp = new oc.TopExp_Explorer_2(
|
||||
compound,
|
||||
oc.TopAbs_ShapeEnum.TopAbs_EDGE,
|
||||
oc.TopAbs_ShapeEnum.TopAbs_SHAPE,
|
||||
);
|
||||
for (; exp.More(); exp.Next()) {
|
||||
const pts = edgeToPolyline(oc, exp.Current());
|
||||
if (pts.length >= 2) out.push({ pts, visible, kind });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Öffentliche API ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* HLR direkt auf einem bereits aufgebauten OCCT-Shape ausführen. Liefert
|
||||
* sichtbare (+ optional verdeckte) 2D-Polylinien in der Projektionsebene.
|
||||
*/
|
||||
export function hlrShape(
|
||||
oc: OpenCascadeInstance,
|
||||
shape: TopoDS_Shape,
|
||||
view: ViewDir,
|
||||
opts: HlrOptions = {},
|
||||
): HlrResult {
|
||||
const includeHidden = opts.includeHidden ?? true;
|
||||
const includeOutline = opts.includeOutline ?? true;
|
||||
|
||||
const t0 = performance.now();
|
||||
const rl = new oc.HLRAppli_ReflectLines(shape);
|
||||
const [dx, dy, dz] = view.dir;
|
||||
const [ux, uy, uz] = view.up;
|
||||
// SetAxes(projDir, projPoint(at), upDir). Blickpunkt = Ursprung genügt für
|
||||
// eine orthographische Projektion (Lage in der Ebene ist translationsfrei).
|
||||
rl.SetAxes(dx, dy, dz, 0, 0, 0, ux, uy, uz);
|
||||
rl.Perform();
|
||||
|
||||
const T = oc.HLRBRep_TypeOfResultingEdge;
|
||||
const visible: HlrPolyline[] = [];
|
||||
const hidden: HlrPolyline[] = [];
|
||||
|
||||
// in3d=false → 2D-projizierte Kanten.
|
||||
visible.push(
|
||||
...collectEdges(
|
||||
oc,
|
||||
rl.GetCompoundOf3dEdges(T.HLRBRep_Sharp, true, false),
|
||||
true,
|
||||
"sharp",
|
||||
),
|
||||
);
|
||||
if (includeOutline) {
|
||||
visible.push(
|
||||
...collectEdges(
|
||||
oc,
|
||||
rl.GetCompoundOf3dEdges(T.HLRBRep_OutLine, true, false),
|
||||
true,
|
||||
"outline",
|
||||
),
|
||||
);
|
||||
}
|
||||
if (includeHidden) {
|
||||
hidden.push(
|
||||
...collectEdges(
|
||||
oc,
|
||||
rl.GetCompoundOf3dEdges(T.HLRBRep_Sharp, false, false),
|
||||
false,
|
||||
"sharp",
|
||||
),
|
||||
);
|
||||
if (includeOutline) {
|
||||
hidden.push(
|
||||
...collectEdges(
|
||||
oc,
|
||||
rl.GetCompoundOf3dEdges(T.HLRBRep_OutLine, false, false),
|
||||
false,
|
||||
"outline",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const hlrMs = performance.now() - t0;
|
||||
return { visible, hidden, hlrMs };
|
||||
}
|
||||
|
||||
/**
|
||||
* Komfort-Einstieg: aus Box-Spezifikationen (Wände/Decke) einen Solid fusen und
|
||||
* HLR für die gegebene Blickrichtung laufen lassen. Lädt OCCT lazy.
|
||||
*/
|
||||
export async function hlrFromBoxes(
|
||||
boxes: BoxSpec[],
|
||||
view: ViewDir,
|
||||
opts: HlrOptions = {},
|
||||
): Promise<HlrResult> {
|
||||
const oc = await loadOcct();
|
||||
const shape = fuseBoxes(oc, boxes);
|
||||
return hlrShape(oc, shape, view, opts);
|
||||
}
|
||||
|
||||
// ── SVG-Ausgabe (Diagnose/Proof) ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Minimaler SVG-Dump zur visuellen Kontrolle: sichtbare Kanten durchgezogen,
|
||||
* verdeckte gestrichelt. Kein Bestandteil der Plan-Pipeline — nur Proof/Debug.
|
||||
*/
|
||||
export function hlrToSvg(result: HlrResult, padding = 0.5): string {
|
||||
const all = [...result.visible, ...result.hidden];
|
||||
const xs: number[] = [];
|
||||
const ys: number[] = [];
|
||||
for (const pl of all)
|
||||
for (const p of pl.pts) {
|
||||
xs.push(p.x);
|
||||
ys.push(p.y);
|
||||
}
|
||||
const minX = Math.min(...xs) - padding;
|
||||
const maxX = Math.max(...xs) + padding;
|
||||
const minY = Math.min(...ys) - padding;
|
||||
const maxY = Math.max(...ys) + padding;
|
||||
const w = maxX - minX;
|
||||
const h = maxY - minY;
|
||||
|
||||
// SVG-Y zeigt nach unten → Projektions-Y spiegeln.
|
||||
const line = (pl: HlrPolyline): string => {
|
||||
const d = pl.pts
|
||||
.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x} ${maxY - (p.y - minY)}`)
|
||||
.join(" ");
|
||||
const stroke = pl.visible ? "#111" : "#999";
|
||||
const dash = pl.visible ? "" : ' stroke-dasharray="0.1 0.1"';
|
||||
const sw = pl.kind === "outline" ? 0.04 : 0.02;
|
||||
return `<path d="${d}" fill="none" stroke="${stroke}" stroke-width="${sw}"${dash}/>`;
|
||||
};
|
||||
|
||||
const paths = all.map(line).join("\n ");
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${minX} ${minY} ${w} ${h}" width="800">
|
||||
<rect x="${minX}" y="${minY}" width="${w}" height="${h}" fill="#fff"/>
|
||||
${paths}
|
||||
</svg>`;
|
||||
}
|
||||
Vendored
+25
@@ -0,0 +1,25 @@
|
||||
// Ambient-Deklarationen für die WASM-Glue-/Asset-Importe von opencascade.js
|
||||
// (Welle-C-Spike: Hidden-Line-Removal). Analog zu `src/io/libredwg-web.d.ts`:
|
||||
// das Paket liefert seine `dist/`-Dateien nicht über sinnvolle `exports`, daher
|
||||
// laden wir Emscripten-Glue + `.wasm`-URL über zwei virtuelle Module (Aliase in
|
||||
// vite.config.ts → echte Dateien im Paket). Hier nur schmale Modul-Stubs.
|
||||
// (Identifier englisch, Kommentare deutsch — CONVENTIONS.md.)
|
||||
|
||||
declare module "virtual:occt-glue" {
|
||||
/**
|
||||
* Emscripten-Modul-Fabrik von opencascade.js. Nimmt Modul-Overrides
|
||||
* (u. a. `locateFile`, um die `.wasm`-URL aufzulösen) und liefert das
|
||||
* initialisierte Modul (das gesamte OCCT-Binding als `unknown`).
|
||||
*/
|
||||
const createModule: (opts?: {
|
||||
locateFile?: (filename: string, scriptDir: string) => string;
|
||||
wasmBinary?: ArrayBuffer | Uint8Array;
|
||||
}) => Promise<unknown>;
|
||||
export default createModule;
|
||||
}
|
||||
|
||||
declare module "virtual:occt-wasm-url" {
|
||||
/** Von Vite aufgelöste URL der `.wasm`-Datei (gehashtes Asset im Build). */
|
||||
const url: string;
|
||||
export default url;
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// Lazy-Loader + schmale Typ-Fassade für opencascade.js (OCCT-WASM).
|
||||
//
|
||||
// Welle-C-Spike (Hidden-Line-Removal). Das Paket `opencascade.js` (Vollbuild
|
||||
// 1.1.1) liefert KEINE TypeScript-Typen und exportiert ~20 000 embind-Symbole.
|
||||
// Wir tippen hier NUR die Klassen/Methoden, die `hlr.ts` tatsächlich benutzt —
|
||||
// verifiziert gegen die reale Laufzeit-API (embind versioniert überladene
|
||||
// Konstruktoren als `_1`, `_2`, …).
|
||||
//
|
||||
// Laden erfolgt bewusst über einen dynamischen Import (siehe `loadOcct`), damit
|
||||
// die 66-MB-WASM NICHT ins Haupt-Bundle wandert und nur bei tatsächlichem
|
||||
// Bedarf (Schnitt-/Ansichts-Generierung) geholt wird.
|
||||
//
|
||||
// Identifier englisch, Kommentare deutsch (CONVENTIONS.md).
|
||||
|
||||
// ── Schmale Typ-Fassade (nur das Benutzte) ───────────────────────────────────
|
||||
|
||||
export interface GpPnt {
|
||||
X(): number;
|
||||
Y(): number;
|
||||
Z(): number;
|
||||
}
|
||||
|
||||
export interface TopoDS_Shape {
|
||||
ShapeType(): unknown;
|
||||
IsNull(): boolean;
|
||||
}
|
||||
|
||||
interface TopoDS_Edge extends TopoDS_Shape {}
|
||||
|
||||
interface Ctor1<T, A> {
|
||||
new (a: A): T;
|
||||
}
|
||||
interface Ctor3<T, A, B, C> {
|
||||
new (a: A, b: B, c: C): T;
|
||||
}
|
||||
|
||||
interface ShapeEnum {
|
||||
TopAbs_EDGE: unknown;
|
||||
TopAbs_FACE: unknown;
|
||||
TopAbs_SHAPE: unknown;
|
||||
TopAbs_SOLID: unknown;
|
||||
}
|
||||
|
||||
interface ResultingEdgeEnum {
|
||||
HLRBRep_Sharp: unknown;
|
||||
HLRBRep_OutLine: unknown;
|
||||
HLRBRep_IsoLine: unknown;
|
||||
HLRBRep_Rg1Line: unknown;
|
||||
HLRBRep_RgNLine: unknown;
|
||||
}
|
||||
|
||||
interface TopExpExplorer {
|
||||
More(): boolean;
|
||||
Next(): void;
|
||||
Current(): TopoDS_Shape;
|
||||
}
|
||||
|
||||
interface BRepAdaptorCurve {
|
||||
FirstParameter(): number;
|
||||
LastParameter(): number;
|
||||
Value(u: number): GpPnt;
|
||||
}
|
||||
|
||||
interface MakeBox {
|
||||
Shape(): TopoDS_Shape;
|
||||
}
|
||||
|
||||
interface BooleanOp {
|
||||
Shape(): TopoDS_Shape;
|
||||
Build?(): void;
|
||||
}
|
||||
|
||||
interface ReflectLines {
|
||||
SetAxes(
|
||||
nx: number,
|
||||
ny: number,
|
||||
nz: number,
|
||||
xAt: number,
|
||||
yAt: number,
|
||||
zAt: number,
|
||||
xUp: number,
|
||||
yUp: number,
|
||||
zUp: number,
|
||||
): void;
|
||||
Perform(): void;
|
||||
/**
|
||||
* Kanten-Compound holen. `visible`=true → sichtbar, false → verdeckt.
|
||||
* `in3d`=true → 3D-Kanten, false → 2D-projizierte Kanten (Z≈0 in der
|
||||
* Projektionsebene: X = horizontal, Y = vertikal).
|
||||
*/
|
||||
GetCompoundOf3dEdges(
|
||||
type: unknown,
|
||||
visible: boolean,
|
||||
in3d: boolean,
|
||||
): TopoDS_Shape;
|
||||
}
|
||||
|
||||
/** Nur die von `hlr.ts` genutzten OCCT-Symbole. */
|
||||
export interface OpenCascadeInstance {
|
||||
gp_Pnt_3: Ctor3<GpPnt, number, number, number>;
|
||||
|
||||
BRepPrimAPI_MakeBox_1: Ctor3<MakeBox, number, number, number>;
|
||||
BRepPrimAPI_MakeBox_3: Ctor1<MakeBox, GpPnt> & {
|
||||
new (origin: GpPnt, corner: GpPnt): MakeBox;
|
||||
};
|
||||
|
||||
BRepAlgoAPI_Fuse_3: {
|
||||
new (a: TopoDS_Shape, b: TopoDS_Shape): BooleanOp;
|
||||
};
|
||||
|
||||
HLRAppli_ReflectLines: Ctor1<ReflectLines, TopoDS_Shape>;
|
||||
|
||||
TopExp_Explorer_2: {
|
||||
new (s: TopoDS_Shape, toFind: unknown, toAvoid: unknown): TopExpExplorer;
|
||||
};
|
||||
|
||||
BRepAdaptor_Curve_2: Ctor1<BRepAdaptorCurve, TopoDS_Edge>;
|
||||
|
||||
TopoDS: { Edge_1(s: TopoDS_Shape): TopoDS_Edge };
|
||||
|
||||
TopAbs_ShapeEnum: ShapeEnum;
|
||||
HLRBRep_TypeOfResultingEdge: ResultingEdgeEnum;
|
||||
}
|
||||
|
||||
// ── Lazy-Loader ──────────────────────────────────────────────────────────────
|
||||
|
||||
type OcctFactory = (opts?: {
|
||||
locateFile?: (filename: string, scriptDir: string) => string;
|
||||
wasmBinary?: ArrayBuffer | Uint8Array;
|
||||
}) => Promise<unknown>;
|
||||
|
||||
let occtPromise: Promise<OpenCascadeInstance> | null = null;
|
||||
|
||||
/** Optionale Metriken des letzten Ladevorgangs (für den Feasibility-Report). */
|
||||
export interface LoadMetrics {
|
||||
wasmBytes: number;
|
||||
fetchMs: number;
|
||||
initMs: number;
|
||||
}
|
||||
let lastMetrics: LoadMetrics | null = null;
|
||||
export const getLoadMetrics = (): LoadMetrics | null => lastMetrics;
|
||||
|
||||
/**
|
||||
* Lädt + initialisiert OCCT-WASM (lazy, einmalig). Glue + `.wasm`-URL werden —
|
||||
* wie bei LibreDWG — über virtuelle Vite-Module aufgelöst, damit die WASM in Dev
|
||||
* UND Build mit korrektem MIME-Type geladen wird. Der dynamische Import hält die
|
||||
* 66-MB-WASM aus dem Haupt-Bundle heraus.
|
||||
*/
|
||||
export function loadOcct(): Promise<OpenCascadeInstance> {
|
||||
if (!occtPromise) {
|
||||
occtPromise = (async () => {
|
||||
const [glueMod, wasmUrlMod] = await Promise.all([
|
||||
import("virtual:occt-glue"),
|
||||
import("virtual:occt-wasm-url"),
|
||||
]);
|
||||
const createModule = glueMod.default as OcctFactory;
|
||||
const wasmUrl = wasmUrlMod.default;
|
||||
|
||||
// WASM einmal explizit holen → Größe messen + als `wasmBinary` reichen
|
||||
// (spart Emscripten den zweiten Fetch, macht die Größe messbar).
|
||||
const tFetch = performance.now();
|
||||
const resp = await fetch(wasmUrl);
|
||||
const wasmBinary = await resp.arrayBuffer();
|
||||
const fetchMs = performance.now() - tFetch;
|
||||
|
||||
const tInit = performance.now();
|
||||
const instance = (await createModule({
|
||||
locateFile: () => wasmUrl,
|
||||
wasmBinary,
|
||||
})) as OpenCascadeInstance;
|
||||
const initMs = performance.now() - tInit;
|
||||
|
||||
lastMetrics = { wasmBytes: wasmBinary.byteLength, fetchMs, initMs };
|
||||
return instance;
|
||||
})();
|
||||
}
|
||||
return occtPromise;
|
||||
}
|
||||
+231
-1
@@ -24,6 +24,7 @@ import type {
|
||||
} from "../model/types";
|
||||
import { commitTransform } from "../tools/transform";
|
||||
import type { CopyMode, TransformOp, TransformSelection } from "../tools/transform";
|
||||
import { centroid as roomCentroid } from "../geometry/roomArea";
|
||||
import { t } from "../i18n";
|
||||
import type { StoreApi } from "./store";
|
||||
|
||||
@@ -161,6 +162,74 @@ export interface ProjectSlice {
|
||||
* Dicke nur auf diese Wand und teilt keine Presets versehentlich.
|
||||
*/
|
||||
setWallThickness: (wallId: string, thickness: number) => void;
|
||||
|
||||
// ── Decken-Editieren ──────────────────────────────────────────────────────
|
||||
/** Verschiebt EINEN Umriss-Vertex (per Index) einer Decke. */
|
||||
moveCeilingGrip: (ceilingId: string, index: number, pt: Vec2) => void;
|
||||
/** Verschiebt eine Decke (ganzer Umriss) um `delta`. */
|
||||
moveCeilingBy: (ceilingId: string, delta: Vec2) => void;
|
||||
/** Verschiebt eine Umriss-KANTE (beide Vertices) einer Decke um `delta`. */
|
||||
moveCeilingEdge: (
|
||||
ceilingId: string,
|
||||
aIndex: number,
|
||||
bIndex: number,
|
||||
delta: Vec2,
|
||||
) => void;
|
||||
/** Generischer immutabler Patch auf eine Decke (per ID). */
|
||||
updateCeiling: (
|
||||
id: string,
|
||||
patch: Partial<import("../model/types").Ceiling>,
|
||||
) => void;
|
||||
/** Setzt die Gesamtdicke einer Decke (thickness-Übersteuerung, Meter). */
|
||||
setCeilingThickness: (ceilingId: string, thickness: number) => void;
|
||||
|
||||
// ── Öffnungs-Editieren (Object-Info-Panel) ────────────────────────────────
|
||||
/** Generischer immutabler Patch auf eine Öffnung (per ID). */
|
||||
updateOpening: (
|
||||
id: string,
|
||||
patch: Partial<import("../model/types").Opening>,
|
||||
) => void;
|
||||
|
||||
// ── Treppen-Editieren ──────────────────────────────────────────────────────
|
||||
/** Verschiebt eine Treppe (Antritt) um `delta`. */
|
||||
moveStairBy: (stairId: string, delta: Vec2) => void;
|
||||
/** Generischer immutabler Patch auf eine Treppe (per ID). */
|
||||
updateStair: (
|
||||
id: string,
|
||||
patch: Partial<import("../model/types").Stair>,
|
||||
) => void;
|
||||
|
||||
// ── Raum-Editieren ──────────────────────────────────────────────────────────
|
||||
/** Verschiebt einen Raum (ganzer Umriss + optionaler Stempel-Anker) um `delta`. */
|
||||
moveRoomBy: (roomId: string, delta: Vec2) => void;
|
||||
/** Verschiebt EINEN Umriss-Vertex (per Index) eines Raums. */
|
||||
moveRoomGrip: (roomId: string, index: number, pt: Vec2) => void;
|
||||
/** Verschiebt eine Umriss-KANTE (beide Vertices) eines Raums um `delta`. */
|
||||
moveRoomEdge: (
|
||||
roomId: string,
|
||||
aIndex: number,
|
||||
bIndex: number,
|
||||
delta: Vec2,
|
||||
) => void;
|
||||
/** Generischer immutabler Patch auf einen Raum (per ID). */
|
||||
updateRoom: (
|
||||
id: string,
|
||||
patch: Partial<import("../model/types").Room>,
|
||||
) => void;
|
||||
/** Verschiebt NUR den Stempel-Anker eines Raums um `delta` (Kontur bleibt). */
|
||||
moveRoomStamp: (roomId: string, delta: Vec2) => void;
|
||||
/** Setzt den Stempel-Anker eines Raums absolut. */
|
||||
setRoomStampAnchor: (roomId: string, anchor: Vec2) => void;
|
||||
/** Setzt den Rich-Text des Raum-Stempels. */
|
||||
setRoomStampDoc: (
|
||||
roomId: string,
|
||||
doc: import("../model/types").RichTextDoc,
|
||||
) => void;
|
||||
/** Setzt den strukturierten Raum-Stempel (Feldmodell). */
|
||||
setRoomStamp: (
|
||||
roomId: string,
|
||||
stamp: import("../model/types").RoomStamp,
|
||||
) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -309,7 +378,13 @@ export function createProjectSlice(
|
||||
p.drawingLevels.filter((z) => z.id !== id),
|
||||
),
|
||||
walls: p.walls.filter((w) => w.floorId !== id),
|
||||
ceilings: (p.ceilings ?? []).filter((c) => c.floorId !== id),
|
||||
stairs: (p.stairs ?? []).filter((s) => s.floorId !== id),
|
||||
rooms: (p.rooms ?? []).filter((r) => r.floorId !== id),
|
||||
doors: p.doors.filter((d) => !removedWallIds.has(d.hostWallId)),
|
||||
openings: (p.openings ?? []).filter(
|
||||
(o) => !removedWallIds.has(o.hostWallId),
|
||||
),
|
||||
};
|
||||
});
|
||||
},
|
||||
@@ -376,7 +451,10 @@ export function createProjectSlice(
|
||||
const codes = new Set(flattenCategories([src]).map((c) => c.code));
|
||||
const usedByWall = p.walls.some((w) => codes.has(w.categoryCode));
|
||||
const usedByDoor = p.doors.some((d) => codes.has(d.categoryCode));
|
||||
if (usedByWall || usedByDoor) {
|
||||
const usedByOpening = (p.openings ?? []).some((o) =>
|
||||
codes.has(o.categoryCode),
|
||||
);
|
||||
if (usedByWall || usedByDoor || usedByOpening) {
|
||||
window.alert(t("alert.layerInUse", { code: src.code, name: src.name }));
|
||||
return p;
|
||||
}
|
||||
@@ -630,6 +708,158 @@ export function createProjectSlice(
|
||||
|
||||
setWallThickness: (wallId, thickness) =>
|
||||
setProject((p) => setWallThickness(p, wallId, thickness)),
|
||||
|
||||
// ── Decken-Editieren ──────────────────────────────────────────────────
|
||||
moveCeilingGrip: (ceilingId, index, pt) =>
|
||||
setProject((p) => mapCeiling(p, ceilingId, (c) => ({
|
||||
...c,
|
||||
outline: c.outline.map((v, i) => (i === index ? pt : v)),
|
||||
}))),
|
||||
|
||||
moveCeilingBy: (ceilingId, delta) =>
|
||||
setProject((p) => mapCeiling(p, ceilingId, (c) => ({
|
||||
...c,
|
||||
outline: c.outline.map((v) => ({ x: v.x + delta.x, y: v.y + delta.y })),
|
||||
}))),
|
||||
|
||||
moveCeilingEdge: (ceilingId, aIndex, bIndex, delta) =>
|
||||
setProject((p) => mapCeiling(p, ceilingId, (c) => ({
|
||||
...c,
|
||||
outline: c.outline.map((v, i) =>
|
||||
i === aIndex || i === bIndex ? { x: v.x + delta.x, y: v.y + delta.y } : v,
|
||||
),
|
||||
}))),
|
||||
|
||||
updateCeiling: (id, patch) =>
|
||||
setProject((p) => mapCeiling(p, id, (c) => ({ ...c, ...patch }))),
|
||||
|
||||
setCeilingThickness: (ceilingId, thickness) =>
|
||||
setProject((p) =>
|
||||
!isFinite(thickness) || thickness <= 0
|
||||
? p
|
||||
: mapCeiling(p, ceilingId, (c) => ({ ...c, thickness })),
|
||||
),
|
||||
|
||||
// ── Öffnungs-Editieren ────────────────────────────────────────────────
|
||||
updateOpening: (id, patch) =>
|
||||
setProject((p) => ({
|
||||
...p,
|
||||
openings: (p.openings ?? []).map((o) => (o.id === id ? { ...o, ...patch } : o)),
|
||||
})),
|
||||
|
||||
// ── Treppen-Editieren ──────────────────────────────────────────────────
|
||||
moveStairBy: (stairId, delta) =>
|
||||
setProject((p) =>
|
||||
mapStair(p, stairId, (s) => ({
|
||||
...s,
|
||||
start: { x: s.start.x + delta.x, y: s.start.y + delta.y },
|
||||
...(s.center
|
||||
? { center: { x: s.center.x + delta.x, y: s.center.y + delta.y } }
|
||||
: {}),
|
||||
})),
|
||||
),
|
||||
|
||||
updateStair: (id, patch) =>
|
||||
setProject((p) => mapStair(p, id, (s) => ({ ...s, ...patch }))),
|
||||
|
||||
// ── Raum-Editieren ──────────────────────────────────────────────────────
|
||||
moveRoomBy: (roomId, delta) =>
|
||||
setProject((p) =>
|
||||
mapRoom(p, roomId, (r) => ({
|
||||
...r,
|
||||
boundary: r.boundary.map((v) => ({ x: v.x + delta.x, y: v.y + delta.y })),
|
||||
...(r.stampAnchor
|
||||
? {
|
||||
stampAnchor: {
|
||||
x: r.stampAnchor.x + delta.x,
|
||||
y: r.stampAnchor.y + delta.y,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
})),
|
||||
),
|
||||
|
||||
moveRoomGrip: (roomId, index, pt) =>
|
||||
setProject((p) =>
|
||||
mapRoom(p, roomId, (r) => ({
|
||||
...r,
|
||||
boundary: r.boundary.map((v, i) => (i === index ? pt : v)),
|
||||
})),
|
||||
),
|
||||
|
||||
moveRoomEdge: (roomId, aIndex, bIndex, delta) =>
|
||||
setProject((p) =>
|
||||
mapRoom(p, roomId, (r) => ({
|
||||
...r,
|
||||
boundary: r.boundary.map((v, i) =>
|
||||
i === aIndex || i === bIndex
|
||||
? { x: v.x + delta.x, y: v.y + delta.y }
|
||||
: v,
|
||||
),
|
||||
})),
|
||||
),
|
||||
|
||||
updateRoom: (id, patch) =>
|
||||
setProject((p) => mapRoom(p, id, (r) => ({ ...r, ...patch }))),
|
||||
|
||||
moveRoomStamp: (roomId, delta) =>
|
||||
setProject((p) =>
|
||||
mapRoom(p, roomId, (r) => {
|
||||
const base = r.stampAnchor ?? roomCentroid(r.boundary);
|
||||
return {
|
||||
...r,
|
||||
stampAnchor: { x: base.x + delta.x, y: base.y + delta.y },
|
||||
};
|
||||
}),
|
||||
),
|
||||
|
||||
setRoomStampAnchor: (roomId, anchor) =>
|
||||
setProject((p) => mapRoom(p, roomId, (r) => ({ ...r, stampAnchor: anchor }))),
|
||||
|
||||
setRoomStampDoc: (roomId, doc) =>
|
||||
setProject((p) => mapRoom(p, roomId, (r) => ({ ...r, stampDoc: doc }))),
|
||||
|
||||
setRoomStamp: (roomId, stamp) =>
|
||||
setProject((p) => mapRoom(p, roomId, (r) => ({ ...r, stamp }))),
|
||||
};
|
||||
}
|
||||
|
||||
/** Immutabler Map über einen Raum (per ID); No-op ohne Treffer. */
|
||||
function mapRoom(
|
||||
project: Project,
|
||||
roomId: string,
|
||||
fn: (r: import("../model/types").Room) => import("../model/types").Room,
|
||||
): Project {
|
||||
const rooms = project.rooms ?? [];
|
||||
return {
|
||||
...project,
|
||||
rooms: rooms.map((r) => (r.id === roomId ? fn(r) : r)),
|
||||
};
|
||||
}
|
||||
|
||||
/** Immutabler Map über eine Treppe (per ID); No-op ohne Treffer. */
|
||||
function mapStair(
|
||||
project: Project,
|
||||
stairId: string,
|
||||
fn: (s: import("../model/types").Stair) => import("../model/types").Stair,
|
||||
): Project {
|
||||
const stairs = project.stairs ?? [];
|
||||
return {
|
||||
...project,
|
||||
stairs: stairs.map((s) => (s.id === stairId ? fn(s) : s)),
|
||||
};
|
||||
}
|
||||
|
||||
/** Immutabler Map über eine Decke (per ID); No-op ohne Treffer. */
|
||||
function mapCeiling(
|
||||
project: Project,
|
||||
ceilingId: string,
|
||||
fn: (c: import("../model/types").Ceiling) => import("../model/types").Ceiling,
|
||||
): Project {
|
||||
const ceilings = project.ceilings ?? [];
|
||||
return {
|
||||
...project,
|
||||
ceilings: ceilings.map((c) => (c.id === ceilingId ? fn(c) : c)),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+325
-3
@@ -10,17 +10,38 @@
|
||||
//
|
||||
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
|
||||
|
||||
import { flattenCategories, getWallType, wallTypeThickness } from "../model/types";
|
||||
import {
|
||||
ceilingThickness,
|
||||
flattenCategories,
|
||||
getCeilingType,
|
||||
getWallType,
|
||||
wallTypeThickness,
|
||||
} from "../model/types";
|
||||
import type {
|
||||
Ceiling,
|
||||
Drawing2D,
|
||||
Drawing2DGeom,
|
||||
LayerCategory,
|
||||
Opening,
|
||||
Project,
|
||||
Room,
|
||||
SiaCategory,
|
||||
Stair,
|
||||
StairShape,
|
||||
VerticalAnchor,
|
||||
Wall,
|
||||
WallReferenceLine,
|
||||
} from "../model/types";
|
||||
import { nextFloorAbove, wallVerticalExtent } from "../model/wall";
|
||||
import { evaluateRoom } from "../geometry/roomArea";
|
||||
import {
|
||||
ceilingVerticalExtent,
|
||||
nextFloorAbove,
|
||||
stairVerticalExtent,
|
||||
wallVerticalExtent,
|
||||
} from "../model/wall";
|
||||
import { ceilingArea, outlineBBox } from "../geometry/ceiling";
|
||||
import { openingGapQuad, openingVerticalExtent } from "../geometry/opening";
|
||||
import { stairGeometry, stairBBox } from "../geometry/stair";
|
||||
|
||||
/** Eintrag für die WallType-Auswahl (Preset-Dropdown) im Object-Info-Panel. */
|
||||
export interface WallTypeChoice {
|
||||
@@ -68,6 +89,102 @@ export interface WallInfo {
|
||||
zTop: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decken-spezifische Attribute für das Object-Info-Panel (nur bei Auswahl genau
|
||||
* einer Decke gesetzt). Anzeigefertige Werte + Listen für die Dropdowns.
|
||||
*/
|
||||
export interface CeilingInfo {
|
||||
wallTypeId: string;
|
||||
/** Aktuelle Gesamtdicke (Meter). */
|
||||
thickness: number;
|
||||
/** Ob der aktuelle Aufbau-Typ einschichtig ist (1 Schicht). */
|
||||
singleLayer: boolean;
|
||||
/** Grundfläche der Decke (m²). */
|
||||
area: number;
|
||||
/** Verfügbare Aufbau-Typ-Presets (wie WallType). */
|
||||
wallTypes: WallTypeChoice[];
|
||||
/** Geschoss, dem die Decke zugeordnet ist. */
|
||||
floorId: string;
|
||||
floorName: string;
|
||||
/** Alle Geschosse (für die OK-„an Geschoss gebunden"-Auswahl). */
|
||||
floors: FloorChoice[];
|
||||
/** OK-Bindung (undefined = Geschoss-Oberkante). */
|
||||
top?: VerticalAnchor;
|
||||
/** Aufgelöste absolute UK/OK (Meter). */
|
||||
zBottom: number;
|
||||
zTop: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Öffnungs-spezifische Attribute für das Object-Info-Panel (nur bei Auswahl
|
||||
* genau einer Öffnung gesetzt). Anzeigefertige Werte + Wirts-Wand-Bezug.
|
||||
*/
|
||||
export interface OpeningInfo {
|
||||
kind: "window" | "door";
|
||||
/** Wirts-Wand (ID + Name/Geschoss zur Anzeige). */
|
||||
hostWallId: string;
|
||||
hostWallName: string;
|
||||
/** Abstand vom Wand-Startpunkt (Meter). */
|
||||
position: number;
|
||||
width: number;
|
||||
height: number;
|
||||
/** Brüstungshöhe (Meter); 0 bei Türen. */
|
||||
sillHeight: number;
|
||||
/** Nur Tür: Anschlag/Aufschlag/Winkel/Richtung. */
|
||||
hinge?: "start" | "end";
|
||||
swing?: "left" | "right";
|
||||
swingAngle?: number;
|
||||
openingDir?: "in" | "out";
|
||||
/** Aufgelöste absolute UK/OK (Meter). */
|
||||
zBottom: number;
|
||||
zTop: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Treppen-spezifische Attribute für das Object-Info-Panel (nur bei Auswahl genau
|
||||
* einer Treppe gesetzt). Anzeigefertige Werte + Listen für die Dropdowns.
|
||||
*/
|
||||
export interface StairInfo {
|
||||
shape: StairShape;
|
||||
/** Laufbreite (Meter). */
|
||||
width: number;
|
||||
/** Stufenanzahl (Setzstufen). */
|
||||
stepCount: number;
|
||||
/** Gesamt-Steighöhe (Meter, OKFF → OKFF). */
|
||||
totalRise: number;
|
||||
/** Abgeleitete Steigungshöhe (Meter) = totalRise / stepCount. */
|
||||
riserHeight: number;
|
||||
/** Abgeleitete Auftrittstiefe (Meter). */
|
||||
treadDepth: number;
|
||||
/** Laufrichtung aufwärts (Auf-/Abpfeil-Richtung). */
|
||||
up: boolean;
|
||||
/** Geschoss, dem die Treppe zugeordnet ist. */
|
||||
floorId: string;
|
||||
floorName: string;
|
||||
/** Aufgelöste absolute UK/OK (Meter). */
|
||||
zBottom: number;
|
||||
zTop: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Raum-spezifische Attribute für das Object-Info-Panel (nur bei Auswahl genau
|
||||
* eines Raums gesetzt). Anzeigefertige Werte; Fläche/Umfang werden aus dem
|
||||
* Umriss abgeleitet (nie gespeichert).
|
||||
*/
|
||||
export interface RoomInfo {
|
||||
/** Raum-Name (editierbar). */
|
||||
name: string;
|
||||
/** SIA-416-Blatt-Kategorie (HNF/NNF/VF/FF/KGF). */
|
||||
siaCategory: SiaCategory;
|
||||
/** Netto-anrechenbare Fläche (m²). */
|
||||
area: number;
|
||||
/** Umfang des Umrisses (m). */
|
||||
perimeter: number;
|
||||
/** Geschoss, dem der Raum zugeordnet ist. */
|
||||
floorId: string;
|
||||
floorName: string;
|
||||
}
|
||||
|
||||
/** Achsparallele Bounding-Box eines Elements in Modell-Metern. */
|
||||
export interface SelectionBBox {
|
||||
minX: number;
|
||||
@@ -82,7 +199,7 @@ export interface SelectionBBox {
|
||||
* gezeichneten Wert zeigt. `fillHatchId`/`closed` sind nur für Drawing2D sinnvoll.
|
||||
*/
|
||||
export interface Selection {
|
||||
kind: "wall" | "drawing2d" | "door";
|
||||
kind: "wall" | "ceiling" | "opening" | "stair" | "room" | "drawing2d" | "door";
|
||||
id: string;
|
||||
/** Grafik-Kategorie (Ebene) des Elements. */
|
||||
categoryCode: string;
|
||||
@@ -109,6 +226,14 @@ export interface Selection {
|
||||
* (Wandtyp/Dicke), Referenzgeschoss + UK/OK für den Wand-Abschnitt im Panel.
|
||||
*/
|
||||
wall?: WallInfo;
|
||||
/** Decken-Attribute (nur bei `kind === "ceiling"`). */
|
||||
ceiling?: CeilingInfo;
|
||||
/** Öffnungs-Attribute (nur bei `kind === "opening"`). */
|
||||
opening?: OpeningInfo;
|
||||
/** Treppen-Attribute (nur bei `kind === "stair"`). */
|
||||
stair?: StairInfo;
|
||||
/** Raum-Attribute (nur bei `kind === "room"`). */
|
||||
room?: RoomInfo;
|
||||
}
|
||||
|
||||
const WALL_FALLBACK_MM = 0.18;
|
||||
@@ -222,6 +347,183 @@ function wallSelection(project: Project, wall: Wall): Selection {
|
||||
};
|
||||
}
|
||||
|
||||
/** Selektion für eine Decke ableiten (Farbe übersteuerbar, Strichstärke = Kategorie). */
|
||||
function ceilingSelection(project: Project, ceiling: Ceiling): Selection {
|
||||
const cat = findCategory(project.layers, ceiling.categoryCode);
|
||||
const color = ceiling.color ?? cat?.color ?? WALL_FALLBACK_COLOR;
|
||||
const weightMm = cat?.lw ?? WALL_FALLBACK_MM;
|
||||
const box = outlineBBox(ceiling.outline);
|
||||
const wt = getCeilingType(project, ceiling);
|
||||
const thickness = ceilingThickness(project, ceiling);
|
||||
const floor = project.drawingLevels.find((z) => z.id === ceiling.floorId);
|
||||
const { zBottom, zTop } = ceilingVerticalExtent(project, ceiling);
|
||||
const ceilingInfo: CeilingInfo = {
|
||||
wallTypeId: ceiling.wallTypeId,
|
||||
thickness,
|
||||
singleLayer: wt.layers.length <= 1,
|
||||
area: ceilingArea(ceiling.outline),
|
||||
wallTypes: project.wallTypes.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
thickness: wallTypeThickness(t),
|
||||
layerCount: t.layers.length,
|
||||
})),
|
||||
floorId: ceiling.floorId,
|
||||
floorName: floor?.name ?? ceiling.floorId,
|
||||
floors: project.drawingLevels
|
||||
.filter((z) => z.kind === "floor")
|
||||
.map((z) => ({ id: z.id, name: z.name })),
|
||||
top: ceiling.top,
|
||||
zBottom,
|
||||
zTop,
|
||||
};
|
||||
return {
|
||||
kind: "ceiling",
|
||||
id: ceiling.id,
|
||||
categoryCode: ceiling.categoryCode,
|
||||
color,
|
||||
weightMm,
|
||||
fillHatchId: undefined,
|
||||
fillColor: undefined,
|
||||
closed: true,
|
||||
bbox: { minX: box.minX, minY: box.minY, maxX: box.maxX, maxY: box.maxY },
|
||||
ceiling: ceilingInfo,
|
||||
};
|
||||
}
|
||||
|
||||
/** Selektion für eine Öffnung ableiten (Farbe übersteuerbar, Strichstärke = Kategorie). */
|
||||
function openingSelection(project: Project, o: Opening): Selection {
|
||||
const cat = findCategory(project.layers, o.categoryCode);
|
||||
const color = o.color ?? cat?.color ?? WALL_FALLBACK_COLOR;
|
||||
const weightMm = cat?.lw ?? WALL_FALLBACK_MM;
|
||||
const wall = project.walls.find((w) => w.id === o.hostWallId);
|
||||
const floor = wall
|
||||
? project.drawingLevels.find((z) => z.id === wall.floorId)
|
||||
: undefined;
|
||||
// Bounding-Box aus dem Lücken-Quad (volle Wanddicke); Fallback: leere Box.
|
||||
const quad = wall ? openingGapQuad(project, wall, o) : null;
|
||||
let minX = Infinity,
|
||||
minY = Infinity,
|
||||
maxX = -Infinity,
|
||||
maxY = -Infinity;
|
||||
for (const p of quad ?? []) {
|
||||
minX = Math.min(minX, p.x);
|
||||
minY = Math.min(minY, p.y);
|
||||
maxX = Math.max(maxX, p.x);
|
||||
maxY = Math.max(maxY, p.y);
|
||||
}
|
||||
if (!isFinite(minX)) {
|
||||
minX = minY = maxX = maxY = 0;
|
||||
}
|
||||
const ext = wall
|
||||
? openingVerticalExtent(project, wall, o)
|
||||
: { zBottom: 0, zTop: o.height };
|
||||
const openingInfo: OpeningInfo = {
|
||||
kind: o.kind,
|
||||
hostWallId: o.hostWallId,
|
||||
hostWallName: floor ? `${floor.name}` : o.hostWallId,
|
||||
position: o.position,
|
||||
width: o.width,
|
||||
height: o.height,
|
||||
sillHeight: o.sillHeight,
|
||||
hinge: o.hinge,
|
||||
swing: o.swing,
|
||||
swingAngle: o.swingAngle,
|
||||
openingDir: o.openingDir,
|
||||
zBottom: ext.zBottom,
|
||||
zTop: ext.zTop,
|
||||
};
|
||||
return {
|
||||
kind: "opening",
|
||||
id: o.id,
|
||||
categoryCode: o.categoryCode,
|
||||
color,
|
||||
weightMm,
|
||||
fillHatchId: undefined,
|
||||
fillColor: undefined,
|
||||
closed: undefined,
|
||||
bbox: { minX, minY, maxX, maxY },
|
||||
opening: openingInfo,
|
||||
};
|
||||
}
|
||||
|
||||
/** Selektion für eine Treppe ableiten (Farbe übersteuerbar, Strichstärke = Kategorie). */
|
||||
function stairSelection(project: Project, s: Stair): Selection {
|
||||
const cat = findCategory(project.layers, s.categoryCode);
|
||||
const color = s.color ?? cat?.color ?? WALL_FALLBACK_COLOR;
|
||||
const weightMm = cat?.lw ?? WALL_FALLBACK_MM;
|
||||
const { zBottom, zTop } = stairVerticalExtent(project, s);
|
||||
const totalRise = zTop - zBottom;
|
||||
const geo = stairGeometry(s, totalRise);
|
||||
const box = stairBBox(geo);
|
||||
const floor = project.drawingLevels.find((z) => z.id === s.floorId);
|
||||
const stairInfo: StairInfo = {
|
||||
shape: s.shape,
|
||||
width: s.width,
|
||||
stepCount: s.stepCount,
|
||||
totalRise,
|
||||
riserHeight: geo.riserHeight,
|
||||
treadDepth: geo.treadDepth,
|
||||
up: s.up !== false,
|
||||
floorId: s.floorId,
|
||||
floorName: floor?.name ?? s.floorId,
|
||||
zBottom,
|
||||
zTop,
|
||||
};
|
||||
return {
|
||||
kind: "stair",
|
||||
id: s.id,
|
||||
categoryCode: s.categoryCode,
|
||||
color,
|
||||
weightMm,
|
||||
fillHatchId: undefined,
|
||||
fillColor: undefined,
|
||||
closed: undefined,
|
||||
bbox: { minX: box.minX, minY: box.minY, maxX: box.maxX, maxY: box.maxY },
|
||||
stair: stairInfo,
|
||||
};
|
||||
}
|
||||
|
||||
/** Selektion für einen Raum ableiten (Fläche/Umfang aus dem Umriss abgeleitet). */
|
||||
function roomSelection(project: Project, r: Room): Selection {
|
||||
const cat = findCategory(project.layers, r.categoryCode);
|
||||
const color = r.color ?? cat?.color ?? WALL_FALLBACK_COLOR;
|
||||
const weightMm = cat?.lw ?? WALL_FALLBACK_MM;
|
||||
const res = evaluateRoom(r.boundary, r.siaCategory);
|
||||
const floor = project.drawingLevels.find((z) => z.id === r.floorId);
|
||||
let minX = Infinity,
|
||||
minY = Infinity,
|
||||
maxX = -Infinity,
|
||||
maxY = -Infinity;
|
||||
for (const p of r.boundary) {
|
||||
minX = Math.min(minX, p.x);
|
||||
minY = Math.min(minY, p.y);
|
||||
maxX = Math.max(maxX, p.x);
|
||||
maxY = Math.max(maxY, p.y);
|
||||
}
|
||||
if (!isFinite(minX)) minX = minY = maxX = maxY = 0;
|
||||
const roomInfo: RoomInfo = {
|
||||
name: r.name,
|
||||
siaCategory: r.siaCategory,
|
||||
area: res.netArea,
|
||||
perimeter: res.perimeter,
|
||||
floorId: r.floorId,
|
||||
floorName: floor?.name ?? r.floorId,
|
||||
};
|
||||
return {
|
||||
kind: "room",
|
||||
id: r.id,
|
||||
categoryCode: r.categoryCode,
|
||||
color,
|
||||
weightMm,
|
||||
fillHatchId: undefined,
|
||||
fillColor: undefined,
|
||||
closed: true,
|
||||
bbox: { minX, minY, maxX, maxY },
|
||||
room: roomInfo,
|
||||
};
|
||||
}
|
||||
|
||||
/** Selektion für ein 2D-Zeichenelement ableiten (gleiche Auflösung wie generatePlan). */
|
||||
function drawingSelection(project: Project, d: Drawing2D): Selection {
|
||||
const ls = d.lineStyleId
|
||||
@@ -254,15 +556,35 @@ export function deriveSelection(
|
||||
project: Project,
|
||||
selectedWallIds: string[],
|
||||
selectedDrawingId: string | null,
|
||||
selectedCeilingId: string | null = null,
|
||||
selectedOpeningId: string | null = null,
|
||||
selectedStairId: string | null = null,
|
||||
selectedRoomId: string | null = null,
|
||||
): Selection | null {
|
||||
if (selectedDrawingId) {
|
||||
const d = project.drawings2d.find((x) => x.id === selectedDrawingId);
|
||||
return d ? drawingSelection(project, d) : null;
|
||||
}
|
||||
if (selectedOpeningId) {
|
||||
const o = (project.openings ?? []).find((x) => x.id === selectedOpeningId);
|
||||
if (o) return openingSelection(project, o);
|
||||
}
|
||||
const wallId = selectedWallIds[0];
|
||||
if (wallId) {
|
||||
const w = project.walls.find((x) => x.id === wallId);
|
||||
return w ? wallSelection(project, w) : null;
|
||||
}
|
||||
if (selectedCeilingId) {
|
||||
const c = (project.ceilings ?? []).find((x) => x.id === selectedCeilingId);
|
||||
return c ? ceilingSelection(project, c) : null;
|
||||
}
|
||||
if (selectedStairId) {
|
||||
const s = (project.stairs ?? []).find((x) => x.id === selectedStairId);
|
||||
return s ? stairSelection(project, s) : null;
|
||||
}
|
||||
if (selectedRoomId) {
|
||||
const r = (project.rooms ?? []).find((x) => x.id === selectedRoomId);
|
||||
return r ? roomSelection(project, r) : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -11,10 +11,22 @@ export interface SelectionSlice {
|
||||
// Gewählte 2D-Zeichenelemente als MENGE (Marquee/Mehrfach/Einzel) — getrennt
|
||||
// von der Wand-Auswahl. Einzel-Auswahl = Array der Länge 1.
|
||||
selectedDrawingIds: string[];
|
||||
// Gewählte Decken als MENGE von IDs — getrennt von Wand-/2D-Auswahl.
|
||||
selectedCeilingIds: string[];
|
||||
// Gewählte Öffnungen (Fenster/Türen) als MENGE von IDs — getrennt.
|
||||
selectedOpeningIds: string[];
|
||||
// Gewählte Treppen als MENGE von IDs — getrennt von den übrigen Kanälen.
|
||||
selectedStairIds: string[];
|
||||
// Gewählte Räume als MENGE von IDs — getrennt von den übrigen Kanälen.
|
||||
selectedRoomIds: string[];
|
||||
|
||||
setSelectedWallIds: (ids: string[]) => void;
|
||||
setSelectedDrawingIds: (ids: string[]) => void;
|
||||
/** Auswahl (Wände + 2D-Elemente) vollständig leeren. */
|
||||
setSelectedCeilingIds: (ids: string[]) => void;
|
||||
setSelectedOpeningIds: (ids: string[]) => void;
|
||||
setSelectedStairIds: (ids: string[]) => void;
|
||||
setSelectedRoomIds: (ids: string[]) => void;
|
||||
/** Auswahl (Wände + Decken + Öffnungen + Treppen + 2D-Elemente) leeren. */
|
||||
clearSelection: () => void;
|
||||
}
|
||||
|
||||
@@ -25,9 +37,24 @@ export function createSelectionSlice(
|
||||
return {
|
||||
selectedWallIds: [],
|
||||
selectedDrawingIds: [],
|
||||
selectedCeilingIds: [],
|
||||
selectedOpeningIds: [],
|
||||
selectedStairIds: [],
|
||||
selectedRoomIds: [],
|
||||
setSelectedWallIds: (ids) => set({ selectedWallIds: ids }),
|
||||
setSelectedDrawingIds: (ids) => set({ selectedDrawingIds: ids }),
|
||||
setSelectedCeilingIds: (ids) => set({ selectedCeilingIds: ids }),
|
||||
setSelectedOpeningIds: (ids) => set({ selectedOpeningIds: ids }),
|
||||
setSelectedStairIds: (ids) => set({ selectedStairIds: ids }),
|
||||
setSelectedRoomIds: (ids) => set({ selectedRoomIds: ids }),
|
||||
clearSelection: () =>
|
||||
set({ selectedWallIds: [], selectedDrawingIds: [] }),
|
||||
set({
|
||||
selectedWallIds: [],
|
||||
selectedDrawingIds: [],
|
||||
selectedCeilingIds: [],
|
||||
selectedOpeningIds: [],
|
||||
selectedStairIds: [],
|
||||
selectedRoomIds: [],
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
+1043
-18
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,727 @@
|
||||
// WYSIWYG Rich-Text-Editor-Komponente. Kontrolliert: Props `value`/`onChange`
|
||||
// steuern den Dokumentzustand; keine interne Dokumentkopie. Toolbar mit
|
||||
// Bold/Italic/Underline/Strikethrough, Schriftgrösse, Farbe und Presets.
|
||||
//
|
||||
// Selektions-Mapping: DOM-Textknoten werden in Dokumentreihenfolge durchlaufen;
|
||||
// zwischen Block-Elementen (div/p) zählt je eine Absatzgrenze als +1. So entsteht
|
||||
// ein linearer Zeichen-Offset, der mit TextRange kompatibel ist.
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { t } from "../i18n";
|
||||
import { docToHtml } from "./renderHtml";
|
||||
import {
|
||||
applyMark,
|
||||
applyPreset,
|
||||
commonMarkValue,
|
||||
DEFAULT_PRESETS,
|
||||
docLength,
|
||||
emptyDoc,
|
||||
isMarkActive,
|
||||
normalizeDoc,
|
||||
serialize,
|
||||
toggleMark,
|
||||
} from "./richText";
|
||||
import type {
|
||||
BoolMark,
|
||||
Marks,
|
||||
RichTextDoc,
|
||||
TextRange,
|
||||
TextStylePreset,
|
||||
} from "./richText";
|
||||
|
||||
// ── Props ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface RichTextEditorProps {
|
||||
value: RichTextDoc;
|
||||
onChange: (doc: RichTextDoc) => void;
|
||||
presets?: TextStylePreset[];
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
|
||||
// ── DOM ↔ Modell-Offset-Übersetzung ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Wandelt eine DOM-Position (Node + Offset innerhalb des Nodes) in einen
|
||||
* linearen Modell-Offset um. Die contenteditable enthält pro Absatz ein <div>,
|
||||
* darin <span>-Runs; leere Absätze haben ein <br>.
|
||||
* Linearer Modell-Offset = Summe der Zeichen in vorigen Absätzen
|
||||
* + Absatz-Index als Trennzeichen-Offset (+1 je Absatzumbruch)
|
||||
* + Zeichen innerhalb des aktuellen Absatzes bis zur Position.
|
||||
*/
|
||||
function domOffsetToModelOffset(
|
||||
editorEl: HTMLElement,
|
||||
targetNode: Node,
|
||||
targetOffset: number,
|
||||
): number {
|
||||
const divs = Array.from(editorEl.children) as HTMLElement[];
|
||||
let modelOffset = 0;
|
||||
|
||||
for (let pIdx = 0; pIdx < divs.length; pIdx++) {
|
||||
const div = divs[pIdx];
|
||||
if (div === targetNode || div.contains(targetNode)) {
|
||||
let found = false;
|
||||
let paraOffset = 0;
|
||||
|
||||
const walkPara = (node: Node): boolean => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
if (node === targetNode) {
|
||||
paraOffset += targetOffset;
|
||||
found = true;
|
||||
return true;
|
||||
}
|
||||
paraOffset += (node as Text).length;
|
||||
return false;
|
||||
}
|
||||
for (const child of Array.from(node.childNodes)) {
|
||||
if ((child as Element).tagName === "BR") {
|
||||
if (child === targetNode || node === targetNode) {
|
||||
found = true;
|
||||
return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (walkPara(child)) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
if (targetNode === div) {
|
||||
const children = Array.from(div.childNodes);
|
||||
const limit = Math.min(targetOffset, children.length);
|
||||
for (let i = 0; i < limit; i++) {
|
||||
const child = children[i];
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
paraOffset += (child as Text).length;
|
||||
} else {
|
||||
paraOffset += (child as Element).textContent?.length ?? 0;
|
||||
}
|
||||
}
|
||||
found = true;
|
||||
} else {
|
||||
walkPara(div);
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
paraOffset = div.textContent?.replace(/\n/g, "").length ?? 0;
|
||||
}
|
||||
|
||||
return modelOffset + paraOffset;
|
||||
}
|
||||
|
||||
const paraText = div.textContent ?? "";
|
||||
modelOffset += paraText.length + 1; // +1 für '\n'-Trennzeichen
|
||||
}
|
||||
|
||||
return modelOffset;
|
||||
}
|
||||
|
||||
/**
|
||||
* Findet für einen linearen Modell-Offset den DOM-Node und Offset darin.
|
||||
*/
|
||||
function modelOffsetToDomPos(
|
||||
editorEl: HTMLElement,
|
||||
modelOffset: number,
|
||||
): { node: Node; offset: number } | null {
|
||||
const divs = Array.from(editorEl.children) as HTMLElement[];
|
||||
let remaining = modelOffset;
|
||||
|
||||
for (let pIdx = 0; pIdx < divs.length; pIdx++) {
|
||||
const div = divs[pIdx];
|
||||
const paraLen = div.textContent?.length ?? 0;
|
||||
|
||||
if (remaining <= paraLen) {
|
||||
let found: { node: Node; offset: number } | null = null;
|
||||
let chars = remaining;
|
||||
|
||||
const walk = (node: Node): boolean => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
const len = (node as Text).length;
|
||||
if (chars <= len) {
|
||||
found = { node, offset: chars };
|
||||
return true;
|
||||
}
|
||||
chars -= len;
|
||||
return false;
|
||||
}
|
||||
if ((node as Element).tagName === "BR") {
|
||||
if (chars === 0) {
|
||||
found = { node: div, offset: 0 };
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
for (const child of Array.from(node.childNodes)) {
|
||||
if (walk(child)) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
walk(div);
|
||||
|
||||
if (!found) {
|
||||
found = { node: div, offset: div.childNodes.length };
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
remaining -= paraLen + 1;
|
||||
}
|
||||
|
||||
const lastDiv = divs[divs.length - 1];
|
||||
if (!lastDiv) return null;
|
||||
return { node: lastDiv, offset: lastDiv.childNodes.length };
|
||||
}
|
||||
|
||||
// ── Plain-Text aus Editor-DOM ────────────────────────────────────────────────
|
||||
|
||||
function editorInnerText(editorEl: HTMLElement): string {
|
||||
const divs = Array.from(editorEl.children) as HTMLElement[];
|
||||
if (divs.length === 0) {
|
||||
return editorEl.innerText.replace(/\n$/, "");
|
||||
}
|
||||
return divs
|
||||
.map((div) => {
|
||||
if (div.children.length === 1 && (div.children[0] as Element).tagName === "BR") {
|
||||
return "";
|
||||
}
|
||||
return div.textContent ?? "";
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
// ── Dokument aus bearbeitetem Text rekonstruieren ────────────────────────────
|
||||
|
||||
function rebuildDocFromText(oldDoc: RichTextDoc, newPlain: string): RichTextDoc {
|
||||
const newLines = newPlain.split("\n");
|
||||
const oldParas = oldDoc.paragraphs;
|
||||
|
||||
const paragraphs = newLines.map((line, pIdx) => {
|
||||
const oldPara = oldParas[pIdx];
|
||||
if (!oldPara) {
|
||||
return { runs: line.length ? [{ text: line, marks: {} }] : [] };
|
||||
}
|
||||
|
||||
type MarkChar = { ch: string; marks: Marks };
|
||||
const oldChars: MarkChar[] = [];
|
||||
for (const run of oldPara.runs) {
|
||||
for (const ch of run.text) {
|
||||
oldChars.push({ ch, marks: run.marks });
|
||||
}
|
||||
}
|
||||
|
||||
const newChars: MarkChar[] = [];
|
||||
let oldIdx = 0;
|
||||
for (const ch of line) {
|
||||
if (oldIdx < oldChars.length && oldChars[oldIdx].ch === ch) {
|
||||
newChars.push(oldChars[oldIdx]);
|
||||
oldIdx++;
|
||||
} else {
|
||||
const nearMarks =
|
||||
oldIdx < oldChars.length ? oldChars[oldIdx].marks : (oldChars[oldIdx - 1]?.marks ?? {});
|
||||
newChars.push({ ch, marks: nearMarks });
|
||||
}
|
||||
}
|
||||
|
||||
if (newChars.length === 0) return { runs: [] };
|
||||
|
||||
const runs: { text: string; marks: Marks }[] = [];
|
||||
let current = { text: newChars[0].ch, marks: newChars[0].marks };
|
||||
for (let i = 1; i < newChars.length; i++) {
|
||||
const { ch, marks } = newChars[i];
|
||||
if (JSON.stringify(marks) === JSON.stringify(current.marks)) {
|
||||
current.text += ch;
|
||||
} else {
|
||||
runs.push(current);
|
||||
current = { text: ch, marks };
|
||||
}
|
||||
}
|
||||
runs.push(current);
|
||||
|
||||
return { runs };
|
||||
});
|
||||
|
||||
return normalizeDoc({ version: 1, paragraphs });
|
||||
}
|
||||
|
||||
// ── Absatz-Einfügung / Löschung ──────────────────────────────────────────────
|
||||
|
||||
function insertParagraphBreak(doc: RichTextDoc, pos: number): RichTextDoc {
|
||||
const paras = doc.paragraphs;
|
||||
let remaining = pos;
|
||||
let pIdx = 0;
|
||||
let charOffset = 0;
|
||||
|
||||
for (let i = 0; i < paras.length; i++) {
|
||||
const len = paras[i].runs.reduce((n, r) => n + r.text.length, 0);
|
||||
if (remaining <= len) {
|
||||
pIdx = i;
|
||||
charOffset = remaining;
|
||||
break;
|
||||
}
|
||||
remaining -= len + 1;
|
||||
if (i === paras.length - 1) {
|
||||
return normalizeDoc({ version: 1, paragraphs: [...paras, { runs: [] }] });
|
||||
}
|
||||
}
|
||||
|
||||
const para = paras[pIdx];
|
||||
type MarkChar = { ch: string; marks: Marks };
|
||||
const chars: MarkChar[] = [];
|
||||
for (const run of para.runs) {
|
||||
for (const ch of run.text) {
|
||||
chars.push({ ch, marks: run.marks });
|
||||
}
|
||||
}
|
||||
|
||||
const buildPara = (chs: MarkChar[]) => {
|
||||
if (chs.length === 0) return { runs: [] };
|
||||
const runs: { text: string; marks: Marks }[] = [];
|
||||
let cur = { text: chs[0].ch, marks: chs[0].marks };
|
||||
for (let i = 1; i < chs.length; i++) {
|
||||
if (JSON.stringify(chs[i].marks) === JSON.stringify(cur.marks)) {
|
||||
cur.text += chs[i].ch;
|
||||
} else {
|
||||
runs.push(cur);
|
||||
cur = { text: chs[i].ch, marks: chs[i].marks };
|
||||
}
|
||||
}
|
||||
runs.push(cur);
|
||||
return { runs };
|
||||
};
|
||||
|
||||
const before = buildPara(chars.slice(0, charOffset));
|
||||
const after = buildPara(chars.slice(charOffset));
|
||||
const newParas = [
|
||||
...paras.slice(0, pIdx),
|
||||
{ ...before, align: para.align },
|
||||
{ ...after, align: para.align },
|
||||
...paras.slice(pIdx + 1),
|
||||
];
|
||||
return normalizeDoc({ version: 1, paragraphs: newParas });
|
||||
}
|
||||
|
||||
function plainTextFromDoc(doc: RichTextDoc): string {
|
||||
return doc.paragraphs.map((p) => p.runs.map((r) => r.text).join("")).join("\n");
|
||||
}
|
||||
|
||||
function deleteCharAt(doc: RichTextDoc, pos: number): RichTextDoc {
|
||||
if (pos < 0) return doc;
|
||||
const plain = plainTextFromDoc(doc);
|
||||
if (pos >= plain.length) return doc;
|
||||
|
||||
if (plain[pos] === "\n") {
|
||||
return mergeParaAt(doc, pos);
|
||||
}
|
||||
|
||||
const newPlain = plain.slice(0, pos) + plain.slice(pos + 1);
|
||||
return rebuildDocFromText(doc, newPlain);
|
||||
}
|
||||
|
||||
function mergeParaAt(doc: RichTextDoc, _pos: number): RichTextDoc {
|
||||
const paras = doc.paragraphs;
|
||||
let cursor = 0;
|
||||
for (let i = 0; i < paras.length - 1; i++) {
|
||||
const len = paras[i].runs.reduce((n, r) => n + r.text.length, 0);
|
||||
cursor += len;
|
||||
if (cursor === _pos) {
|
||||
const merged = {
|
||||
runs: [...paras[i].runs, ...paras[i + 1].runs],
|
||||
align: paras[i].align,
|
||||
};
|
||||
const newParas = [...paras.slice(0, i), merged, ...paras.slice(i + 2)];
|
||||
return normalizeDoc({ version: 1, paragraphs: newParas });
|
||||
}
|
||||
cursor += 1;
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
// ── Selektion lesen / wiederherstellen ──────────────────────────────────────
|
||||
|
||||
function readSelection(editorEl: HTMLElement): TextRange | null {
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.rangeCount === 0) return null;
|
||||
const range = sel.getRangeAt(0);
|
||||
if (!editorEl.contains(range.commonAncestorContainer)) return null;
|
||||
|
||||
const start = domOffsetToModelOffset(editorEl, range.startContainer, range.startOffset);
|
||||
const end = domOffsetToModelOffset(editorEl, range.endContainer, range.endOffset);
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
function restoreSelection(editorEl: HTMLElement, selRange: TextRange | null): void {
|
||||
if (!selRange) return;
|
||||
const sel = window.getSelection();
|
||||
if (!sel) return;
|
||||
|
||||
const startPos = modelOffsetToDomPos(editorEl, selRange.start);
|
||||
const endPos = modelOffsetToDomPos(editorEl, selRange.end);
|
||||
if (!startPos || !endPos) return;
|
||||
|
||||
try {
|
||||
const range = document.createRange();
|
||||
range.setStart(startPos.node, startPos.offset);
|
||||
range.setEnd(endPos.node, endPos.offset);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
} catch {
|
||||
// Ungültige DOM-Position ignorieren.
|
||||
}
|
||||
}
|
||||
|
||||
// ── Komponente ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function RichTextEditor({
|
||||
value,
|
||||
onChange,
|
||||
presets,
|
||||
autoFocus,
|
||||
}: RichTextEditorProps): JSX.Element {
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
// Letzter in innerHTML geschriebener serialisierter Zustand — verhindert
|
||||
// Cursor-Sprünge durch unnötige DOM-Updates.
|
||||
const lastSerializedRef = useRef<string>("");
|
||||
// Verhindert das nächste onInput nach programmatischem innerHTML-Set.
|
||||
const suppressNextInput = useRef(false);
|
||||
// Ausgewählter Bereich im linearen Modell-Offset-Raum.
|
||||
const [selRange, setSelRange] = useState<TextRange | null>(null);
|
||||
// Immer aktuelles value/onChange ohne Stale-Closure-Problem (keine useCallback-Deps).
|
||||
const valueRef = useRef<RichTextDoc>(value);
|
||||
const onChangeRef = useRef<(doc: RichTextDoc) => void>(onChange);
|
||||
valueRef.current = value;
|
||||
onChangeRef.current = onChange;
|
||||
|
||||
const effectivePresets = presets ?? DEFAULT_PRESETS;
|
||||
|
||||
// ── HTML-Renderer ──────────────────────────────────────────────────────────
|
||||
|
||||
const renderHtml = useCallback((d: RichTextDoc): string => {
|
||||
return docToHtml(d, { basePt: 12, color: "var(--ink)" });
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Setzt innerHTML direkt. `suppressInput` steuert, ob das daraus folgende
|
||||
* Browser-onInput-Ereignis unterdrückt wird (true nur bei programmatischen
|
||||
* Edits aus Keyboard-/Toolbar-Handlern, nicht beim externen Prop-Update).
|
||||
*/
|
||||
const setEditorHtml = useCallback(
|
||||
(d: RichTextDoc, restoreSel: TextRange | null = null, suppressInput = false) => {
|
||||
const el = editorRef.current;
|
||||
if (!el) return;
|
||||
const serial = serialize(d);
|
||||
if (serial !== lastSerializedRef.current) {
|
||||
if (suppressInput) suppressNextInput.current = true;
|
||||
lastSerializedRef.current = serial;
|
||||
el.innerHTML = renderHtml(d);
|
||||
}
|
||||
if (restoreSel) {
|
||||
restoreSelection(el, restoreSel);
|
||||
}
|
||||
},
|
||||
[renderHtml],
|
||||
);
|
||||
|
||||
// ── Initiale Render + externe prop-Änderungen ──────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
const el = editorRef.current;
|
||||
if (!el) return;
|
||||
// Programmatisches innerHTML-Set feuert kein Browser-onInput — kein suppress nötig.
|
||||
const serial = serialize(value);
|
||||
lastSerializedRef.current = serial;
|
||||
el.innerHTML = renderHtml(value);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // Nur beim Mount.
|
||||
|
||||
useEffect(() => {
|
||||
// Externe prop-Änderungen übernehmen ohne Cursor zu versetzen.
|
||||
setEditorHtml(value, null);
|
||||
}, [value, setEditorHtml]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocus) {
|
||||
editorRef.current?.focus();
|
||||
}
|
||||
}, [autoFocus]);
|
||||
|
||||
// ── Selektion verfolgen ────────────────────────────────────────────────────
|
||||
|
||||
const captureSelection = useCallback(() => {
|
||||
const el = editorRef.current;
|
||||
if (!el) return;
|
||||
const range = readSelection(el);
|
||||
setSelRange(range);
|
||||
}, []);
|
||||
|
||||
// ── Eingabe-Handler ────────────────────────────────────────────────────────
|
||||
|
||||
const handleInput = useCallback(() => {
|
||||
if (suppressNextInput.current) {
|
||||
suppressNextInput.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const el = editorRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const currentSel = readSelection(el);
|
||||
const newPlain = editorInnerText(el);
|
||||
const newDoc = rebuildDocFromText(valueRef.current, newPlain);
|
||||
|
||||
const serial = serialize(newDoc);
|
||||
lastSerializedRef.current = serial;
|
||||
onChangeRef.current(newDoc);
|
||||
|
||||
// HTML neu setzen (ohne suppressNextInput — programmatische DOM-Änderungen
|
||||
// lösen kein natives input-Event aus) und Selektion wiederherstellen.
|
||||
el.innerHTML = renderHtml(newDoc);
|
||||
if (currentSel) restoreSelection(el, currentSel);
|
||||
setSelRange(currentSel);
|
||||
}, [renderHtml]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const el = editorRef.current;
|
||||
if (!el) return;
|
||||
const currentSel = readSelection(el);
|
||||
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const cur = valueRef.current;
|
||||
const pos = currentSel?.start ?? plainTextFromDoc(cur).length;
|
||||
const newDoc = insertParagraphBreak(cur, pos);
|
||||
onChangeRef.current(newDoc);
|
||||
const newSel: TextRange = { start: pos + 1, end: pos + 1 };
|
||||
setEditorHtml(newDoc, newSel, true);
|
||||
setSelRange(newSel);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "Backspace") {
|
||||
if (currentSel && currentSel.start !== currentSel.end) {
|
||||
return;
|
||||
}
|
||||
const pos = currentSel?.start ?? 0;
|
||||
if (pos === 0) return;
|
||||
e.preventDefault();
|
||||
const cur = valueRef.current;
|
||||
const newDoc = deleteCharAt(cur, pos - 1);
|
||||
const newPos = pos - 1;
|
||||
onChangeRef.current(newDoc);
|
||||
const newSel: TextRange = { start: newPos, end: newPos };
|
||||
setEditorHtml(newDoc, newSel, true);
|
||||
setSelRange(newSel);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "Delete") {
|
||||
if (currentSel && currentSel.start !== currentSel.end) {
|
||||
return;
|
||||
}
|
||||
const pos = currentSel?.start ?? 0;
|
||||
const cur = valueRef.current;
|
||||
const plain = plainTextFromDoc(cur);
|
||||
if (pos >= plain.length) return;
|
||||
e.preventDefault();
|
||||
const newDoc = deleteCharAt(cur, pos);
|
||||
onChangeRef.current(newDoc);
|
||||
const newSel: TextRange = { start: pos, end: pos };
|
||||
setEditorHtml(newDoc, newSel, true);
|
||||
setSelRange(newSel);
|
||||
}
|
||||
},
|
||||
[setEditorHtml],
|
||||
);
|
||||
|
||||
// ── Toolbar-Aktionen ───────────────────────────────────────────────────────
|
||||
|
||||
const effectiveRange = useCallback((): TextRange => {
|
||||
if (selRange && selRange.start !== selRange.end) return selRange;
|
||||
const len = docLength(valueRef.current);
|
||||
return { start: 0, end: Math.max(1, len) };
|
||||
}, [selRange]);
|
||||
|
||||
const handleToggleMark = useCallback(
|
||||
(mark: BoolMark) => {
|
||||
const range = effectiveRange();
|
||||
const newDoc = toggleMark(valueRef.current, range, mark);
|
||||
onChangeRef.current(newDoc);
|
||||
setEditorHtml(newDoc, selRange, true);
|
||||
},
|
||||
[effectiveRange, selRange, setEditorHtml],
|
||||
);
|
||||
|
||||
const handleSizeChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const v = parseFloat(e.target.value);
|
||||
if (isNaN(v) || v <= 0) return;
|
||||
const range = effectiveRange();
|
||||
const newDoc = applyMark(valueRef.current, range, "sizePt", v);
|
||||
onChangeRef.current(newDoc);
|
||||
setEditorHtml(newDoc, selRange, true);
|
||||
},
|
||||
[effectiveRange, selRange, setEditorHtml],
|
||||
);
|
||||
|
||||
const handleColorChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const range = effectiveRange();
|
||||
const newDoc = applyMark(valueRef.current, range, "color", e.target.value);
|
||||
onChangeRef.current(newDoc);
|
||||
setEditorHtml(newDoc, selRange, true);
|
||||
},
|
||||
[effectiveRange, selRange, setEditorHtml],
|
||||
);
|
||||
|
||||
const handlePreset = useCallback(
|
||||
(e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const id = e.target.value;
|
||||
const preset = effectivePresets.find((p) => p.id === id);
|
||||
if (!preset) return;
|
||||
const range = selRange ?? { start: 0, end: 0 };
|
||||
const newDoc = applyPreset(valueRef.current, range, preset);
|
||||
onChangeRef.current(newDoc);
|
||||
setEditorHtml(newDoc, selRange, true);
|
||||
e.target.value = "";
|
||||
},
|
||||
[effectivePresets, selRange, setEditorHtml],
|
||||
);
|
||||
|
||||
// ── Toolbar-Zustand berechnen ──────────────────────────────────────────────
|
||||
|
||||
const queryRange: TextRange =
|
||||
selRange && selRange.start !== selRange.end
|
||||
? selRange
|
||||
: { start: 0, end: Math.max(1, docLength(value)) };
|
||||
|
||||
const isBold = isMarkActive(value, queryRange, "bold");
|
||||
const isItalic = isMarkActive(value, queryRange, "italic");
|
||||
const isUnderline = isMarkActive(value, queryRange, "underline");
|
||||
const isStrike = isMarkActive(value, queryRange, "strike");
|
||||
|
||||
const currentSize = commonMarkValue(value, queryRange, "sizePt") ?? 12;
|
||||
const rawColor = commonMarkValue(value, queryRange, "color") ?? "#ededed";
|
||||
const currentColor = rawColor.startsWith("#") ? rawColor : "#ededed";
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div className="rt-editor">
|
||||
<div className="rt-toolbar">
|
||||
{/* Fett */}
|
||||
<button
|
||||
type="button"
|
||||
className={`rt-btn${isBold ? " rt-btn--active" : ""}`}
|
||||
title={t("rt.bold")}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
handleToggleMark("bold");
|
||||
}}
|
||||
>
|
||||
<strong>B</strong>
|
||||
</button>
|
||||
|
||||
{/* Kursiv */}
|
||||
<button
|
||||
type="button"
|
||||
className={`rt-btn${isItalic ? " rt-btn--active" : ""}`}
|
||||
title={t("rt.italic")}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
handleToggleMark("italic");
|
||||
}}
|
||||
>
|
||||
<em>I</em>
|
||||
</button>
|
||||
|
||||
{/* Unterstrichen */}
|
||||
<button
|
||||
type="button"
|
||||
className={`rt-btn${isUnderline ? " rt-btn--active" : ""}`}
|
||||
title={t("rt.underline")}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
handleToggleMark("underline");
|
||||
}}
|
||||
>
|
||||
<u>U</u>
|
||||
</button>
|
||||
|
||||
{/* Durchgestrichen */}
|
||||
<button
|
||||
type="button"
|
||||
className={`rt-btn${isStrike ? " rt-btn--active" : ""}`}
|
||||
title={t("rt.strike")}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
handleToggleMark("strike");
|
||||
}}
|
||||
>
|
||||
<s>S</s>
|
||||
</button>
|
||||
|
||||
<div className="rt-toolbar-sep" />
|
||||
|
||||
{/* Schriftgrösse */}
|
||||
<label className="rt-toolbar-label" title={t("rt.fontSize")}>
|
||||
<input
|
||||
type="number"
|
||||
className="rt-size-input"
|
||||
min={6}
|
||||
max={96}
|
||||
step={1}
|
||||
value={currentSize}
|
||||
onChange={handleSizeChange}
|
||||
title={t("rt.fontSize")}
|
||||
/>
|
||||
<span className="rt-unit">pt</span>
|
||||
</label>
|
||||
|
||||
{/* Farbe */}
|
||||
<input
|
||||
type="color"
|
||||
className="rt-color-input"
|
||||
value={currentColor}
|
||||
onChange={handleColorChange}
|
||||
title={t("rt.color")}
|
||||
/>
|
||||
|
||||
{/* Presets */}
|
||||
{effectivePresets.length > 0 && (
|
||||
<select
|
||||
className="rt-preset-select"
|
||||
defaultValue=""
|
||||
onChange={handlePreset}
|
||||
title={t("rt.preset")}
|
||||
>
|
||||
<option value="" disabled>
|
||||
{t("rt.preset")} …
|
||||
</option>
|
||||
{effectivePresets.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Editierbare Fläche */}
|
||||
<div
|
||||
ref={editorRef}
|
||||
className="rt-surface"
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
spellCheck={false}
|
||||
onInput={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
onSelect={captureSelection}
|
||||
onKeyUp={captureSelection}
|
||||
onMouseUp={captureSelection}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default RichTextEditor;
|
||||
export { emptyDoc };
|
||||
@@ -0,0 +1,212 @@
|
||||
// Render-Adapter für das Rich-Text-Modell (richText.ts): einmal nach HTML/CSS
|
||||
// (für den WYSIWYG-Editor und die Bildschirm-Vorschau auf dem Canvas) und einmal
|
||||
// nach SVG-<tspan> (für den vektoriellen Grundriss-/Print-Renderer). Beide Wege
|
||||
// bilden dieselbe Mark→Stil-Zuordnung ab, damit Editor-Vorschau und gezeichneter
|
||||
// Text identisch aussehen. Reines Modul: keine React-/App-Abhängigkeit.
|
||||
|
||||
import type { Marks, Paragraph, RichTextDoc, TextRun } from "./richText";
|
||||
|
||||
// ── Text-Escaping ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Escapt Text für HTML/XML/SVG-Inhalt (kein Attribut). */
|
||||
export function escapeText(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
/** Escapt einen Wert für ein doppelt-gequotetes XML-Attribut. */
|
||||
function escapeAttr(s: string): string {
|
||||
return escapeText(s).replace(/"/g, """);
|
||||
}
|
||||
|
||||
// ── Gemeinsame Mark→Stil-Zuordnung ──────────────────────────────────────────
|
||||
|
||||
/** Kombiniert underline + strike zu einem CSS/SVG-text-decoration-Wert. */
|
||||
function textDecoration(marks: Marks): string | undefined {
|
||||
const parts: string[] = [];
|
||||
if (marks.underline) parts.push("underline");
|
||||
if (marks.strike) parts.push("line-through");
|
||||
return parts.length ? parts.join(" ") : undefined;
|
||||
}
|
||||
|
||||
/** Faktor, um den super-/sub-Text zu verkleinern. */
|
||||
const SCRIPT_SCALE = 0.7;
|
||||
|
||||
/**
|
||||
* CSS-Deklarationen (als `{prop: value}`) für einen Run. `basePt` ist die
|
||||
* Basisschriftgrösse in pt, falls der Run selbst keine `sizePt` trägt.
|
||||
*/
|
||||
export function runCssProps(marks: Marks, basePt = 12): Record<string, string> {
|
||||
const css: Record<string, string> = {};
|
||||
if (marks.bold) css["font-weight"] = "700";
|
||||
if (marks.italic) css["font-style"] = "italic";
|
||||
const deco = textDecoration(marks);
|
||||
if (deco) css["text-decoration"] = deco;
|
||||
if (marks.font) css["font-family"] = marks.font;
|
||||
const sizePt = (marks.sizePt ?? basePt) * (marks.super || marks.sub ? SCRIPT_SCALE : 1);
|
||||
css["font-size"] = `${round(sizePt)}pt`;
|
||||
if (marks.color) css["color"] = marks.color;
|
||||
if (marks.super) css["vertical-align"] = "super";
|
||||
if (marks.sub) css["vertical-align"] = "sub";
|
||||
return css;
|
||||
}
|
||||
|
||||
function cssPropsToString(css: Record<string, string>): string {
|
||||
return Object.entries(css)
|
||||
.map(([k, v]) => `${k}:${v}`)
|
||||
.join(";");
|
||||
}
|
||||
|
||||
function round(n: number): number {
|
||||
return Math.round(n * 1000) / 1000;
|
||||
}
|
||||
|
||||
// ── HTML-Render (Editor / Canvas-Vorschau) ──────────────────────────────────
|
||||
|
||||
export interface HtmlOptions {
|
||||
/** Basisschriftgrösse in pt für Runs ohne eigene Grösse. */
|
||||
basePt?: number;
|
||||
/** Standardfarbe (CSS) falls kein Run eine Farbe setzt. */
|
||||
color?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rendert das Dokument zu einem sanitisierten HTML-String: ein `<div>` pro
|
||||
* Absatz (mit `text-align`), darin ein `<span>` pro Run mit Inline-Styles.
|
||||
* Leere Absätze erhalten ein `<br>`, damit die Zeilenhöhe erhalten bleibt.
|
||||
*/
|
||||
export function docToHtml(doc: RichTextDoc, opts: HtmlOptions = {}): string {
|
||||
const basePt = opts.basePt ?? 12;
|
||||
return doc.paragraphs
|
||||
.map((p) => paragraphToHtml(p, basePt, opts.color))
|
||||
.join("");
|
||||
}
|
||||
|
||||
function paragraphToHtml(p: Paragraph, basePt: number, color?: string): string {
|
||||
const align = p.align && p.align !== "left" ? ` style="text-align:${p.align}"` : "";
|
||||
if (p.runs.length === 0) return `<div${align}><br></div>`;
|
||||
const inner = p.runs.map((r) => runToHtmlSpan(r, basePt, color)).join("");
|
||||
return `<div${align}>${inner}</div>`;
|
||||
}
|
||||
|
||||
function runToHtmlSpan(run: TextRun, basePt: number, color?: string): string {
|
||||
const css = runCssProps(run.marks, basePt);
|
||||
if (color && !css["color"]) css["color"] = color;
|
||||
const style = cssPropsToString(css);
|
||||
const text = escapeText(run.text) || "";
|
||||
return `<span style="${escapeAttr(style)}">${text}</span>`;
|
||||
}
|
||||
|
||||
// ── SVG-Render (Grundriss / Print) ──────────────────────────────────────────
|
||||
|
||||
/** Beschreibung eines einzelnen <tspan> (ein Run innerhalb einer Zeile). */
|
||||
export interface SvgTspan {
|
||||
text: string;
|
||||
/** Schriftgrösse in Benutzereinheiten (bereits umgerechnet). */
|
||||
fontSize: number;
|
||||
fontWeight?: "700";
|
||||
fontStyle?: "italic";
|
||||
fontFamily?: string;
|
||||
fill: string;
|
||||
textDecoration?: string;
|
||||
/** Vertikaler Versatz für Hoch-/Tiefstellung, in Benutzereinheiten. */
|
||||
baselineShift?: number;
|
||||
}
|
||||
|
||||
/** Eine gerenderte Zeile (= Absatz) mit ihren Runs. */
|
||||
export interface SvgLine {
|
||||
tspans: SvgTspan[];
|
||||
align: "left" | "center" | "right";
|
||||
}
|
||||
|
||||
export interface SvgTextOptions {
|
||||
/** Ankerpunkt X in Benutzereinheiten. */
|
||||
x: number;
|
||||
/** Baseline der ERSTEN Zeile, Y in Benutzereinheiten. */
|
||||
y: number;
|
||||
/** Zeilenvorschub (Baseline zu Baseline) in Benutzereinheiten. */
|
||||
lineHeight: number;
|
||||
/**
|
||||
* Umrechnung Schriftgrösse: `sizePt` × `unitPerPt` = Grösse in Benutzereinheiten.
|
||||
* Der Aufrufer bestimmt so, ob Text in px, mm oder Metern gezeichnet wird.
|
||||
*/
|
||||
unitPerPt: number;
|
||||
/** Basis-`sizePt` für Runs ohne eigene Grösse. */
|
||||
basePt?: number;
|
||||
/** Standardfarbe "#rrggbb" für Runs ohne Farbe. */
|
||||
color?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zerlegt das Dokument in Zeilen/Tspans mit bereits in Benutzereinheiten
|
||||
* umgerechneten Grössen. Der Aufrufer positioniert die Zeilen selbst (Baseline
|
||||
* der Zeile i = `y + i·lineHeight`).
|
||||
*/
|
||||
export function docToLines(doc: RichTextDoc, opts: SvgTextOptions): SvgLine[] {
|
||||
const basePt = opts.basePt ?? 12;
|
||||
const color = opts.color ?? "#000000";
|
||||
return doc.paragraphs.map((p) => ({
|
||||
align: p.align ?? "left",
|
||||
tspans: p.runs
|
||||
.filter((r) => r.text.length > 0)
|
||||
.map((r) => runToTspan(r, basePt, opts.unitPerPt, color)),
|
||||
}));
|
||||
}
|
||||
|
||||
function runToTspan(run: TextRun, basePt: number, unitPerPt: number, color: string): SvgTspan {
|
||||
const m = run.marks;
|
||||
const script = m.super || m.sub;
|
||||
const sizePt = (m.sizePt ?? basePt) * (script ? SCRIPT_SCALE : 1);
|
||||
const fontSize = sizePt * unitPerPt;
|
||||
const tspan: SvgTspan = {
|
||||
text: run.text,
|
||||
fontSize: round(fontSize),
|
||||
fill: m.color ?? color,
|
||||
};
|
||||
if (m.bold) tspan.fontWeight = "700";
|
||||
if (m.italic) tspan.fontStyle = "italic";
|
||||
if (m.font) tspan.fontFamily = m.font;
|
||||
const deco = textDecoration(m);
|
||||
if (deco) tspan.textDecoration = deco;
|
||||
if (m.super) tspan.baselineShift = round(fontSize * 0.35);
|
||||
if (m.sub) tspan.baselineShift = round(-fontSize * 0.2);
|
||||
return tspan;
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut ein komplettes `<text>`-SVG-Element als String. Jede Zeile ist ein
|
||||
* `<tspan x=… dy=…>`, darin ein `<tspan>` je Run. `text-anchor` folgt der
|
||||
* Absatz-Ausrichtung (left→start, center→middle, right→end).
|
||||
*/
|
||||
export function docToSvgText(doc: RichTextDoc, opts: SvgTextOptions): string {
|
||||
const lines = docToLines(doc, opts);
|
||||
const parts: string[] = [];
|
||||
lines.forEach((line, i) => {
|
||||
const dy = i === 0 ? 0 : opts.lineHeight;
|
||||
const anchor = line.align === "center" ? "middle" : line.align === "right" ? "end" : "start";
|
||||
const runSpans = line.tspans.map((t) => tspanToSvg(t)).join("");
|
||||
// Leere Zeile: ein Zero-Width-Space, damit dy dennoch wirkt.
|
||||
const content = runSpans || "​";
|
||||
parts.push(
|
||||
`<tspan x="${round(opts.x)}" dy="${round(dy)}" text-anchor="${anchor}">${content}</tspan>`,
|
||||
);
|
||||
});
|
||||
return `<text x="${round(opts.x)}" y="${round(opts.y)}">${parts.join("")}</text>`;
|
||||
}
|
||||
|
||||
function tspanToSvg(t: SvgTspan): string {
|
||||
const attrs: string[] = [`font-size="${t.fontSize}"`, `fill="${escapeAttr(t.fill)}"`];
|
||||
if (t.fontWeight) attrs.push(`font-weight="${t.fontWeight}"`);
|
||||
if (t.fontStyle) attrs.push(`font-style="${t.fontStyle}"`);
|
||||
if (t.fontFamily) attrs.push(`font-family="${escapeAttr(t.fontFamily)}"`);
|
||||
if (t.textDecoration) attrs.push(`text-decoration="${t.textDecoration}"`);
|
||||
if (t.baselineShift) attrs.push(`dy="${-t.baselineShift}"`);
|
||||
const body = escapeText(t.text);
|
||||
// Bei baseline-shift den dy nach dem Run zurücksetzen, damit die Grundlinie hält.
|
||||
if (t.baselineShift) {
|
||||
return `<tspan ${attrs.join(" ")}>${body}</tspan><tspan dy="${t.baselineShift}">​</tspan>`;
|
||||
}
|
||||
return `<tspan ${attrs.join(" ")}>${body}</tspan>`;
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
// Rich-Text-Dokumentmodell — die wiederverwendbare Grundlage für TextEntity-
|
||||
// Annotationen (Text-/Stempel-Bauteil). Bewusst frei von App-Store und React:
|
||||
// ein reines Datenmodell + Operationen, unit-getestet.
|
||||
//
|
||||
// Aufbau (an DOSSIERs text_editor.py angelehnt: Runs mit text/font/bold/italic/
|
||||
// underline/strike/sup/sub/color, Grössen in Punkt):
|
||||
//
|
||||
// RichTextDoc = { version, paragraphs: Paragraph[] }
|
||||
// Paragraph = { runs: TextRun[], align? }
|
||||
// TextRun = { text: string, marks: Marks }
|
||||
// Marks = { bold?, italic?, underline?, strike?, super?, sub?,
|
||||
// font?, sizePt?, color? }
|
||||
//
|
||||
// Absätze trennen Zeilen (harte Umbrüche); innerhalb eines Absatzes tragen die
|
||||
// Runs die Zeichenformatierung. Ein „Zeichen-Offset" adressiert das Dokument
|
||||
// linear über alle Absätze hinweg, wobei jeder Absatzumbruch als EIN Zeichen
|
||||
// zählt (wie „\n"). Das erlaubt Bereichs-Operationen (applyMark/toggleMark) über
|
||||
// Absatzgrenzen hinweg.
|
||||
|
||||
/** Zeichen-Marks eines Runs. Alle optional; fehlend = „nicht gesetzt". */
|
||||
export interface Marks {
|
||||
bold?: boolean;
|
||||
italic?: boolean;
|
||||
underline?: boolean;
|
||||
strike?: boolean;
|
||||
/** Hochgestellt. super und sub schliessen sich gegenseitig aus. */
|
||||
super?: boolean;
|
||||
/** Tiefgestellt. */
|
||||
sub?: boolean;
|
||||
/** Schriftfamilie (CSS font-family-Wert), z. B. "Helvetica". */
|
||||
font?: string;
|
||||
/** Schriftgrösse in typografischen Punkt (pt). */
|
||||
sizePt?: number;
|
||||
/** Farbe als "#rrggbb". */
|
||||
color?: string;
|
||||
}
|
||||
|
||||
/** Ein zusammenhängender Textabschnitt mit einheitlicher Formatierung. */
|
||||
export interface TextRun {
|
||||
text: string;
|
||||
marks: Marks;
|
||||
}
|
||||
|
||||
/** Absatz-Ausrichtung. */
|
||||
export type Align = "left" | "center" | "right";
|
||||
|
||||
/** Ein Absatz: eine Zeile aus Runs plus optionale Ausrichtung. */
|
||||
export interface Paragraph {
|
||||
runs: TextRun[];
|
||||
align?: Align;
|
||||
}
|
||||
|
||||
/** Das gesamte Rich-Text-Dokument. */
|
||||
export interface RichTextDoc {
|
||||
version: 1;
|
||||
paragraphs: Paragraph[];
|
||||
}
|
||||
|
||||
/** Namen der booleschen Marks (togglebar). */
|
||||
export type BoolMark = "bold" | "italic" | "underline" | "strike" | "super" | "sub";
|
||||
|
||||
/** Ein Zeichen-Bereich [start, end) im linearen Dokument-Offset. */
|
||||
export interface TextRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
// ── Konstruktoren ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Leeres Dokument (ein leerer Absatz). */
|
||||
export function emptyDoc(): RichTextDoc {
|
||||
return { version: 1, paragraphs: [{ runs: [] }] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Dokument aus reinem Text. Zeilenumbrüche ("\n") werden zu Absätzen; die
|
||||
* übergebenen Marks gelten für den ganzen Text.
|
||||
*/
|
||||
export function docFromText(text: string, marks: Marks = {}): RichTextDoc {
|
||||
const lines = text.split("\n");
|
||||
const paragraphs: Paragraph[] = lines.map((line) => ({
|
||||
runs: line.length ? [{ text: line, marks: { ...marks } }] : [],
|
||||
}));
|
||||
return { version: 1, paragraphs };
|
||||
}
|
||||
|
||||
/** Erzeugt einen Run (defensive Kopie der Marks). */
|
||||
export function makeRun(text: string, marks: Marks = {}): TextRun {
|
||||
return { text, marks: { ...marks } };
|
||||
}
|
||||
|
||||
// ── Extraktion ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Reintext des Dokuments; Absätze werden mit "\n" verbunden. */
|
||||
export function plainText(doc: RichTextDoc): string {
|
||||
return doc.paragraphs
|
||||
.map((p) => p.runs.map((r) => r.text).join(""))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/** Ist das Dokument (nach Reintext) leer? */
|
||||
export function isEmpty(doc: RichTextDoc): boolean {
|
||||
return plainText(doc).length === 0;
|
||||
}
|
||||
|
||||
/** Gesamtlänge in Zeichen (inkl. Absatzumbrüche als je 1 Zeichen). */
|
||||
export function docLength(doc: RichTextDoc): number {
|
||||
return plainText(doc).length;
|
||||
}
|
||||
|
||||
// ── Marks-Vergleich / Normalisierung ───────────────────────────────────────
|
||||
|
||||
const MARK_KEYS: (keyof Marks)[] = [
|
||||
"bold",
|
||||
"italic",
|
||||
"underline",
|
||||
"strike",
|
||||
"super",
|
||||
"sub",
|
||||
"font",
|
||||
"sizePt",
|
||||
"color",
|
||||
];
|
||||
|
||||
/** Normalisiert Marks: entfernt „falsy"/leere Einträge, damit Vergleiche stabil sind. */
|
||||
export function normalizeMarks(marks: Marks): Marks {
|
||||
const out: Marks = {};
|
||||
for (const key of MARK_KEYS) {
|
||||
const value = marks[key];
|
||||
if (value === undefined || value === false || value === null) continue;
|
||||
if ((key === "font" || key === "color") && value === "") continue;
|
||||
// super und sub sind exklusiv — sub gewinnt nicht über super, wir behalten beide
|
||||
// Werte nur wenn true; Auflösung geschieht bei toggleMark.
|
||||
(out as Record<string, unknown>)[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Strukturvergleich zweier Marks-Objekte (nach Normalisierung). */
|
||||
export function marksEqual(a: Marks, b: Marks): boolean {
|
||||
const na = normalizeMarks(a);
|
||||
const nb = normalizeMarks(b);
|
||||
for (const key of MARK_KEYS) {
|
||||
if ((na as Record<string, unknown>)[key] !== (nb as Record<string, unknown>)[key]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Führt benachbarte Runs mit gleichen Marks zusammen und entfernt leere Runs.
|
||||
* Idempotent; hält das Dokument nach Editieroperationen kompakt.
|
||||
*/
|
||||
export function normalizeDoc(doc: RichTextDoc): RichTextDoc {
|
||||
const paragraphs: Paragraph[] = doc.paragraphs.map((p) => {
|
||||
const merged: TextRun[] = [];
|
||||
for (const run of p.runs) {
|
||||
if (run.text.length === 0) continue;
|
||||
const norm = { text: run.text, marks: normalizeMarks(run.marks) };
|
||||
const last = merged[merged.length - 1];
|
||||
if (last && marksEqual(last.marks, norm.marks)) {
|
||||
last.text += norm.text;
|
||||
} else {
|
||||
merged.push(norm);
|
||||
}
|
||||
}
|
||||
const out: Paragraph = { runs: merged };
|
||||
if (p.align && p.align !== "left") out.align = p.align;
|
||||
return out;
|
||||
});
|
||||
if (paragraphs.length === 0) paragraphs.push({ runs: [] });
|
||||
return { version: 1, paragraphs };
|
||||
}
|
||||
|
||||
// ── Bereichs-Adressierung (linearer Offset ↔ Absatz/Run) ────────────────────
|
||||
|
||||
interface ParaSpan {
|
||||
index: number;
|
||||
/** Start-Offset dieses Absatzes im linearen Dokument. */
|
||||
start: number;
|
||||
/** End-Offset (exklusive Umbruch) = start + Reintextlänge des Absatzes. */
|
||||
end: number;
|
||||
}
|
||||
|
||||
/** Berechnet Start/End-Offsets je Absatz. */
|
||||
function paragraphSpans(doc: RichTextDoc): ParaSpan[] {
|
||||
const spans: ParaSpan[] = [];
|
||||
let cursor = 0;
|
||||
doc.paragraphs.forEach((p, index) => {
|
||||
const len = p.runs.reduce((n, r) => n + r.text.length, 0);
|
||||
spans.push({ index, start: cursor, end: cursor + len });
|
||||
cursor += len + 1; // +1 für den Absatzumbruch
|
||||
});
|
||||
return spans;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wendet `fn` auf jeden Run an, der (ganz oder teilweise) im Bereich [start,end)
|
||||
* liegt; teilt Runs an den Bereichsgrenzen auf. Gibt ein neues, normalisiertes
|
||||
* Dokument zurück. Die Callback bekommt die aktuellen Marks des Teil-Runs und
|
||||
* liefert die neuen Marks.
|
||||
*/
|
||||
export function mapRunsInRange(
|
||||
doc: RichTextDoc,
|
||||
range: TextRange,
|
||||
fn: (marks: Marks) => Marks,
|
||||
): RichTextDoc {
|
||||
const start = Math.max(0, Math.min(range.start, range.end));
|
||||
const end = Math.max(range.start, range.end);
|
||||
if (start === end) return normalizeDoc(doc);
|
||||
|
||||
const spans = paragraphSpans(doc);
|
||||
const paragraphs: Paragraph[] = doc.paragraphs.map((p, pIdx) => {
|
||||
const span = spans[pIdx];
|
||||
// Absatz komplett ausserhalb des Bereichs?
|
||||
if (span.end <= start || span.start >= end) return p;
|
||||
|
||||
const newRuns: TextRun[] = [];
|
||||
let runStart = span.start;
|
||||
for (const run of p.runs) {
|
||||
const runEnd = runStart + run.text.length;
|
||||
const selStart = Math.max(start, runStart);
|
||||
const selEnd = Math.min(end, runEnd);
|
||||
if (selEnd <= selStart) {
|
||||
// Run ausserhalb — unverändert übernehmen.
|
||||
newRuns.push(run);
|
||||
} else {
|
||||
const relStart = selStart - runStart;
|
||||
const relEnd = selEnd - runStart;
|
||||
if (relStart > 0) {
|
||||
newRuns.push(makeRun(run.text.slice(0, relStart), run.marks));
|
||||
}
|
||||
newRuns.push(makeRun(run.text.slice(relStart, relEnd), fn(run.marks)));
|
||||
if (relEnd < run.text.length) {
|
||||
newRuns.push(makeRun(run.text.slice(relEnd), run.marks));
|
||||
}
|
||||
}
|
||||
runStart = runEnd;
|
||||
}
|
||||
return { ...p, runs: newRuns };
|
||||
});
|
||||
|
||||
return normalizeDoc({ version: 1, paragraphs });
|
||||
}
|
||||
|
||||
// ── Mark-Operationen ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Setzt ein Mark auf einen Bereich. `value` bei boolschen Marks true/false;
|
||||
* bei font/sizePt/color der jeweilige Wert (oder undefined zum Entfernen).
|
||||
* super/sub werden exklusiv gehalten.
|
||||
*/
|
||||
export function applyMark<K extends keyof Marks>(
|
||||
doc: RichTextDoc,
|
||||
range: TextRange,
|
||||
mark: K,
|
||||
value: Marks[K] | undefined,
|
||||
): RichTextDoc {
|
||||
return mapRunsInRange(doc, range, (marks) => {
|
||||
const next: Marks = { ...marks };
|
||||
if (value === undefined || value === false) {
|
||||
delete next[mark];
|
||||
} else {
|
||||
(next as Record<string, unknown>)[mark] = value;
|
||||
if (mark === "super" && value) delete next.sub;
|
||||
if (mark === "sub" && value) delete next.super;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ist ein boolsches Mark über den GESAMTEN Bereich aktiv? (Für den
|
||||
* Toolbar-Zustand: „Fett"-Knopf gedrückt, wenn die ganze Auswahl fett ist.)
|
||||
*/
|
||||
export function isMarkActive(doc: RichTextDoc, range: TextRange, mark: BoolMark): boolean {
|
||||
const start = Math.max(0, Math.min(range.start, range.end));
|
||||
const end = Math.max(range.start, range.end);
|
||||
if (start === end) return false;
|
||||
const spans = paragraphSpans(doc);
|
||||
let sawAny = false;
|
||||
|
||||
for (let pIdx = 0; pIdx < doc.paragraphs.length; pIdx++) {
|
||||
const span = spans[pIdx];
|
||||
if (span.end <= start || span.start >= end) continue;
|
||||
let runStart = span.start;
|
||||
for (const run of doc.paragraphs[pIdx].runs) {
|
||||
const runEnd = runStart + run.text.length;
|
||||
const selStart = Math.max(start, runStart);
|
||||
const selEnd = Math.min(end, runEnd);
|
||||
if (selEnd > selStart) {
|
||||
sawAny = true;
|
||||
if (!run.marks[mark]) return false;
|
||||
}
|
||||
runStart = runEnd;
|
||||
}
|
||||
}
|
||||
return sawAny;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schaltet ein boolsches Mark über den Bereich um: ist es überall aktiv →
|
||||
* entfernen, sonst überall setzen.
|
||||
*/
|
||||
export function toggleMark(doc: RichTextDoc, range: TextRange, mark: BoolMark): RichTextDoc {
|
||||
const active = isMarkActive(doc, range, mark);
|
||||
return applyMark(doc, range, mark, active ? undefined : true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert den gemeinsamen Wert eines Nicht-Bool-Marks über den Bereich, oder
|
||||
* undefined wenn uneinheitlich/leer. (Für Toolbar-Anzeige von Grösse/Farbe/Font.)
|
||||
*/
|
||||
export function commonMarkValue<K extends "font" | "sizePt" | "color">(
|
||||
doc: RichTextDoc,
|
||||
range: TextRange,
|
||||
mark: K,
|
||||
): Marks[K] | undefined {
|
||||
const start = Math.max(0, Math.min(range.start, range.end));
|
||||
const end = Math.max(range.start, range.end);
|
||||
if (start === end) return undefined;
|
||||
const spans = paragraphSpans(doc);
|
||||
let value: Marks[K] | undefined;
|
||||
let first = true;
|
||||
|
||||
for (let pIdx = 0; pIdx < doc.paragraphs.length; pIdx++) {
|
||||
const span = spans[pIdx];
|
||||
if (span.end <= start || span.start >= end) continue;
|
||||
let runStart = span.start;
|
||||
for (const run of doc.paragraphs[pIdx].runs) {
|
||||
const runEnd = runStart + run.text.length;
|
||||
const selStart = Math.max(start, runStart);
|
||||
const selEnd = Math.min(end, runEnd);
|
||||
if (selEnd > selStart) {
|
||||
const v = run.marks[mark];
|
||||
if (first) {
|
||||
value = v;
|
||||
first = false;
|
||||
} else if (v !== value) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
runStart = runEnd;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// ── Serialisierung (stabiles JSON) ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Stabile JSON-Serialisierung: Schlüssel in fester Reihenfolge, damit gleiche
|
||||
* Dokumente denselben String ergeben (Diff-/Test-freundlich).
|
||||
*/
|
||||
export function serialize(doc: RichTextDoc): string {
|
||||
const norm = normalizeDoc(doc);
|
||||
const paragraphs = norm.paragraphs.map((p) => {
|
||||
const runs = p.runs.map((r) => {
|
||||
const marks: Record<string, unknown> = {};
|
||||
for (const key of MARK_KEYS) {
|
||||
const v = (r.marks as Record<string, unknown>)[key];
|
||||
if (v !== undefined) marks[key] = v;
|
||||
}
|
||||
return { text: r.text, marks };
|
||||
});
|
||||
const obj: Record<string, unknown> = { runs };
|
||||
if (p.align && p.align !== "left") obj.align = p.align;
|
||||
return obj;
|
||||
});
|
||||
return JSON.stringify({ version: 1, paragraphs });
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest ein Dokument aus JSON. Robust gegenüber fehlenden Feldern; unbekannter
|
||||
* Input ergibt ein leeres Dokument statt eines Fehlers.
|
||||
*/
|
||||
export function deserialize(json: string): RichTextDoc {
|
||||
try {
|
||||
const raw = JSON.parse(json) as unknown;
|
||||
return fromJson(raw);
|
||||
} catch {
|
||||
return emptyDoc();
|
||||
}
|
||||
}
|
||||
|
||||
/** Wandelt ein bereits geparstes Objekt in ein valides Dokument. */
|
||||
export function fromJson(raw: unknown): RichTextDoc {
|
||||
if (!raw || typeof raw !== "object") return emptyDoc();
|
||||
const obj = raw as { paragraphs?: unknown };
|
||||
if (!Array.isArray(obj.paragraphs)) return emptyDoc();
|
||||
const paragraphs: Paragraph[] = obj.paragraphs.map((p) => {
|
||||
const pp = (p ?? {}) as { runs?: unknown; align?: unknown };
|
||||
const runs: TextRun[] = Array.isArray(pp.runs)
|
||||
? pp.runs.map((r) => {
|
||||
const rr = (r ?? {}) as { text?: unknown; marks?: unknown };
|
||||
const text = typeof rr.text === "string" ? rr.text : "";
|
||||
const marks = normalizeMarks((rr.marks ?? {}) as Marks);
|
||||
return { text, marks };
|
||||
})
|
||||
: [];
|
||||
const para: Paragraph = { runs };
|
||||
if (pp.align === "center" || pp.align === "right") para.align = pp.align;
|
||||
return para;
|
||||
});
|
||||
return normalizeDoc({ version: 1, paragraphs });
|
||||
}
|
||||
|
||||
// ── Style-Presets ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Ein benannter Stil (Marks-Vorlage), z. B. „Titel". */
|
||||
export interface TextStylePreset {
|
||||
/** Stabiler Schlüssel (englisch), z. B. "title". */
|
||||
id: string;
|
||||
/** Anzeigename (bereits übersetzt oder Schlüssel für t()). */
|
||||
label: string;
|
||||
/** Marks, die der Stil auf die Auswahl (oder das ganze Dokument) legt. */
|
||||
marks: Marks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard-Presets, an DOSSIERs Textstile angelehnt. Grössen in pt; die
|
||||
* SVG-/Canvas-Renderer rechnen pt → Meter/px selbst um.
|
||||
*/
|
||||
export const DEFAULT_PRESETS: TextStylePreset[] = [
|
||||
{ id: "title", label: "Titel", marks: { bold: true, sizePt: 18 } },
|
||||
{ id: "subtitle", label: "Untertitel", marks: { italic: true, sizePt: 13 } },
|
||||
{ id: "label", label: "Beschriftung", marks: { sizePt: 10 } },
|
||||
{ id: "note", label: "Notiz", marks: { italic: true, sizePt: 8, color: "#8a8580" } },
|
||||
];
|
||||
|
||||
/**
|
||||
* Wendet ein Preset auf einen Bereich an (setzt jede Mark des Presets). Ein
|
||||
* leerer Bereich wird auf das ganze Dokument angewendet.
|
||||
*/
|
||||
export function applyPreset(
|
||||
doc: RichTextDoc,
|
||||
range: TextRange,
|
||||
preset: TextStylePreset,
|
||||
): RichTextDoc {
|
||||
const full: TextRange =
|
||||
range.start === range.end ? { start: 0, end: Math.max(1, docLength(doc)) } : range;
|
||||
let out = doc;
|
||||
for (const key of MARK_KEYS) {
|
||||
const v = (preset.marks as Record<string, unknown>)[key];
|
||||
if (v !== undefined) {
|
||||
out = applyMark(out, full, key as keyof Marks, v as Marks[keyof Marks]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
+174
-2
@@ -1,9 +1,10 @@
|
||||
// Konkrete Werkzeuge (Phase 1: select / wall / line) + Registry.
|
||||
// docs/design/drawing-tools.md §3, §4, §10.
|
||||
|
||||
import type { Drawing2D, Project, Vec2, Wall } from "../model/types";
|
||||
import type { Ceiling, Drawing2D, Project, Vec2, Wall } from "../model/types";
|
||||
import { wallTypeThickness } from "../model/types";
|
||||
import { wallCorners } from "../model/geometry";
|
||||
import { normalizeOutline, ceilingArea } from "../geometry/ceiling";
|
||||
import type { DraftShape, Tool, ToolContext, ToolDraft, ToolId } from "./types";
|
||||
import { uniqueId } from "./types";
|
||||
|
||||
@@ -137,6 +138,161 @@ const wallTool: Tool = {
|
||||
},
|
||||
};
|
||||
|
||||
// ── Ceiling ────────────────────────────────────────────────────────────────
|
||||
// Zeichnet einen GESCHLOSSENEN Umriss (wie eine geschlossene Polylinie) und
|
||||
// committet daraus eine `Ceiling`. Der reguläre Zeichenpfad läuft über den
|
||||
// gekoppelten `ceiling`-Befehl (TOOL_COMMAND); dieses Legacy-Tool ist eine
|
||||
// vollständige, funktionsfähige Alternative (kein Stub).
|
||||
|
||||
interface CeilDrawing {
|
||||
phase: "drawing";
|
||||
points: Vec2[];
|
||||
cursor: Vec2 | null;
|
||||
}
|
||||
type CeilState = { phase: "idle" } | CeilDrawing;
|
||||
|
||||
function ceilDraft(points: Vec2[], cursor: Vec2 | null): ToolDraft {
|
||||
const all = cursor ? [...points, cursor] : points;
|
||||
const preview: DraftShape[] = [{ kind: "poly", pts: all, closed: all.length >= 3 }];
|
||||
const draft: ToolDraft = { preview, vertices: points };
|
||||
if (cursor && points.length > 0) {
|
||||
const a = points[points.length - 1];
|
||||
if (segLen(a, cursor) >= EPS) {
|
||||
draft.hud = {
|
||||
at: cursor,
|
||||
text: `${segLen(a, cursor).toFixed(2)} m · ${Math.abs(
|
||||
(segAngleDeg(a, cursor) + 360) % 360,
|
||||
).toFixed(0)}°`,
|
||||
};
|
||||
}
|
||||
}
|
||||
return draft;
|
||||
}
|
||||
|
||||
function appendCeiling(project: Project, pts: Vec2[], ctx: ToolContext): Project {
|
||||
const outline = normalizeOutline(pts);
|
||||
if (!outline || ceilingArea(outline) < 1e-4) return project;
|
||||
const hasCat = project.layers.some((c) => c.code === "30");
|
||||
const c: Ceiling = {
|
||||
id: uniqueId("C"),
|
||||
type: "ceiling",
|
||||
floorId: ctx.level.id,
|
||||
categoryCode: hasCat ? "30" : ctx.defaultCategoryCode,
|
||||
outline,
|
||||
wallTypeId: ctx.activeWallTypeId,
|
||||
};
|
||||
return { ...project, ceilings: project.ceilings ? [...project.ceilings, c] : [c] };
|
||||
}
|
||||
|
||||
const ceilingTool: Tool = {
|
||||
id: "ceiling",
|
||||
labelKey: "tool.ceiling",
|
||||
floorOnly: true,
|
||||
hintKey: (s) =>
|
||||
(s as CeilState).phase === "drawing"
|
||||
? "tool.ceiling.nextPoint"
|
||||
: "tool.ceiling.firstPoint",
|
||||
init: () => ({ phase: "idle" }),
|
||||
onClick: (state, p, ctx) => {
|
||||
const s = state as CeilState;
|
||||
if (s.phase !== "drawing") {
|
||||
return [{ phase: "drawing", points: [p.point], cursor: p.point }, { draft: ceilDraft([p.point], p.point) }];
|
||||
}
|
||||
// Klick auf den Startpunkt schließt den Umriss (ab 3 Punkten) und committet.
|
||||
if (s.points.length >= 3 && segLen(p.point, s.points[0]) < EPS) {
|
||||
const pts = s.points;
|
||||
return [{ phase: "idle" }, { draft: null, done: true, commit: (proj) => appendCeiling(proj, pts, ctx) }];
|
||||
}
|
||||
const points = [...s.points, p.point];
|
||||
return [{ phase: "drawing", points, cursor: p.point }, { draft: ceilDraft(points, p.point) }];
|
||||
},
|
||||
onMove: (state, p) => {
|
||||
const s = state as CeilState;
|
||||
if (s.phase !== "drawing") return [s, { draft: { preview: [], vertices: [] } }];
|
||||
return [{ ...s, cursor: p.point }, { draft: ceilDraft(s.points, p.point) }];
|
||||
},
|
||||
onCommitGesture: (state, ctx) => {
|
||||
const s = state as CeilState;
|
||||
if (s.phase !== "drawing" || s.points.length < 3) {
|
||||
return [{ phase: "idle" }, { draft: null, done: true }];
|
||||
}
|
||||
const pts = s.points;
|
||||
return [{ phase: "idle" }, { draft: null, done: true, commit: (proj) => appendCeiling(proj, pts, ctx) }];
|
||||
},
|
||||
onCancel: () => [{ phase: "idle" }, { draft: null, done: true }],
|
||||
onUndoPoint: (state) => {
|
||||
const s = state as CeilState;
|
||||
if (s.phase === "drawing" && s.points.length > 1) {
|
||||
const points = s.points.slice(0, -1);
|
||||
return [{ phase: "drawing", points, cursor: s.cursor }, { draft: ceilDraft(points, s.cursor) }];
|
||||
}
|
||||
return [{ phase: "idle" }, { draft: null, done: true }];
|
||||
},
|
||||
};
|
||||
|
||||
// ── Window / Door (Öffnungen) ─────────────────────────────────────────────────
|
||||
// Beide sind an den gekoppelten Befehl `fenster`/`tuer` (TOOL_COMMAND) gebunden:
|
||||
// der interaktive Ablauf (Host-Wand picken → Position → committen) läuft komplett
|
||||
// in der CommandEngine. Diese Tool-Objekte sind reine Platzhalter (wie `select`);
|
||||
// der Controller ruft sie nicht auf, sobald der gekoppelte Befehl läuft.
|
||||
const windowTool: Tool = {
|
||||
id: "window",
|
||||
labelKey: "tool.window",
|
||||
floorOnly: true,
|
||||
hintKey: () => "tool.window.hint",
|
||||
init: () => ({ phase: "idle" }),
|
||||
onClick: (s) => [s, { draft: null }],
|
||||
onMove: (s) => [s, { draft: null }],
|
||||
onCommitGesture: (s) => [s, { draft: null, done: true }],
|
||||
onCancel: (s) => [s, { draft: null, done: true }],
|
||||
};
|
||||
|
||||
const doorTool: Tool = {
|
||||
id: "door",
|
||||
labelKey: "tool.door",
|
||||
floorOnly: true,
|
||||
hintKey: () => "tool.door.hint",
|
||||
init: () => ({ phase: "idle" }),
|
||||
onClick: (s) => [s, { draft: null }],
|
||||
onMove: (s) => [s, { draft: null }],
|
||||
onCommitGesture: (s) => [s, { draft: null, done: true }],
|
||||
onCancel: (s) => [s, { draft: null, done: true }],
|
||||
};
|
||||
|
||||
// ── Stair (Treppe) ───────────────────────────────────────────────────────────
|
||||
// An den gekoppelten Befehl `stair` (TOOL_COMMAND) gebunden: der interaktive
|
||||
// Ablauf (Startpunkt → Laufrichtung/Ende → committen) läuft komplett in der
|
||||
// CommandEngine. Dieses Tool-Objekt ist ein reiner Platzhalter (wie window/door);
|
||||
// der Controller ruft es nicht auf, sobald der gekoppelte Befehl läuft.
|
||||
const stairTool: Tool = {
|
||||
id: "stair",
|
||||
labelKey: "tool.stair",
|
||||
floorOnly: true,
|
||||
hintKey: () => "tool.stair.hint",
|
||||
init: () => ({ phase: "idle" }),
|
||||
onClick: (s) => [s, { draft: null }],
|
||||
onMove: (s) => [s, { draft: null }],
|
||||
onCommitGesture: (s) => [s, { draft: null, done: true }],
|
||||
onCancel: (s) => [s, { draft: null, done: true }],
|
||||
};
|
||||
|
||||
// ── Room (Raum) ──────────────────────────────────────────────────────────────
|
||||
// An den gekoppelten Befehl `room` (TOOL_COMMAND) gebunden: der interaktive
|
||||
// Ablauf (Klick-innen zur Erkennung ODER manueller Umriss) läuft komplett in der
|
||||
// CommandEngine. Dieses Tool-Objekt ist ein reiner Platzhalter (wie stair/window);
|
||||
// der Controller ruft es nicht auf, sobald der gekoppelte Befehl läuft.
|
||||
const roomTool: Tool = {
|
||||
id: "room",
|
||||
labelKey: "tool.room",
|
||||
floorOnly: true,
|
||||
hintKey: () => "tool.room.hint",
|
||||
init: () => ({ phase: "idle" }),
|
||||
onClick: (s) => [s, { draft: null }],
|
||||
onMove: (s) => [s, { draft: null }],
|
||||
onCommitGesture: (s) => [s, { draft: null, done: true }],
|
||||
onCancel: (s) => [s, { draft: null, done: true }],
|
||||
};
|
||||
|
||||
// ── Line ─────────────────────────────────────────────────────────────────────
|
||||
// Zwei-Klick-Strecke → Drawing2D{shape:"line"}.
|
||||
|
||||
@@ -375,6 +531,11 @@ const rectTool: Tool = {
|
||||
const TOOLS: Record<ToolId, Tool> = {
|
||||
select: selectTool,
|
||||
wall: wallTool,
|
||||
ceiling: ceilingTool,
|
||||
window: windowTool,
|
||||
door: doorTool,
|
||||
stair: stairTool,
|
||||
room: roomTool,
|
||||
line: lineTool,
|
||||
polyline: polylineTool,
|
||||
rect: rectTool,
|
||||
@@ -386,4 +547,15 @@ export function getTool(id: ToolId): Tool {
|
||||
}
|
||||
|
||||
/** Reihenfolge der Werkzeuge in der Werkzeugleiste. */
|
||||
export const TOOL_ORDER: ToolId[] = ["select", "wall", "line", "polyline", "rect"];
|
||||
export const TOOL_ORDER: ToolId[] = [
|
||||
"select",
|
||||
"wall",
|
||||
"ceiling",
|
||||
"window",
|
||||
"door",
|
||||
"stair",
|
||||
"room",
|
||||
"line",
|
||||
"polyline",
|
||||
"rect",
|
||||
];
|
||||
|
||||
+11
-1
@@ -8,7 +8,17 @@
|
||||
import type { DrawingLevel, Project, Vec2 } from "../model/types";
|
||||
|
||||
/** Werkzeug-Identität. */
|
||||
export type ToolId = "select" | "wall" | "line" | "polyline" | "rect";
|
||||
export type ToolId =
|
||||
| "select"
|
||||
| "wall"
|
||||
| "ceiling"
|
||||
| "window"
|
||||
| "door"
|
||||
| "stair"
|
||||
| "room"
|
||||
| "line"
|
||||
| "polyline"
|
||||
| "rect";
|
||||
|
||||
// ── Snapping ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
+51
-13
@@ -13,14 +13,20 @@ export interface CommandLineOption {
|
||||
id: string;
|
||||
labelKey: string;
|
||||
value?: string;
|
||||
/** Aktuell gewählte Option (hervorgehoben)? */
|
||||
active?: boolean;
|
||||
/** Tastatur-Kürzel (z. B. „U"), wirkt AUCH bei fokussiertem Eingabefeld. */
|
||||
key?: string;
|
||||
}
|
||||
|
||||
/** Ein Zahlenfeld des Tab-Feld-Zyklus (§2.7). */
|
||||
export interface CommandLineField {
|
||||
id: string;
|
||||
labelKey: string;
|
||||
/** Gelockter Wert (oder null = folgt der Maus). */
|
||||
/** Gelockter ODER live (maus-folgender) Wert; null = (noch) keiner. */
|
||||
value: number | null;
|
||||
/** Ist der Wert gelockt (getippt) statt live (folgt der Maus)? */
|
||||
locked: boolean;
|
||||
/** Aktuelles Tab-Ziel? */
|
||||
active: boolean;
|
||||
}
|
||||
@@ -73,6 +79,25 @@ export const CommandLine = forwardRef<CommandLineHandle, CommandLineProps>(
|
||||
};
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
// Options-Kürzel (z. B. U/I/O/P der Kopiermodi): auch bei fokussiertem Feld
|
||||
// aktiv. Ein einzelner Buchstabe, der einer Option zugeordnet ist, löst die
|
||||
// Option aus statt in die Zahleneingabe zu fallen (Ziffern bleiben Eingabe).
|
||||
if (
|
||||
active &&
|
||||
e.key.length === 1 &&
|
||||
!e.ctrlKey &&
|
||||
!e.metaKey &&
|
||||
!e.altKey
|
||||
) {
|
||||
const opt = options.find(
|
||||
(o) => o.key && o.key.toLowerCase() === e.key.toLowerCase(),
|
||||
);
|
||||
if (opt) {
|
||||
e.preventDefault();
|
||||
onOption(opt.id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Tasten NICHT zum globalen App-Handler durchreichen (eigener Eingabefokus).
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
@@ -130,12 +155,13 @@ export const CommandLine = forwardRef<CommandLineHandle, CommandLineProps>(
|
||||
<button
|
||||
key={o.id}
|
||||
type="button"
|
||||
className="cmdline-option"
|
||||
className={`cmdline-option${o.active ? " active" : ""}`}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
onOption(o.id);
|
||||
}}
|
||||
>
|
||||
{o.key && <span className="cmdline-option-key">{o.key}</span>}
|
||||
{t(o.labelKey as TranslationKey)}
|
||||
{o.value != null && <span className="cmdline-option-val">={o.value}</span>}
|
||||
</button>
|
||||
@@ -144,18 +170,30 @@ export const CommandLine = forwardRef<CommandLineHandle, CommandLineProps>(
|
||||
)}
|
||||
{hasFields && (
|
||||
<span className="cmdline-fields">
|
||||
{fields.map((f) => (
|
||||
<span
|
||||
key={f.id}
|
||||
className={`cmdline-field${f.active ? " active" : ""}`}
|
||||
title={t(f.labelKey as TranslationKey)}
|
||||
>
|
||||
<span className="cmdline-field-label">{t(f.labelKey as TranslationKey)}</span>
|
||||
<span className="cmdline-field-val">
|
||||
{f.value == null ? "—" : Number(f.value.toFixed(3)).toString()}
|
||||
{fields.map((f) => {
|
||||
// Winkelfeld in Grad, sonst Längen in Metern. Gelockte Werte fest
|
||||
// (mehr Nachkommastellen), live (maus-folgende) Werte gerundet.
|
||||
const isAngle = f.id === "angle";
|
||||
const digits = isAngle ? (f.locked ? 1 : 0) : f.locked ? 3 : 2;
|
||||
const unit = isAngle ? "°" : " m";
|
||||
const text =
|
||||
f.value == null
|
||||
? "—"
|
||||
: `${Number(f.value.toFixed(digits)).toString()}${unit}`;
|
||||
return (
|
||||
<span
|
||||
key={f.id}
|
||||
className={
|
||||
`cmdline-field${f.active ? " active" : ""}` +
|
||||
(f.locked ? " locked" : f.value != null ? " live" : "")
|
||||
}
|
||||
title={t(f.labelKey as TranslationKey)}
|
||||
>
|
||||
<span className="cmdline-field-label">{t(f.labelKey as TranslationKey)}</span>
|
||||
<span className="cmdline-field-val">{text}</span>
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
<input
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
// Standort-Import-Dialog (modal). Sucht einen Ort/Adresse (geo.admin
|
||||
// SearchServer), lässt einen Radius wählen und importiert Gebäude-Grundrisse
|
||||
// (swisstopo) sowie OSM-Features (Gebäude/Strassen/Wasser/Grün) als Kontext-
|
||||
// Geometrie ins Projekt.
|
||||
//
|
||||
// Muster wie ExportPdfDialog/ImportDialog: abgedunkeltes Overlay, Klick auf den
|
||||
// Hintergrund + Esc schließen. Der eigentliche Netz-Abruf läuft hier (io/*); das
|
||||
// Ergebnis wird über `onImport(objs)` nach oben gereicht (SitePanel legt es via
|
||||
// Host in `project.context` ab). Bezeichner englisch, UI-Text/Kommentare deutsch
|
||||
// (CONVENTIONS.md).
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { t } from "../i18n";
|
||||
import { geocode, fetchBuildings } from "../io/swissTopo";
|
||||
import type { GeocodeCandidate } from "../io/swissTopo";
|
||||
import { fetchOsm } from "../io/osm";
|
||||
import type { OsmSelection } from "../io/osm";
|
||||
import { featuresToContextObjects } from "../io/geoContext";
|
||||
import type { GeoFeature } from "../io/geoContext";
|
||||
import type { GeoOrigin } from "../io/lv95";
|
||||
import type { ContextObject } from "../model/types";
|
||||
|
||||
export interface ContextImportDialogProps {
|
||||
/** Übernimmt die fertigen Kontext-Objekte (nach erfolgreichem Import). */
|
||||
onImport: (objs: ContextObject[]) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type Sources = {
|
||||
swissBuildings: boolean;
|
||||
osmBuildings: boolean;
|
||||
osmRoads: boolean;
|
||||
osmWater: boolean;
|
||||
osmGreen: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_SOURCES: Sources = {
|
||||
swissBuildings: true,
|
||||
osmBuildings: false,
|
||||
osmRoads: true,
|
||||
osmWater: true,
|
||||
osmGreen: true,
|
||||
};
|
||||
|
||||
export function ContextImportDialog({
|
||||
onImport,
|
||||
onClose,
|
||||
}: ContextImportDialogProps) {
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const [candidates, setCandidates] = useState<GeocodeCandidate[]>([]);
|
||||
const [selected, setSelected] = useState<GeocodeCandidate | null>(null);
|
||||
const [radius, setRadius] = useState(200);
|
||||
const [sources, setSources] = useState<Sources>(DEFAULT_SOURCES);
|
||||
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const searchSeq = useRef(0);
|
||||
|
||||
const runSearch = async () => {
|
||||
const q = query.trim();
|
||||
if (!q) return;
|
||||
setError(null);
|
||||
setSearching(true);
|
||||
const seq = ++searchSeq.current;
|
||||
try {
|
||||
const res = await geocode(q, 8);
|
||||
if (seq !== searchSeq.current) return; // veraltet
|
||||
setCandidates(res);
|
||||
setSelected(res[0] ?? null);
|
||||
if (res.length === 0) setStatus(t("ctxImport.noResults"));
|
||||
else setStatus(null);
|
||||
} catch {
|
||||
if (seq === searchSeq.current) setError(t("ctxImport.searchError"));
|
||||
} finally {
|
||||
if (seq === searchSeq.current) setSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggle = (key: keyof Sources) =>
|
||||
setSources((s) => ({ ...s, [key]: !s[key] }));
|
||||
|
||||
const anySource =
|
||||
sources.swissBuildings ||
|
||||
sources.osmBuildings ||
|
||||
sources.osmRoads ||
|
||||
sources.osmWater ||
|
||||
sources.osmGreen;
|
||||
|
||||
const runImport = async () => {
|
||||
if (!selected || !anySource) return;
|
||||
setError(null);
|
||||
setImporting(true);
|
||||
setStatus(t("ctxImport.working"));
|
||||
try {
|
||||
const center = selected.lv95;
|
||||
let origin: GeoOrigin | undefined;
|
||||
const all: GeoFeature[] = [];
|
||||
|
||||
if (sources.swissBuildings) {
|
||||
const r = await fetchBuildings(center, radius, origin);
|
||||
origin = r.origin;
|
||||
all.push(...r.features);
|
||||
}
|
||||
|
||||
const osmSel: OsmSelection = {
|
||||
buildings: sources.osmBuildings,
|
||||
roads: sources.osmRoads,
|
||||
water: sources.osmWater,
|
||||
green: sources.osmGreen,
|
||||
};
|
||||
if (
|
||||
osmSel.buildings ||
|
||||
osmSel.roads ||
|
||||
osmSel.water ||
|
||||
osmSel.green
|
||||
) {
|
||||
const r = await fetchOsm(center, radius, osmSel, origin);
|
||||
origin = r.origin;
|
||||
all.push(...r.features);
|
||||
}
|
||||
|
||||
if (all.length === 0) {
|
||||
setError(t("ctxImport.empty"));
|
||||
setStatus(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const objs = featuresToContextObjects(all, selected.label);
|
||||
onImport(objs);
|
||||
setStatus(
|
||||
t("ctxImport.imported", { count: all.length, sets: objs.length }),
|
||||
);
|
||||
onClose();
|
||||
} catch {
|
||||
setError(t("ctxImport.importError"));
|
||||
setStatus(null);
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="imp-overlay" onClick={onClose}>
|
||||
<div
|
||||
className="imp-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("ctxImport.title")}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<header className="imp-head">
|
||||
<span className="imp-head-title">{t("ctxImport.title")}</span>
|
||||
<button
|
||||
className="imp-close"
|
||||
onClick={onClose}
|
||||
aria-label={t("ctxImport.close")}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="imp-body">
|
||||
{/* Ortssuche */}
|
||||
<section className="imp-section">
|
||||
<div className="imp-section-title">{t("ctxImport.location")}</div>
|
||||
<div className="cim-search-row">
|
||||
<input
|
||||
className="cim-input"
|
||||
type="text"
|
||||
value={query}
|
||||
placeholder={t("ctxImport.searchPlaceholder")}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void runSearch();
|
||||
}
|
||||
}}
|
||||
aria-label={t("ctxImport.location")}
|
||||
/>
|
||||
<button
|
||||
className="imp-btn"
|
||||
onClick={() => void runSearch()}
|
||||
disabled={searching || !query.trim()}
|
||||
>
|
||||
{searching ? t("ctxImport.searching") : t("ctxImport.search")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{candidates.length > 0 && (
|
||||
<ul className="cim-results">
|
||||
{candidates.map((c, i) => (
|
||||
<li
|
||||
key={`${c.label}-${i}`}
|
||||
className={
|
||||
"cim-result" + (c === selected ? " selected" : "")
|
||||
}
|
||||
onClick={() => setSelected(c)}
|
||||
title={c.label}
|
||||
>
|
||||
{c.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Radius */}
|
||||
<section className="imp-section">
|
||||
<div className="imp-section-title">{t("ctxImport.radius")}</div>
|
||||
<div className="cim-radius-row">
|
||||
<input
|
||||
className="cim-input cim-input-num"
|
||||
type="number"
|
||||
min={20}
|
||||
max={2000}
|
||||
step={10}
|
||||
value={radius}
|
||||
onChange={(e) =>
|
||||
setRadius(
|
||||
Math.max(20, Math.min(2000, Number(e.target.value) || 0)),
|
||||
)
|
||||
}
|
||||
aria-label={t("ctxImport.radius")}
|
||||
/>
|
||||
<span className="cim-unit">m</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Quellen */}
|
||||
<section className="imp-section">
|
||||
<div className="imp-section-title">{t("ctxImport.sources")}</div>
|
||||
<div className="cim-checks">
|
||||
<label className="cim-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sources.swissBuildings}
|
||||
onChange={() => toggle("swissBuildings")}
|
||||
/>
|
||||
{t("ctxImport.src.swissBuildings")}
|
||||
</label>
|
||||
<label className="cim-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sources.osmBuildings}
|
||||
onChange={() => toggle("osmBuildings")}
|
||||
/>
|
||||
{t("ctxImport.src.osmBuildings")}
|
||||
</label>
|
||||
<label className="cim-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sources.osmRoads}
|
||||
onChange={() => toggle("osmRoads")}
|
||||
/>
|
||||
{t("ctxImport.src.osmRoads")}
|
||||
</label>
|
||||
<label className="cim-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sources.osmWater}
|
||||
onChange={() => toggle("osmWater")}
|
||||
/>
|
||||
{t("ctxImport.src.osmWater")}
|
||||
</label>
|
||||
<label className="cim-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sources.osmGreen}
|
||||
onChange={() => toggle("osmGreen")}
|
||||
/>
|
||||
{t("ctxImport.src.osmGreen")}
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{status && <div className="cim-status">{status}</div>}
|
||||
{error && <div className="imp-warn">{error}</div>}
|
||||
</div>
|
||||
|
||||
<footer className="imp-foot">
|
||||
<button className="imp-btn" onClick={onClose}>
|
||||
{t("ctxImport.cancel")}
|
||||
</button>
|
||||
<button
|
||||
className="imp-btn primary"
|
||||
onClick={() => void runImport()}
|
||||
disabled={importing || !selected || !anySource}
|
||||
>
|
||||
{importing ? t("ctxImport.importing") : t("ctxImport.confirm")}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
// bzw. den festen `placeholder`.
|
||||
//
|
||||
// Reine Darstellung + Callbacks; voll gesteuert. Identifier englisch, UI-Text
|
||||
// kommt als Props (i18n) herein (CLAUDE.md).
|
||||
// kommt als Props (i18n) herein (CONVENTIONS.md).
|
||||
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// DXF-Export-Dialog (modal). Fragt „Schraffuren mitexportieren?" ab und weist auf
|
||||
// die Meter-Einheit hin (DXF ist Modell-Space in echten Metern, 1:1). Muster wie
|
||||
// ExportPdfDialog: abgedunkeltes Overlay, Klick auf den Hintergrund + Esc schliessen.
|
||||
//
|
||||
// Rein gesteuert: hält nur Formular-Zustand und meldet die Entscheidung über
|
||||
// `onExport` nach oben (App führt den Export aus, da es den Plan erzeugt).
|
||||
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { t } from "../i18n";
|
||||
import { Dropdown } from "./Dropdown";
|
||||
|
||||
/** Entscheidung, die der Dialog beim Bestätigen liefert. */
|
||||
export interface ExportDxfDecision {
|
||||
includeHatches: boolean;
|
||||
}
|
||||
|
||||
export interface ExportDxfDialogProps {
|
||||
/** Titel/Geschossname (nur Anzeige im Dialogkopf). */
|
||||
planTitle: string;
|
||||
onExport: (decision: ExportDxfDecision) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ExportDxfDialog({ planTitle, onExport, onClose }: ExportDxfDialogProps) {
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
const [includeHatches, setIncludeHatches] = useState(true);
|
||||
|
||||
const onConfirm = () => onExport({ includeHatches });
|
||||
|
||||
return (
|
||||
<div className="imp-overlay" onClick={onClose}>
|
||||
<div
|
||||
className="imp-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("exportDxf.title")}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<header className="imp-head">
|
||||
<span className="imp-head-title">{t("exportDxf.title")}</span>
|
||||
<button className="imp-close" onClick={onClose} aria-label={t("export.close")}>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="imp-body">
|
||||
<div className="imp-file">
|
||||
<span className="imp-file-label">{t("export.plan")}</span>
|
||||
<span className="imp-file-name" title={planTitle}>
|
||||
{planTitle}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<section className="imp-section">
|
||||
<div className="imp-section-title">{t("exportDxf.hatches")}</div>
|
||||
<Dropdown
|
||||
value={includeHatches ? "yes" : "no"}
|
||||
onChange={(v) => setIncludeHatches(v === "yes")}
|
||||
title={t("exportDxf.hatches")}
|
||||
options={[
|
||||
{ value: "yes", label: t("exportDxf.hatches.yes") },
|
||||
{ value: "no", label: t("exportDxf.hatches.no") },
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div className="imp-note">{t("exportDxf.unitsNote")}</div>
|
||||
</div>
|
||||
|
||||
<footer className="imp-foot">
|
||||
<button className="imp-btn" onClick={onClose}>
|
||||
{t("export.cancel")}
|
||||
</button>
|
||||
<button className="imp-btn primary" onClick={onConfirm}>
|
||||
{t("export.confirm")}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
// PDF-Export-Dialog (modal). Fragt Massstab (1:N), Papierformat (A4/A3) und
|
||||
// Ausrichtung (Hoch/Quer) ab und löst den Vektor-PDF-Export aus. Muster wie
|
||||
// ImportDialog: abgedunkeltes Overlay, Klick auf den Hintergrund + Esc schließen.
|
||||
// Themedes Dropdown für die Auswahlfelder.
|
||||
//
|
||||
// Rein gesteuert: hält nur Formular-Zustand und meldet die Entscheidung über
|
||||
// `onExport` nach oben (App führt den eigentlichen Export aus, da es den Plan
|
||||
// erzeugt). Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { t } from "../i18n";
|
||||
import { Dropdown } from "./Dropdown";
|
||||
import { SCALE_PRESETS } from "./TopBar";
|
||||
import type { Orientation, PaperFormat } from "../export/exportPdf";
|
||||
import { pageSizeMm } from "../export/exportPdf";
|
||||
|
||||
/** Entscheidung, die der Dialog beim Bestätigen liefert. */
|
||||
export interface ExportPdfDecision {
|
||||
scaleDenominator: number;
|
||||
paper: PaperFormat;
|
||||
orientation: Orientation;
|
||||
}
|
||||
|
||||
export interface ExportPdfDialogProps {
|
||||
/** Vorbelegung des Massstabs (aktueller Plan-Massstab). */
|
||||
initialScale: number;
|
||||
/** Titel/Geschossname (nur Anzeige im Dialogkopf). */
|
||||
planTitle: string;
|
||||
/**
|
||||
* Massstäbliche Inhalts-Maße des Plans in Metern (Breite/Höhe), für die
|
||||
* „passt aufs Blatt?"-Warnung. Optional.
|
||||
*/
|
||||
contentMeters?: { w: number; h: number };
|
||||
onExport: (decision: ExportPdfDecision) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ExportPdfDialog({
|
||||
initialScale,
|
||||
planTitle,
|
||||
contentMeters,
|
||||
onExport,
|
||||
onClose,
|
||||
}: ExportPdfDialogProps) {
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
const [scale, setScale] = useState<number>(initialScale);
|
||||
const [paper, setPaper] = useState<PaperFormat>("a4");
|
||||
const [orientation, setOrientation] = useState<Orientation>("landscape");
|
||||
|
||||
// Passt der Plan im gewählten Massstab aufs Blatt (abzüglich Rand)? Best-effort-
|
||||
// Hinweis; der Export läuft trotzdem (Plan ragt dann über den Blattrand).
|
||||
const fits = (() => {
|
||||
if (!contentMeters) return true;
|
||||
const { w: pageW, h: pageH } = pageSizeMm(paper, orientation);
|
||||
const margin = 12; // Rand + etwas Luft fürs Schriftfeld
|
||||
const k = 1000 / scale; // mm je Meter
|
||||
return (
|
||||
contentMeters.w * k <= pageW - margin * 2 &&
|
||||
contentMeters.h * k <= pageH - margin * 2
|
||||
);
|
||||
})();
|
||||
|
||||
const onConfirm = () => onExport({ scaleDenominator: scale, paper, orientation });
|
||||
|
||||
const scaleOptions = SCALE_PRESETS.map((n) => ({
|
||||
value: String(n),
|
||||
label: `1:${n}`,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="imp-overlay" onClick={onClose}>
|
||||
<div
|
||||
className="imp-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("export.title")}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<header className="imp-head">
|
||||
<span className="imp-head-title">{t("export.title")}</span>
|
||||
<button
|
||||
className="imp-close"
|
||||
onClick={onClose}
|
||||
aria-label={t("export.close")}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="imp-body">
|
||||
<div className="imp-file">
|
||||
<span className="imp-file-label">{t("export.plan")}</span>
|
||||
<span className="imp-file-name" title={planTitle}>
|
||||
{planTitle}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<section className="imp-section">
|
||||
<div className="imp-section-title">{t("export.scale")}</div>
|
||||
<Dropdown
|
||||
value={String(scale)}
|
||||
onChange={(v) => setScale(Number(v))}
|
||||
title={t("export.scale")}
|
||||
triggerClassName="tb-dd-mono"
|
||||
options={scaleOptions}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="imp-section">
|
||||
<div className="imp-section-title">{t("export.paper")}</div>
|
||||
<Dropdown
|
||||
value={paper}
|
||||
onChange={(v) => setPaper(v as PaperFormat)}
|
||||
title={t("export.paper")}
|
||||
options={[
|
||||
{ value: "a4", label: "A4" },
|
||||
{ value: "a3", label: "A3" },
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="imp-section">
|
||||
<div className="imp-section-title">{t("export.orientation")}</div>
|
||||
<Dropdown
|
||||
value={orientation}
|
||||
onChange={(v) => setOrientation(v as Orientation)}
|
||||
title={t("export.orientation")}
|
||||
options={[
|
||||
{ value: "portrait", label: t("export.orientation.portrait") },
|
||||
{ value: "landscape", label: t("export.orientation.landscape") },
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{!fits && <div className="imp-warn">{t("export.warnFit")}</div>}
|
||||
</div>
|
||||
|
||||
<footer className="imp-foot">
|
||||
<button className="imp-btn" onClick={onClose}>
|
||||
{t("export.cancel")}
|
||||
</button>
|
||||
<button className="imp-btn primary" onClick={onConfirm}>
|
||||
{t("export.confirm")}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+30
-3
@@ -11,14 +11,14 @@
|
||||
// eigentlichen Store-Mutationen (Ebene anlegen, Drawings anhängen, Kontext)
|
||||
// führt App in der richtigen Reihenfolge aus.
|
||||
//
|
||||
// Bezeichner englisch, UI-Text/Kommentare deutsch (CLAUDE.md). Alle sichtbaren
|
||||
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md). Alle sichtbaren
|
||||
// Texte über t(...).
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { t } from "../i18n";
|
||||
import type { TranslationKey } from "../i18n";
|
||||
import type { DrawingLevelKind, ImportedMesh, Project } from "../model/types";
|
||||
import type { DxfImportResult } from "../io/dxfParser";
|
||||
import type { DxfImportResult, ImportDiagnostics } from "../io/dxfParser";
|
||||
import {
|
||||
contoursToDrawings,
|
||||
countContours,
|
||||
@@ -52,6 +52,21 @@ function kindBadgeKey(kind: DrawingLevelKind): TranslationKey {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatiert das Entity-Typ-Histogramm der Import-Diagnose zu einer kompakten
|
||||
* Daten-Zeile, z. B. „120 Entities — INSERT×80, ARC×30, LINE×10". Die Entity-
|
||||
* Typ-Codes sind technische DWG-Bezeichner (keine übersetzbare UI-Prosa); nur
|
||||
* die Gesamtzahl ist eine reine Zahl. Bei 0 Entities zeigt sie „0 Entities" —
|
||||
* das deutet auf ein Encoding-/Versions-Problem (nicht auf eine Abdeckungslücke).
|
||||
*/
|
||||
function formatDiagnostics(d: ImportDiagnostics): string {
|
||||
const parts = Object.entries(d.entityCounts)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([type, n]) => `${type}×${n}`);
|
||||
const head = `${d.total} Entities`;
|
||||
return parts.length > 0 ? `${head} — ${parts.join(", ")}` : head;
|
||||
}
|
||||
|
||||
export interface ImportDialogProps {
|
||||
fileName: string;
|
||||
/**
|
||||
@@ -179,7 +194,19 @@ export function ImportDialog({
|
||||
</li>
|
||||
</ul>
|
||||
{!hasDrawings && !hasMeshes && (
|
||||
<div className="imp-warn">{t("import.summary.empty")}</div>
|
||||
<div className="imp-warn">
|
||||
{t("import.summary.empty")}
|
||||
{/* Diagnose-Histogramm (vom DWG-Parser): macht sichtbar,
|
||||
WORAN es liegt — 0 Entities (Encoding/Version) vs.
|
||||
Abdeckungslücke (z. B. „500 ARC, 200 INSERT"). Entity-Typ-
|
||||
Codes + Zahlen sind technische DWG-Bezeichner (Daten,
|
||||
keine übersetzbare Prosa). */}
|
||||
{parsed?.diagnostics && (
|
||||
<div className="imp-diag">
|
||||
{formatDiagnostics(parsed.diagnostics)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
|
||||
+475
-3
@@ -16,9 +16,10 @@
|
||||
// Rendering noch Persistenz. Bezeichner englisch, UI-Text deutsch
|
||||
// (CONVENTIONS.md).
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type {
|
||||
Component,
|
||||
ComponentMaterial,
|
||||
HatchPattern,
|
||||
HatchStyle,
|
||||
LineStyle,
|
||||
@@ -27,6 +28,15 @@ import type {
|
||||
import { PEN_WEIGHTS } from "../model/types";
|
||||
import { parseLin } from "../io/linParser";
|
||||
import { parsePat } from "../io/patParser";
|
||||
import { MATERIAL_LIBRARY } from "../materials/library";
|
||||
import { materialFromAsset, DEFAULT_TILE_SIZE_M } from "../materials/runtime";
|
||||
import {
|
||||
searchMaterials,
|
||||
fetchMaterialMaps,
|
||||
AMBIENT_CATEGORIES,
|
||||
type AmbientMaterial,
|
||||
type AmbientResolution,
|
||||
} from "../materials/ambientcg";
|
||||
import { EyeIcon } from "./EyeIcon";
|
||||
import { t } from "../i18n";
|
||||
|
||||
@@ -269,7 +279,7 @@ function ResCell({
|
||||
|
||||
// ── Bauteile (Components) ──────────────────────────────────────────────────
|
||||
|
||||
// Spalten: Name | Farbe | Schraffur | Prio | Textur | (löschen).
|
||||
// Spalten: Name | Farbe | Schraffur | Prio | Textur | Material | (löschen).
|
||||
const COMPONENT_COLUMNS: ResColumn[] = [
|
||||
{ titleKey: "resources.col.name" },
|
||||
{ titleKey: "resources.col.color" },
|
||||
@@ -280,10 +290,465 @@ const COMPONENT_COLUMNS: ResColumn[] = [
|
||||
hintKey: "resources.col.prio.hint",
|
||||
},
|
||||
{ titleKey: "resources.col.texture" },
|
||||
{ titleKey: "resources.col.material" },
|
||||
{ titleKey: "" },
|
||||
];
|
||||
const COMPONENT_TEMPLATE =
|
||||
"minmax(120px, 1fr) auto minmax(120px, 0.8fr) 64px minmax(100px, 0.8fr) 36px";
|
||||
"minmax(120px, 1fr) auto minmax(120px, 0.8fr) 64px minmax(90px, 0.7fr) minmax(130px, 0.9fr) 36px";
|
||||
|
||||
/**
|
||||
* Material-Zelle der Bauteil-Tabelle: ein Button, der den aktuellen Material-
|
||||
* Namen zeigt (oder „ohne") und ein Auswahl-Modal öffnet. Die Zuweisung läuft
|
||||
* über `onAssign`/`onClear` (immutabel via onPatchComponent).
|
||||
*/
|
||||
function MaterialCell({
|
||||
material,
|
||||
onAssign,
|
||||
onClear,
|
||||
}: {
|
||||
material: ComponentMaterial | undefined;
|
||||
onAssign: (m: ComponentMaterial) => void;
|
||||
onClear: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const label = materialLabel(material);
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="res-select res-material-btn"
|
||||
onClick={() => setOpen(true)}
|
||||
title={t("material.assign")}
|
||||
>
|
||||
{material?.color && (
|
||||
<span
|
||||
className="res-material-swatch"
|
||||
style={{ backgroundImage: `url(${material.color})` }}
|
||||
/>
|
||||
)}
|
||||
<span className="res-material-name">{label}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<MaterialPicker
|
||||
material={material}
|
||||
onAssign={(m) => {
|
||||
onAssign(m);
|
||||
}}
|
||||
onClear={() => {
|
||||
onClear();
|
||||
}}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Anzeigename eines zugewiesenen Materials (Bibliothek → Name, sonst Upload). */
|
||||
function materialLabel(m: ComponentMaterial | undefined): string {
|
||||
if (!m) return t("material.none");
|
||||
if (m.libraryId) {
|
||||
const asset = MATERIAL_LIBRARY.find((a) => a.id === m.libraryId);
|
||||
if (asset) return asset.name;
|
||||
}
|
||||
return t("material.uploaded");
|
||||
}
|
||||
|
||||
/**
|
||||
* Material-Auswahl-Modal: oben die eingebaute Bibliothek als Vorschau-Grid
|
||||
* (Farb-Karte als Thumbnail) — Klick weist sofort zu; darunter ein Upload-
|
||||
* Bereich (Farbe/Normal/Rauheit/Höhe/AO als Bild-Dateien → ObjectURL) plus die
|
||||
* physische Kachelgröße. „Zuweisen" übernimmt den Upload; „Material entfernen"
|
||||
* setzt das Bauteil auf kein Material zurück.
|
||||
*/
|
||||
function MaterialPicker({
|
||||
material,
|
||||
onAssign,
|
||||
onClear,
|
||||
onClose,
|
||||
}: {
|
||||
material: ComponentMaterial | undefined;
|
||||
onAssign: (m: ComponentMaterial) => void;
|
||||
onClear: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
// Upload-Zustand: gewählte Karten als ObjectURLs + Kachelgröße. Startwert aus
|
||||
// einem bereits zugewiesenen Upload-Material (damit Größe/Karten sichtbar sind).
|
||||
const [upload, setUpload] = useState<ComponentMaterial>(() =>
|
||||
material && !material.libraryId ? { ...material } : { sizeM: DEFAULT_TILE_SIZE_M },
|
||||
);
|
||||
const [sizeM, setSizeM] = useState<number>(material?.sizeM ?? DEFAULT_TILE_SIZE_M);
|
||||
// Modus des Pickers: Schnellauswahl (lokaler Starter, offline), Live-Browse
|
||||
// der kompletten ambientCG-Bibliothek, oder Upload eigener Karten.
|
||||
const [mode, setMode] = useState<"starter" | "browse" | "upload">("starter");
|
||||
|
||||
const pickFile = (kind: keyof ComponentMaterial) => (file: File | null) => {
|
||||
if (!file) return;
|
||||
const url = URL.createObjectURL(file);
|
||||
setUpload((u) => ({ ...u, [kind]: url }));
|
||||
};
|
||||
|
||||
const uploadFields: { kind: keyof ComponentMaterial; labelKey: string }[] = [
|
||||
{ kind: "color", labelKey: "material.upload.color" },
|
||||
{ kind: "normal", labelKey: "material.upload.normal" },
|
||||
{ kind: "roughness", labelKey: "material.upload.roughness" },
|
||||
{ kind: "displacement", labelKey: "material.upload.displacement" },
|
||||
{ kind: "ao", labelKey: "material.upload.ao" },
|
||||
];
|
||||
|
||||
const applyUpload = () => {
|
||||
if (!upload.color && !upload.normal && !upload.roughness && !upload.ao && !upload.displacement) {
|
||||
return;
|
||||
}
|
||||
onAssign({ ...upload, libraryId: undefined, sizeM });
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="res-overlay mat-overlay" onClick={onClose}>
|
||||
<div
|
||||
className="mat-dialog"
|
||||
role="dialog"
|
||||
aria-label={t("material.dialog.title")}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<header className="mat-head">
|
||||
<span className="mat-title">{t("material.dialog.title")}</span>
|
||||
<button className="res-close" onClick={onClose} aria-label={t("material.close")}>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
<div className="mat-hint">{t("material.dialog.hint")}</div>
|
||||
|
||||
{/* Modus-Umschalter: Schnellauswahl / Bibliothek durchsuchen / Upload. */}
|
||||
<nav className="mat-modes" role="tablist">
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={mode === "starter"}
|
||||
className={`mat-mode-btn${mode === "starter" ? " active" : ""}`}
|
||||
onClick={() => setMode("starter")}
|
||||
>
|
||||
{t("material.browse.starter")}
|
||||
</button>
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={mode === "browse"}
|
||||
className={`mat-mode-btn${mode === "browse" ? " active" : ""}`}
|
||||
onClick={() => setMode("browse")}
|
||||
>
|
||||
{t("material.browse")}
|
||||
</button>
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={mode === "upload"}
|
||||
className={`mat-mode-btn${mode === "upload" ? " active" : ""}`}
|
||||
onClick={() => setMode("upload")}
|
||||
>
|
||||
{t("material.upload")}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{/* Schnellauswahl: lokaler 12er-Starter (offline verfügbar). */}
|
||||
{mode === "starter" && (
|
||||
<div className="mat-grid">
|
||||
{MATERIAL_LIBRARY.map((asset) => (
|
||||
<button
|
||||
key={asset.id}
|
||||
className={`mat-tile${material?.libraryId === asset.id ? " active" : ""}`}
|
||||
title={asset.name}
|
||||
onClick={() => {
|
||||
onAssign(materialFromAsset(asset, sizeM));
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="mat-thumb"
|
||||
style={{ backgroundImage: `url(${asset.maps.color})` }}
|
||||
/>
|
||||
<span className="mat-tile-name">{asset.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Live-Browse der kompletten ambientCG-Bibliothek. */}
|
||||
{mode === "browse" && (
|
||||
<AmbientBrowser
|
||||
sizeM={sizeM}
|
||||
activeId={material?.libraryId}
|
||||
onAssign={(m) => {
|
||||
onAssign(m);
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Upload eigener Karten. */}
|
||||
{mode === "upload" && (
|
||||
<>
|
||||
<div className="mat-hint">{t("material.upload.hint")}</div>
|
||||
<div className="mat-upload-grid">
|
||||
{uploadFields.map((f) => (
|
||||
<label key={f.kind} className="mat-upload-row">
|
||||
<span className="mat-upload-label">{t(f.labelKey)}</span>
|
||||
<ImagePickButton onFile={pickFile(f.kind)} />
|
||||
{upload[f.kind] && <span className="mat-upload-ok">✓</span>}
|
||||
</label>
|
||||
))}
|
||||
<label className="mat-upload-row">
|
||||
<span className="mat-upload-label">{t("material.size")}</span>
|
||||
<input
|
||||
type="number"
|
||||
className="res-input mono num"
|
||||
value={sizeM}
|
||||
step={0.1}
|
||||
min={0.05}
|
||||
onChange={(e) => {
|
||||
const v = Number(e.target.value);
|
||||
if (!Number.isNaN(v) && v > 0) setSizeM(v);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="mat-actions">
|
||||
<button className="res-add" onClick={applyUpload}>
|
||||
{t("material.upload.apply")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Kachelgröße (m) für Schnellauswahl + Browse, plus Material entfernen. */}
|
||||
{mode !== "upload" && (
|
||||
<div className="mat-actions mat-actions-browse">
|
||||
<label className="mat-size-inline">
|
||||
<span className="mat-upload-label">{t("material.size")}</span>
|
||||
<input
|
||||
type="number"
|
||||
className="res-input mono num"
|
||||
value={sizeM}
|
||||
step={0.1}
|
||||
min={0.05}
|
||||
onChange={(e) => {
|
||||
const v = Number(e.target.value);
|
||||
if (!Number.isNaN(v) && v > 0) setSizeM(v);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
className="res-add mat-danger"
|
||||
onClick={() => {
|
||||
onClear();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{t("material.clear")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Live-Browse der kompletten ambientCG-Bibliothek: Suchfeld + Kategorie-Filter
|
||||
* + Auflösungswahl über der Trefferliste, Thumbnail-Grid (aus der API, direkt
|
||||
* geladen — CORS `*`) mit Pagination („mehr laden"). Ein Klick lädt die Textur-
|
||||
* Karten des Materials herunter (`fetchMaterialMaps`, über den Proxy), entpackt
|
||||
* sie und weist das entstehende `ComponentMaterial` zu. Lade-/Fehlerzustände
|
||||
* sauber (Spinner, Offline-/CORS-Hinweis).
|
||||
*/
|
||||
function AmbientBrowser({
|
||||
sizeM,
|
||||
activeId,
|
||||
onAssign,
|
||||
}: {
|
||||
sizeM: number;
|
||||
activeId: string | undefined;
|
||||
onAssign: (m: ComponentMaterial) => void;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [committedQuery, setCommittedQuery] = useState("");
|
||||
const [category, setCategory] = useState("");
|
||||
const [resolution, setResolution] = useState<AmbientResolution>("1K");
|
||||
const [items, setItems] = useState<AmbientMaterial[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// ID des gerade herunterladenden Materials (Spinner auf der Kachel).
|
||||
const [downloadingId, setDownloadingId] = useState<string | null>(null);
|
||||
|
||||
const LIMIT = 24;
|
||||
|
||||
// Suche (bei committedQuery/category-Wechsel neu laden — Seite 0).
|
||||
useEffect(() => {
|
||||
const ctrl = new AbortController();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
searchMaterials({
|
||||
query: committedQuery,
|
||||
category,
|
||||
limit: LIMIT,
|
||||
offset: 0,
|
||||
signal: ctrl.signal,
|
||||
})
|
||||
.then((res) => {
|
||||
setItems(res.items);
|
||||
setTotal(res.total);
|
||||
setOffset(0);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (ctrl.signal.aborted) return;
|
||||
setError(t("material.browse.error"));
|
||||
setItems([]);
|
||||
setTotal(0);
|
||||
console.error("[ambientcg] search failed:", e);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!ctrl.signal.aborted) setLoading(false);
|
||||
});
|
||||
return () => ctrl.abort();
|
||||
}, [committedQuery, category]);
|
||||
|
||||
const loadMore = () => {
|
||||
const nextOffset = offset + LIMIT;
|
||||
setLoading(true);
|
||||
searchMaterials({ query: committedQuery, category, limit: LIMIT, offset: nextOffset })
|
||||
.then((res) => {
|
||||
setItems((prev) => [...prev, ...res.items]);
|
||||
setTotal(res.total);
|
||||
setOffset(nextOffset);
|
||||
})
|
||||
.catch((e) => {
|
||||
setError(t("material.browse.error"));
|
||||
console.error("[ambientcg] load more failed:", e);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
const pick = async (id: string) => {
|
||||
setDownloadingId(id);
|
||||
setError(null);
|
||||
try {
|
||||
const { material } = await fetchMaterialMaps(id, resolution, sizeM);
|
||||
onAssign(material);
|
||||
} catch (e) {
|
||||
setError(t("material.browse.downloadError"));
|
||||
console.error("[ambientcg] download failed:", e);
|
||||
} finally {
|
||||
setDownloadingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const submitSearch = () => setCommittedQuery(query);
|
||||
|
||||
return (
|
||||
<div className="mat-browse">
|
||||
<div className="mat-hint">{t("material.browse.hint")}</div>
|
||||
<div className="mat-browse-bar">
|
||||
<input
|
||||
type="text"
|
||||
className="res-input mat-search"
|
||||
value={query}
|
||||
placeholder={t("material.browse.searchPlaceholder")}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") submitSearch();
|
||||
}}
|
||||
/>
|
||||
<button className="res-add" onClick={submitSearch}>
|
||||
{t("material.browse.search")}
|
||||
</button>
|
||||
<select
|
||||
className="res-select"
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
>
|
||||
<option value="">{t("material.browse.category.all")}</option>
|
||||
{AMBIENT_CATEGORIES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="res-select"
|
||||
value={resolution}
|
||||
title={t("material.browse.resolution")}
|
||||
onChange={(e) => setResolution(e.target.value as AmbientResolution)}
|
||||
>
|
||||
<option value="1K">1K</option>
|
||||
<option value="2K">2K</option>
|
||||
<option value="4K">4K</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && <div className="mat-browse-error">{error}</div>}
|
||||
|
||||
<div className="mat-grid mat-browse-grid">
|
||||
{items.map((it) => (
|
||||
<button
|
||||
key={it.id}
|
||||
className={`mat-tile${activeId === it.id ? " active" : ""}${
|
||||
downloadingId === it.id ? " loading" : ""
|
||||
}`}
|
||||
title={`${it.name}${it.category ? ` — ${it.category}` : ""}`}
|
||||
disabled={downloadingId !== null}
|
||||
onClick={() => pick(it.id)}
|
||||
>
|
||||
<span
|
||||
className="mat-thumb"
|
||||
style={{ backgroundImage: it.thumbUrl ? `url(${it.thumbUrl})` : undefined }}
|
||||
/>
|
||||
{downloadingId === it.id && <span className="mat-tile-spinner" />}
|
||||
<span className="mat-tile-name">{it.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!loading && items.length === 0 && !error && (
|
||||
<div className="res-empty">{t("material.browse.empty")}</div>
|
||||
)}
|
||||
|
||||
<div className="mat-browse-foot">
|
||||
{loading && <span className="mat-browse-status">{t("material.browse.loading")}</span>}
|
||||
{!loading && items.length > 0 && (
|
||||
<span className="mat-browse-status">
|
||||
{t("material.browse.count", { shown: items.length, total })}
|
||||
</span>
|
||||
)}
|
||||
{!loading && items.length < total && (
|
||||
<button className="res-add" onClick={loadMore}>
|
||||
{t("material.browse.more")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Versteckter Bild-Datei-Picker mit sichtbarem Button. */
|
||||
function ImagePickButton({ onFile }: { onFile: (file: File | null) => void }) {
|
||||
const ref = useRef<HTMLInputElement | null>(null);
|
||||
return (
|
||||
<>
|
||||
<button className="res-add mat-pick" onClick={() => ref.current?.click()}>
|
||||
{t("material.upload.choose")}
|
||||
</button>
|
||||
<input
|
||||
ref={ref}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => {
|
||||
onFile(e.target.files?.[0] ?? null);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ComponentRow({
|
||||
component,
|
||||
@@ -335,6 +800,13 @@ function ComponentRow({
|
||||
mono
|
||||
/>
|
||||
</ResCell>
|
||||
<ResCell>
|
||||
<MaterialCell
|
||||
material={component.material}
|
||||
onAssign={(material) => onPatch({ material })}
|
||||
onClear={() => onPatch({ material: undefined })}
|
||||
/>
|
||||
</ResCell>
|
||||
<ResCell>
|
||||
<DeleteButton
|
||||
onClick={onDelete}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// Schwebendes Dialog-Fenster zum Bearbeiten eines Rich-Text-Dokuments (Raum-
|
||||
// Stempel / Textobjekt). Doppelklick auf einen Stempel öffnet es (nicht den
|
||||
// Footer). Der Body ist der bestehende RichTextEditor (mit eigener Mini-Toolbar,
|
||||
// hier ok, weil eigenes Fenster). „Übernehmen" schreibt das bearbeitete Dokument
|
||||
// zurück, „Abbrechen" verwirft. Bezeichner englisch, UI-Text deutsch.
|
||||
|
||||
import { useState } from "react";
|
||||
import { t } from "../i18n";
|
||||
import { RichTextEditor } from "../text/RichTextEditor";
|
||||
import type { RichTextDoc } from "../text/richText";
|
||||
|
||||
export interface TextEditorDialogProps {
|
||||
/** Ausgangsdokument (wird beim Öffnen kopiert; Editor arbeitet lokal). */
|
||||
value: RichTextDoc;
|
||||
/** „Übernehmen" — das bearbeitete Dokument zurückschreiben. */
|
||||
onApply: (doc: RichTextDoc) => void;
|
||||
/** „Abbrechen"/Schliessen — ohne Übernahme. */
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function TextEditorDialog({ value, onApply, onCancel }: TextEditorDialogProps) {
|
||||
// Lokaler Arbeitszustand — erst „Übernehmen" schreibt zurück (echtes
|
||||
// Abbrechen möglich). Start = übergebenes Dokument.
|
||||
const [draft, setDraft] = useState<RichTextDoc>(value);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="texteditor-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("text.editTitle")}
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) onCancel();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") onCancel();
|
||||
}}
|
||||
>
|
||||
<div className="texteditor-dialog">
|
||||
<div className="texteditor-head">
|
||||
<span className="texteditor-title">{t("text.editTitle")}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="texteditor-x"
|
||||
onClick={onCancel}
|
||||
title={t("text.cancel")}
|
||||
aria-label={t("text.cancel")}
|
||||
>
|
||||
<span className="material-symbols-outlined" aria-hidden="true">
|
||||
close
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="texteditor-body">
|
||||
<RichTextEditor value={draft} onChange={setDraft} autoFocus />
|
||||
</div>
|
||||
<div className="texteditor-foot">
|
||||
<button type="button" className="texteditor-btn" onClick={onCancel}>
|
||||
{t("text.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="texteditor-btn texteditor-btn-primary"
|
||||
onClick={() => onApply(draft)}
|
||||
>
|
||||
{t("text.apply")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TextEditorDialog;
|
||||
+566
-70
@@ -14,6 +14,21 @@ import { t } from "../i18n";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Dropdown } from "./Dropdown";
|
||||
import type { DropdownItem, DropdownOption } from "./Dropdown";
|
||||
import {
|
||||
applyMark,
|
||||
applyPreset,
|
||||
commonMarkValue,
|
||||
DEFAULT_PRESETS,
|
||||
docLength,
|
||||
isMarkActive,
|
||||
toggleMark,
|
||||
} from "../text/richText";
|
||||
import type {
|
||||
Align,
|
||||
BoolMark,
|
||||
RichTextDoc,
|
||||
TextRange,
|
||||
} from "../text/richText";
|
||||
|
||||
/** Ansichtstyp eines Geschosses (wie in App). */
|
||||
export type ViewType = "grundriss" | "perspektive";
|
||||
@@ -27,9 +42,16 @@ export type DetailLevel = "grob" | "mittel" | "fein";
|
||||
|
||||
/**
|
||||
* Render-/Anzeigemodus der 3D-Ansicht: schattiert, einheitlich weiss (Clay-Look),
|
||||
* Drahtgitter, Verdeckte Kanten (Hidden-Line). Wirkt nur in der Perspektive.
|
||||
* texturiert (echte PBR-Materialien je Bauteil), Drahtgitter, Verdeckte Kanten
|
||||
* (Hidden-Line). Wirkt nur in der Perspektive. Der Slot „section" (Schnitt-
|
||||
* schraffur, Welle B) ist bewusst freigelassen.
|
||||
*/
|
||||
export type RenderMode = "shaded" | "white" | "wireframe" | "hidden";
|
||||
export type RenderMode =
|
||||
| "shaded"
|
||||
| "white"
|
||||
| "textured"
|
||||
| "wireframe"
|
||||
| "hidden";
|
||||
|
||||
/**
|
||||
* Kanonischer Blickwinkel der 3D-Ansicht (Vectorworks/DOSSIER-Stil). „front",
|
||||
@@ -97,6 +119,10 @@ export interface TopBarProps {
|
||||
onFit: () => void;
|
||||
onFitSelection: () => void;
|
||||
onZoom100: () => void;
|
||||
/** Öffnet den PDF-Export-Dialog (nur im Grundriss sinnvoll). */
|
||||
onExportPdf: () => void;
|
||||
/** Öffnet den DXF-Export-Dialog (nur im Grundriss sinnvoll). */
|
||||
onExportDxf: () => void;
|
||||
|
||||
// Darstellungsart — kontextabhängig: Perspektive nutzt `renderMode`,
|
||||
// Grundriss nutzt `planColorMode`. Genau einer der beiden ist je View aktiv.
|
||||
@@ -131,6 +157,30 @@ export interface TopBarProps {
|
||||
|
||||
/** Layout-Menü (von App geliefert, damit dessen Logik dort bleibt). */
|
||||
layoutMenu: ReactNode;
|
||||
|
||||
// ── Text-Gruppe (Stempel-/Text-Formatierung) ───────────────────────────────
|
||||
/**
|
||||
* Ziel der Text-Formatierung: `null` = nichts Text-artiges selektiert (die
|
||||
* Controls setzen nur Defaults für neuen Text). Sonst das aktuell selektierte
|
||||
* Rich-Text-Dokument (z. B. Raumstempel) samt optionalem Bereich und einem
|
||||
* Setter. Ist `range` `null`, wirken Aktionen auf das GANZE Doc.
|
||||
*/
|
||||
textTarget: TextTarget | null;
|
||||
/**
|
||||
* Startet das Text-Werkzeug (neues Textobjekt platzieren). `null`, solange das
|
||||
* Werkzeug noch nicht existiert → der „+ Text"-Knopf ist dann deaktiviert.
|
||||
*/
|
||||
onAddText: (() => void) | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Beschreibt das aktuell formatierbare Rich-Text-Ziel der Oberleiste. `apply`
|
||||
* schreibt das neue Dokument zurück in den App-State (z. B. via setRoomStampDoc).
|
||||
*/
|
||||
export interface TextTarget {
|
||||
doc: RichTextDoc;
|
||||
range: TextRange | null;
|
||||
apply: (doc: RichTextDoc) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -155,6 +205,19 @@ function Sep() {
|
||||
return <span className="tb-sep" aria-hidden="true" />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Material-Symbols-Icon für die Oberleiste (gleiche Icon-Font wie ContextMenu/
|
||||
* MenuIcon). Rein dekorativ — der sprechende Text steckt im `title`/aria-label
|
||||
* des umgebenden Buttons.
|
||||
*/
|
||||
function TbIcon({ name }: { name: string }) {
|
||||
return (
|
||||
<span className="material-symbols-outlined tb-ico" aria-hidden="true">
|
||||
{name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kleines Kombinations-Menü (Vectorworks-Stil) — gespiegelt von `LayoutMenu`:
|
||||
* aktuelle Sichtbarkeit unter einem Namen sichern, eine gespeicherte Kombination
|
||||
@@ -392,6 +455,376 @@ function ViewIcon({ name }: { name: View3d | "camera" | "grundriss" }) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Eine Zelle einer Segmentpille. */
|
||||
interface SegmentCell {
|
||||
/** Stabiler Schlüssel. */
|
||||
key: string;
|
||||
/** Material-Symbols-Icon-Name. */
|
||||
icon: string;
|
||||
/** Tooltip/aria-label (Klartext). */
|
||||
title: string;
|
||||
/** Aktiver Zustand (Akzentfüllung). */
|
||||
active?: boolean;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Segmentpille (DOSSIER-Look): ein Aussencontainer (radius 999, overflow hidden)
|
||||
* mit gleich hohen Zellen; interne Trenner über `border-left`. Aktive Zelle in
|
||||
* Akzentfarbe. Genutzt für Zoom-Aktionen, B/I/U und L/C/R. `accent` setzt einen
|
||||
* Akzent-Rand am Container (Auswahl-Bewusstsein der Text-Gruppe).
|
||||
*/
|
||||
function Segment({
|
||||
cells,
|
||||
ariaLabel,
|
||||
accent,
|
||||
style,
|
||||
}: {
|
||||
cells: SegmentCell[];
|
||||
ariaLabel: string;
|
||||
accent?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`tb-seg${accent ? " tb-seg-accent" : ""}`}
|
||||
role="group"
|
||||
aria-label={ariaLabel}
|
||||
style={style}
|
||||
>
|
||||
{cells.map((c) => (
|
||||
<button
|
||||
key={c.key}
|
||||
type="button"
|
||||
className={`tb-seg-cell${c.active ? " active" : ""}`}
|
||||
onClick={c.onClick}
|
||||
disabled={c.disabled}
|
||||
aria-pressed={c.active}
|
||||
title={c.title}
|
||||
aria-label={c.title}
|
||||
>
|
||||
<span className="material-symbols-outlined tb-ico" aria-hidden="true">
|
||||
{c.icon}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Systemfont-Auswahl der Text-Gruppe. */
|
||||
const TEXT_FONTS: string[] = [
|
||||
"Helvetica",
|
||||
"Arial",
|
||||
"Inter",
|
||||
"Times New Roman",
|
||||
"Georgia",
|
||||
"Courier New",
|
||||
];
|
||||
|
||||
/** Grössen-Presets (pt) der Text-Gruppe. */
|
||||
const TEXT_SIZES: number[] = [8, 9, 10, 11, 12, 14, 18, 24, 36, 48];
|
||||
|
||||
/**
|
||||
* Text-Gruppe der Oberleiste (immer sichtbar, 3×2-Raster). Setzt Defaults für
|
||||
* neuen Text (lokaler State) UND formatiert das aktuelle `textTarget` live. Ist
|
||||
* `textTarget` gesetzt, tragen die Zeile-2-Pillen einen Akzent-Rand und alle
|
||||
* Aktionen wirken auf `textTarget.doc` via `textTarget.apply`.
|
||||
*/
|
||||
function TextGroup({
|
||||
textTarget,
|
||||
onAddText,
|
||||
}: {
|
||||
textTarget: TextTarget | null;
|
||||
onAddText: (() => void) | null;
|
||||
}) {
|
||||
// Defaults für neuen Text, solange nichts Text-artiges selektiert ist. Bei
|
||||
// aktivem Ziel spiegeln die Controls die gemeinsamen Werte des Ziels wider.
|
||||
const [defFont, setDefFont] = useState<string>("Helvetica");
|
||||
const [defSize, setDefSize] = useState<number>(12);
|
||||
const [defAlign, setDefAlign] = useState<Align>("left");
|
||||
const [defBold, setDefBold] = useState(false);
|
||||
const [defItalic, setDefItalic] = useState(false);
|
||||
const [defUnderline, setDefUnderline] = useState(false);
|
||||
|
||||
// Effektiver Bereich am Ziel: expliziter Bereich oder — ohne Bereich — das
|
||||
// ganze Dokument (mind. 1 Zeichen, damit isMarkActive/applyMark greifen).
|
||||
const targetRange = (doc: RichTextDoc, range: TextRange | null): TextRange =>
|
||||
range && range.start !== range.end
|
||||
? range
|
||||
: { start: 0, end: Math.max(1, docLength(doc)) };
|
||||
|
||||
const active = textTarget !== null;
|
||||
|
||||
// Aktueller Anzeige-Zustand: vom Ziel abgeleitet, sonst aus den Defaults.
|
||||
const rng = textTarget ? targetRange(textTarget.doc, textTarget.range) : null;
|
||||
const isBold = textTarget && rng ? isMarkActive(textTarget.doc, rng, "bold") : defBold;
|
||||
const isItalic = textTarget && rng ? isMarkActive(textTarget.doc, rng, "italic") : defItalic;
|
||||
const isUnderline =
|
||||
textTarget && rng ? isMarkActive(textTarget.doc, rng, "underline") : defUnderline;
|
||||
const curFont =
|
||||
textTarget && rng ? commonMarkValue(textTarget.doc, rng, "font") ?? "" : defFont;
|
||||
const curSize =
|
||||
textTarget && rng ? commonMarkValue(textTarget.doc, rng, "sizePt") ?? 0 : defSize;
|
||||
// Ausrichtung: gemeinsamer Wert der berührten Absätze, sonst Default.
|
||||
const curAlign: Align = textTarget
|
||||
? commonAlign(textTarget.doc, rng) ?? "left"
|
||||
: defAlign;
|
||||
|
||||
// ── Aktions-Helfer (wirken auf das Ziel oder setzen Defaults) ──────────────
|
||||
const toggle = (mark: BoolMark, setDefault: (v: boolean) => void, cur: boolean) => {
|
||||
if (textTarget) {
|
||||
const r = targetRange(textTarget.doc, textTarget.range);
|
||||
textTarget.apply(toggleMark(textTarget.doc, r, mark));
|
||||
} else {
|
||||
setDefault(!cur);
|
||||
}
|
||||
};
|
||||
|
||||
const setFontValue = (font: string) => {
|
||||
if (textTarget) {
|
||||
const r = targetRange(textTarget.doc, textTarget.range);
|
||||
textTarget.apply(applyMark(textTarget.doc, r, "font", font));
|
||||
} else {
|
||||
setDefFont(font);
|
||||
}
|
||||
};
|
||||
|
||||
const setSizeValue = (size: number) => {
|
||||
if (!Number.isFinite(size) || size <= 0) return;
|
||||
if (textTarget) {
|
||||
const r = targetRange(textTarget.doc, textTarget.range);
|
||||
textTarget.apply(applyMark(textTarget.doc, r, "sizePt", size));
|
||||
} else {
|
||||
setDefSize(size);
|
||||
}
|
||||
};
|
||||
|
||||
const setAlignValue = (align: Align) => {
|
||||
if (textTarget) {
|
||||
textTarget.apply(applyAlign(textTarget.doc, textTarget.range, align));
|
||||
} else {
|
||||
setDefAlign(align);
|
||||
}
|
||||
};
|
||||
|
||||
const applyPresetValue = (id: string) => {
|
||||
const preset = DEFAULT_PRESETS.find((p) => p.id === id);
|
||||
if (!preset) return;
|
||||
if (textTarget) {
|
||||
const r = textTarget.range ?? { start: 0, end: 0 };
|
||||
textTarget.apply(applyPreset(textTarget.doc, r, preset));
|
||||
} else {
|
||||
// Ohne Ziel: Preset-Defaults übernehmen (Font bleibt, Marks spiegeln).
|
||||
if (preset.marks.sizePt != null) setDefSize(preset.marks.sizePt);
|
||||
setDefBold(!!preset.marks.bold);
|
||||
setDefItalic(!!preset.marks.italic);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Options-Listen ─────────────────────────────────────────────────────────
|
||||
const styleOptions: DropdownOption[] = [
|
||||
{ value: "__none__", label: t("text.style.none") },
|
||||
...DEFAULT_PRESETS.map((p) => ({ value: p.id, label: p.label })),
|
||||
];
|
||||
const fontOptions: DropdownOption[] = TEXT_FONTS.map((f) => ({
|
||||
value: f,
|
||||
label: f,
|
||||
}));
|
||||
const sizeInPresets = TEXT_SIZES.includes(curSize);
|
||||
const sizeOptions: DropdownOption[] = [
|
||||
...(sizeInPresets || curSize <= 0
|
||||
? []
|
||||
: [{ value: "__current__", label: `${curSize} pt` }]),
|
||||
...TEXT_SIZES.map((n) => ({ value: String(n), label: `${n} pt` })),
|
||||
{ value: "__custom__", label: t("text.size.custom") },
|
||||
];
|
||||
|
||||
const onSizeChange = (value: string) => {
|
||||
if (value === "__custom__") {
|
||||
const input = window.prompt(
|
||||
t("text.size.customPrompt"),
|
||||
String(curSize > 0 ? curSize : 12),
|
||||
);
|
||||
const n = input == null ? NaN : Math.round(Number(input.trim()));
|
||||
if (Number.isFinite(n) && n > 0) setSizeValue(n);
|
||||
return;
|
||||
}
|
||||
if (value === "__current__") return;
|
||||
setSizeValue(Number(value));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="tb-group tb-textgroup" role="group" aria-label={t("text.group")}>
|
||||
{/* Zeile 1: Stil-Preset · Schrift · Grösse. */}
|
||||
<div className="tb-textgroup-row">
|
||||
<Dropdown
|
||||
value="__none__"
|
||||
onChange={applyPresetValue}
|
||||
title={t("text.style")}
|
||||
width={110}
|
||||
options={styleOptions}
|
||||
/>
|
||||
<Dropdown
|
||||
value={curFont || "__none__"}
|
||||
onChange={setFontValue}
|
||||
title={t("text.font")}
|
||||
width={130}
|
||||
options={
|
||||
curFont && !TEXT_FONTS.includes(curFont)
|
||||
? [{ value: curFont, label: curFont }, ...fontOptions]
|
||||
: fontOptions
|
||||
}
|
||||
/>
|
||||
<Dropdown
|
||||
value={
|
||||
curSize <= 0
|
||||
? "__custom__"
|
||||
: sizeInPresets
|
||||
? String(curSize)
|
||||
: "__current__"
|
||||
}
|
||||
onChange={onSizeChange}
|
||||
title={t("text.size")}
|
||||
width={80}
|
||||
triggerClassName="tb-dd-mono"
|
||||
options={sizeOptions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Zeile 2: B/I/U · L/C/R · „+ Text". */}
|
||||
<div className="tb-textgroup-row">
|
||||
<Segment
|
||||
ariaLabel={t("text.group")}
|
||||
accent={active}
|
||||
style={{ width: 110 }}
|
||||
cells={[
|
||||
{
|
||||
key: "bold",
|
||||
icon: "format_bold",
|
||||
title: t("text.bold"),
|
||||
active: !!isBold,
|
||||
onClick: () => toggle("bold", setDefBold, defBold),
|
||||
},
|
||||
{
|
||||
key: "italic",
|
||||
icon: "format_italic",
|
||||
title: t("text.italic"),
|
||||
active: !!isItalic,
|
||||
onClick: () => toggle("italic", setDefItalic, defItalic),
|
||||
},
|
||||
{
|
||||
key: "underline",
|
||||
icon: "format_underlined",
|
||||
title: t("text.underline"),
|
||||
active: !!isUnderline,
|
||||
onClick: () => toggle("underline", setDefUnderline, defUnderline),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Segment
|
||||
ariaLabel={t("text.group")}
|
||||
accent={active}
|
||||
style={{ width: 130 }}
|
||||
cells={[
|
||||
{
|
||||
key: "left",
|
||||
icon: "format_align_left",
|
||||
title: t("text.align.left"),
|
||||
active: curAlign === "left",
|
||||
onClick: () => setAlignValue("left"),
|
||||
},
|
||||
{
|
||||
key: "center",
|
||||
icon: "format_align_center",
|
||||
title: t("text.align.center"),
|
||||
active: curAlign === "center",
|
||||
onClick: () => setAlignValue("center"),
|
||||
},
|
||||
{
|
||||
key: "right",
|
||||
icon: "format_align_right",
|
||||
title: t("text.align.right"),
|
||||
active: curAlign === "right",
|
||||
onClick: () => setAlignValue("right"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="tb-addtext"
|
||||
disabled={onAddText == null}
|
||||
onClick={() => onAddText?.()}
|
||||
title={onAddText ? t("text.add") : t("text.add.hint")}
|
||||
aria-label={t("text.add")}
|
||||
>
|
||||
<span className="material-symbols-outlined tb-ico" aria-hidden="true">
|
||||
add
|
||||
</span>
|
||||
<span className="tb-addtext-label">{t("text.add")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemeinsame Absatz-Ausrichtung der vom Bereich berührten Absätze; `undefined`
|
||||
* bei uneinheitlicher/leerer Auswahl. Ohne Bereich → Ausrichtung des ersten
|
||||
* Absatzes (das ganze Doc als Ziel).
|
||||
*/
|
||||
function commonAlign(doc: RichTextDoc, range: TextRange | null): Align | undefined {
|
||||
const paras = touchedParagraphs(doc, range);
|
||||
if (paras.length === 0) return doc.paragraphs[0]?.align ?? "left";
|
||||
let value: Align | undefined;
|
||||
let first = true;
|
||||
for (const idx of paras) {
|
||||
const a = doc.paragraphs[idx]?.align ?? "left";
|
||||
if (first) {
|
||||
value = a;
|
||||
first = false;
|
||||
} else if (a !== value) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt die Absatz-Ausrichtung auf alle vom Bereich berührten Absätze. Ohne
|
||||
* Bereich (null) auf ALLE Absätze des Dokuments.
|
||||
*/
|
||||
function applyAlign(doc: RichTextDoc, range: TextRange | null, align: Align): RichTextDoc {
|
||||
const touched = new Set(touchedParagraphs(doc, range));
|
||||
const all = range == null;
|
||||
return {
|
||||
version: 1,
|
||||
paragraphs: doc.paragraphs.map((p, i) =>
|
||||
all || touched.has(i) ? { ...p, align } : p,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Indizes der Absätze, die der Bereich [start,end) berührt. */
|
||||
function touchedParagraphs(doc: RichTextDoc, range: TextRange | null): number[] {
|
||||
if (range == null || range.start === range.end) {
|
||||
return doc.paragraphs.map((_, i) => i);
|
||||
}
|
||||
const start = Math.min(range.start, range.end);
|
||||
const end = Math.max(range.start, range.end);
|
||||
const out: number[] = [];
|
||||
let cursor = 0;
|
||||
doc.paragraphs.forEach((p, i) => {
|
||||
const len = p.runs.reduce((n, r) => n + r.text.length, 0);
|
||||
const pStart = cursor;
|
||||
const pEnd = cursor + len;
|
||||
if (!(pEnd < start || pStart > end)) out.push(i);
|
||||
cursor += len + 1;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
export function TopBar({
|
||||
project,
|
||||
viewToggleEnabled,
|
||||
@@ -412,6 +845,8 @@ export function TopBar({
|
||||
onFit,
|
||||
onFitSelection,
|
||||
onZoom100,
|
||||
onExportPdf,
|
||||
onExportDxf,
|
||||
renderMode,
|
||||
onRenderMode,
|
||||
renderModeEnabled,
|
||||
@@ -427,6 +862,8 @@ export function TopBar({
|
||||
resourcesOpen,
|
||||
onToggleResources,
|
||||
layoutMenu,
|
||||
textTarget,
|
||||
onAddText,
|
||||
}: TopBarProps) {
|
||||
// Dezente Scrollleiste: die Oberleiste scrollt horizontal (overflow-x). Statt
|
||||
// der prägnanten Standard-Leiste blenden wir eine eigene, sehr ruhige Leiste
|
||||
@@ -578,8 +1015,10 @@ export function TopBar({
|
||||
{/* Detailgrad (grob/mittel/fein) — wirkt im Grundriss auf Symbole +
|
||||
Schraffuren/Linien. In der Perspektive (noch) ohne Wirkung → dort
|
||||
deaktiviert mit Tooltip statt stiller No-Op. */}
|
||||
<div className="tb-group" role="group" aria-label={t("topbar.detailGroup")}>
|
||||
<label className="tb-field">
|
||||
{/* Detailgrad + Massstab gestapelt — eine Spalte, platzsparend (wie die
|
||||
Ebenen-/Zeichnungs-Kombis). */}
|
||||
<div className="tb-group tb-stack" role="group" aria-label={t("topbar.detailGroup")}>
|
||||
<label className="tb-field tb-field-row">
|
||||
<span className="tb-label">{t("topbar.detail")}</span>
|
||||
<Dropdown
|
||||
value={detail}
|
||||
@@ -597,16 +1036,7 @@ export function TopBar({
|
||||
]}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Sep />
|
||||
|
||||
{/* Massstab „1:N" (echter Papier-Massstab) + Zoom-Buttons. Das Dropdown
|
||||
zeigt den gewählten/angewandten Massstab; rechts steht der LIVE 1:N
|
||||
aus der View-Transform (ändert sich beim Zoomen). Nur aktiv, wenn ein
|
||||
Plan gezeichnet wird (Perspektive zeigt „—"). */}
|
||||
<div className="tb-group" role="group" aria-label={t("topbar.scaleGroup")}>
|
||||
<label className="tb-field">
|
||||
<label className="tb-field tb-field-row">
|
||||
<span className="tb-label">{t("topbar.scale")}</span>
|
||||
<Dropdown
|
||||
value={inPresets ? String(scaleDenominator) : "__current__"}
|
||||
@@ -617,42 +1047,103 @@ export function TopBar({
|
||||
options={scaleOptions}
|
||||
/>
|
||||
</label>
|
||||
<span className="tb-live-scale" title={t("topbar.scale.live")}>
|
||||
{zoomEnabled && liveScale != null ? `1:${Math.round(liveScale)}` : "—"}
|
||||
</span>
|
||||
<div className="tb-btns">
|
||||
<button
|
||||
className="tb-btn"
|
||||
onClick={onZoom100}
|
||||
disabled={!zoomEnabled}
|
||||
title={t("topbar.zoom100.hint", { n: scaleDenominator })}
|
||||
>
|
||||
{t("topbar.zoom100")}
|
||||
</button>
|
||||
<button
|
||||
className="tb-btn"
|
||||
onClick={onFit}
|
||||
disabled={!zoomEnabled}
|
||||
title={t("topbar.fit.hint")}
|
||||
>
|
||||
{t("topbar.fit")}
|
||||
</button>
|
||||
<button
|
||||
className="tb-btn"
|
||||
onClick={onFitSelection}
|
||||
disabled={!zoomEnabled}
|
||||
title={t("topbar.fitSelection.hint")}
|
||||
>
|
||||
{t("topbar.fitSelection")}
|
||||
</button>
|
||||
</div>
|
||||
<span className="tb-zoom" title={t("topbar.zoom.current")}>
|
||||
{zoomEnabled ? `${Math.round(zoomPercent)}%` : "—"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Sep />
|
||||
|
||||
{/* Massstab/Zoom-Cluster (DOSSIER, 2×2). Links über beide Zeilen die
|
||||
kombinierte Stat-Pille (Live-Massstab 1:N oben / Zoom% unten); rechts
|
||||
oben das Massstab-Dropdown, rechts unten die Zoom-Segmentpille. „am
|
||||
Massstab" (Live-Massstab == gewählter Massstab) hebt die Stat-Pille im
|
||||
Akzent hervor. */}
|
||||
<div className="tb-group tb-zoomcluster" role="group" aria-label={t("topbar.scaleGroup")}>
|
||||
<div
|
||||
className={`tb-stat${
|
||||
zoomEnabled && liveScale != null && Math.round(liveScale) === scaleDenominator
|
||||
? " tb-stat-atscale"
|
||||
: ""
|
||||
}`}
|
||||
title={`${t("topbar.scale.live")} · ${t("topbar.zoom.current")}`}
|
||||
>
|
||||
<span className="tb-stat-scale">
|
||||
{zoomEnabled && liveScale != null ? `1:${Math.round(liveScale)}` : "—"}
|
||||
</span>
|
||||
<span className="tb-stat-zoom">
|
||||
{zoomEnabled ? `${Math.round(zoomPercent)}%` : "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="tb-zoomcluster-right">
|
||||
<Dropdown
|
||||
value={inPresets ? String(scaleDenominator) : "__current__"}
|
||||
disabled={!zoomEnabled}
|
||||
onChange={onScaleChange}
|
||||
title={t("topbar.scale.apply")}
|
||||
triggerClassName="tb-dd-mono"
|
||||
width={140}
|
||||
options={scaleOptions}
|
||||
/>
|
||||
<Segment
|
||||
ariaLabel={t("topbar.scaleGroup")}
|
||||
cells={[
|
||||
{
|
||||
key: "zoom100",
|
||||
icon: "straighten",
|
||||
title: t("topbar.zoom100.hint", { n: scaleDenominator }),
|
||||
onClick: onZoom100,
|
||||
disabled: !zoomEnabled,
|
||||
},
|
||||
{
|
||||
key: "fit",
|
||||
icon: "fit_screen",
|
||||
title: t("topbar.fit.hint"),
|
||||
onClick: onFit,
|
||||
disabled: !zoomEnabled,
|
||||
},
|
||||
{
|
||||
key: "fitSelection",
|
||||
icon: "center_focus_strong",
|
||||
title: t("topbar.fitSelection.hint"),
|
||||
onClick: onFitSelection,
|
||||
disabled: !zoomEnabled,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Export als eigene kleine Pillen-Reihe (nicht mehr Teil des Zoom-Blocks,
|
||||
damit der Cluster ruhig bleibt). */}
|
||||
<div className="tb-group tb-btns" role="group" aria-label={t("topbar.exportPdf")}>
|
||||
<button
|
||||
type="button"
|
||||
className="tb-iconbtn"
|
||||
onClick={onExportPdf}
|
||||
disabled={!zoomEnabled}
|
||||
title={t("topbar.exportPdf.hint")}
|
||||
aria-label={t("topbar.exportPdf")}
|
||||
>
|
||||
<TbIcon name="picture_as_pdf" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="tb-iconbtn"
|
||||
onClick={onExportDxf}
|
||||
disabled={!zoomEnabled}
|
||||
title={t("topbar.exportDxf.hint")}
|
||||
aria-label={t("topbar.exportDxf")}
|
||||
>
|
||||
<TbIcon name="download" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Sep />
|
||||
|
||||
{/* Text-Gruppe (immer sichtbar) — Stil/Schrift/Grösse + B/I/U · L/C/R +
|
||||
„+ Text". Formatiert das aktuelle Text-Ziel (Raumstempel) live. */}
|
||||
<TextGroup textTarget={textTarget} onAddText={onAddText} />
|
||||
|
||||
<Sep />
|
||||
|
||||
{/* Darstellungsart (Vectorworks-Stil) — EIN Dropdown, dessen Optionen vom
|
||||
Ansichtstyp abhängen: Grundriss → Farbig/Schwarz-Weiss (planColorMode);
|
||||
Perspektive → Schattiert/Weiss/Drahtgitter/Kanten (renderMode). Der
|
||||
@@ -669,6 +1160,7 @@ export function TopBar({
|
||||
options={[
|
||||
{ value: "shaded", label: t("display.style.shaded") },
|
||||
{ value: "white", label: t("display.style.white") },
|
||||
{ value: "textured", label: t("display.style.textured") },
|
||||
{ value: "wireframe", label: t("display.style.wireframe") },
|
||||
{ value: "hidden", label: t("display.style.hidden") },
|
||||
]}
|
||||
@@ -693,54 +1185,58 @@ export function TopBar({
|
||||
Achslinien im Grundriss. Nur im Grundriss sinnvoll → sonst deaktiviert. */}
|
||||
<div className="tb-group" role="group" aria-label={t("topbar.referenceLinesGroup")}>
|
||||
<button
|
||||
className={`tb-toggle${referenceLines ? " active" : ""}`}
|
||||
className={`view-icon${referenceLines ? " active" : ""}`}
|
||||
onClick={() => onReferenceLines(!referenceLines)}
|
||||
disabled={!referenceLinesEnabled}
|
||||
aria-pressed={referenceLines}
|
||||
aria-label={t("topbar.referenceLines")}
|
||||
title={
|
||||
referenceLinesEnabled
|
||||
? t("topbar.referenceLines.hint")
|
||||
? `${t("topbar.referenceLines")} — ${t("topbar.referenceLines.hint")}`
|
||||
: t("topbar.referenceLines.hintDisabled")
|
||||
}
|
||||
>
|
||||
{t("topbar.referenceLines")}
|
||||
<TbIcon name="straighten" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Sep />
|
||||
|
||||
{/* Linien-Darstellung: Display (alle Haarlinien) ↔ Print (echte mm-
|
||||
Strichstärken). Nur im Grundriss sinnvoll. */}
|
||||
Strichstärken). Eine Einfach-Auswahl → als Dropdown (Trigger-Pill +
|
||||
Häkchen-Liste, wie Detailgrad/Massstab), nicht mehr als Segment-Pillen.
|
||||
Optionen tragen ein Icon plus Klartext. Nur im Grundriss sinnvoll. */}
|
||||
<div className="tb-group" role="group" aria-label={t("topbar.lineModeGroup")}>
|
||||
<div className="view-toggle">
|
||||
<button
|
||||
className={`view-btn${lineMode === "display" ? " active" : ""}`}
|
||||
onClick={() => onLineMode("display")}
|
||||
<label className="tb-field">
|
||||
<span className="tb-label">{t("topbar.lineModeGroup")}</span>
|
||||
<Dropdown
|
||||
value={lineMode}
|
||||
disabled={!lineModeEnabled}
|
||||
title={t("topbar.lineMode.display.hint")}
|
||||
>
|
||||
{t("topbar.lineMode.display")}
|
||||
</button>
|
||||
<button
|
||||
className={`view-btn${lineMode === "print" ? " active" : ""}`}
|
||||
onClick={() => onLineMode("print")}
|
||||
disabled={!lineModeEnabled}
|
||||
title={t("topbar.lineMode.print.hint")}
|
||||
>
|
||||
{t("topbar.lineMode.print")}
|
||||
</button>
|
||||
</div>
|
||||
onChange={(v) => onLineMode(v as "display" | "print")}
|
||||
title={
|
||||
lineModeEnabled
|
||||
? `${t("topbar.lineMode.display.hint")} · ${t("topbar.lineMode.print.hint")}`
|
||||
: t("topbar.lineModeGroup")
|
||||
}
|
||||
options={[
|
||||
{ value: "display", label: t("topbar.lineMode.display") },
|
||||
{ value: "print", label: t("topbar.lineMode.print") },
|
||||
]}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Rechte Gruppe: Layout-Menü · Ressourcen · Projektname. */}
|
||||
<div className="topbar-right">
|
||||
{layoutMenu}
|
||||
<button
|
||||
className={`res-trigger${resourcesOpen ? " active" : ""}`}
|
||||
className={`view-icon${resourcesOpen ? " active" : ""}`}
|
||||
onClick={onToggleResources}
|
||||
title={t("topbar.resources.hint")}
|
||||
aria-pressed={resourcesOpen}
|
||||
aria-label={t("topbar.resources")}
|
||||
title={`${t("topbar.resources")} — ${t("topbar.resources.hint")}`}
|
||||
>
|
||||
{t("topbar.resources")}
|
||||
<TbIcon name="widgets" />
|
||||
</button>
|
||||
<span className="project-name">{project.name}</span>
|
||||
</div>
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
// Modusleiste der aktiven Transformation (Bewegen/Spiegeln/Drehen), im Stil von
|
||||
// Vectorworks. Zeigt die Operation, die Vervielfältigungs-Modi U/I/O/P und einen
|
||||
// kontextuellen Klick-Hinweis. Tasten U/I/O/P schalten die Modi (in App); ein
|
||||
// Klick auf die Schaltflächen tut dasselbe. Rein gesteuert (controlled).
|
||||
|
||||
import { t } from "../i18n";
|
||||
import type { CopyMode, TransformOp } from "../tools/transform";
|
||||
|
||||
const MODES: { key: string; mode: CopyMode; labelKey: string }[] = [
|
||||
{ key: "U", mode: "move", labelKey: "transform.mode.move" },
|
||||
{ key: "I", mode: "copy", labelKey: "transform.mode.copy" },
|
||||
{ key: "O", mode: "array", labelKey: "transform.mode.array" },
|
||||
{ key: "P", mode: "distribute", labelKey: "transform.mode.distribute" },
|
||||
];
|
||||
|
||||
/** Kontextueller Hinweis je Operation + Anzahl bereits gesetzter Punkte. */
|
||||
function hintKey(op: TransformOp, pointsLen: number): string {
|
||||
return `transform.hint.${op}.${Math.min(pointsLen, op === "rotate" ? 2 : 1)}`;
|
||||
}
|
||||
|
||||
export function TransformBar({
|
||||
op,
|
||||
copyMode,
|
||||
count,
|
||||
pointsLen,
|
||||
onMode,
|
||||
onCancel,
|
||||
}: {
|
||||
op: TransformOp;
|
||||
copyMode: CopyMode;
|
||||
count: number;
|
||||
pointsLen: number;
|
||||
onMode: (mode: CopyMode) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="transform-bar" role="group" aria-label={t("transform.bar")}>
|
||||
<span className="tf-op">{t(`transform.${op}`)}</span>
|
||||
<span className="tf-sep" />
|
||||
<div className="tf-modes">
|
||||
{MODES.map((m) => (
|
||||
<button
|
||||
key={m.mode}
|
||||
className={`tf-mode${copyMode === m.mode ? " active" : ""}`}
|
||||
onClick={() => onMode(m.mode)}
|
||||
title={t(m.labelKey)}
|
||||
>
|
||||
<span className="tf-key">{m.key}</span>
|
||||
<span className="tf-mode-label">
|
||||
{t(m.labelKey)}
|
||||
{(m.mode === "array" || m.mode === "distribute") && copyMode === m.mode
|
||||
? ` ×${count}`
|
||||
: ""}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="tf-sep" />
|
||||
<span className="tf-hint">{t(hintKey(op, pointsLen))}</span>
|
||||
<button className="tf-cancel" onClick={onCancel} title={t("transform.cancel")}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+1362
-25
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user