Browser-BIM (cad): semantisches Modell, abgeleitete 2D/3D-Sichten, Zeichenwerkzeuge

Standalone-Browser-Port von DOSSIER. Enthaelt das semantische Modell mit
Plan-/3D-Ableitung, Zeichen- und Editierwerkzeuge, Rhino-artiges Befehlssystem,
dockbares Panel-System, Resource-Manager, DXF/.lin/.pat-Import, i18n (de/en)
sowie Projektdokumentation und Probe-Harness.
This commit is contained in:
2026-06-30 20:52:27 +02:00
commit 359ce35737
138 changed files with 31585 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
# Dependencies
node_modules/
# Build output
dist/
*.tsbuildinfo
# Probe / screenshot artifacts (regenerated)
scripts/*.png
# Editor / OS
.DS_Store
*.log
+122
View File
@@ -0,0 +1,122 @@
# DOSSIER Standalone
Browser-BIM für Wohnbau. Ein Werkzeug, um ein Wohnhaus aus semantischen
Bauteilen zu modellieren und daraus saubere, normgerechte 2D-Pläne zu ziehen —
ohne Revit, ohne Installation, im Browser.
Das ist die eigenständige Browser-Variante des Rhino-Plugins
[DOSSIER](https://git.kgva.ch/karim/DOSSIER): dieselbe Denkweise (Geschosse,
Kategorie-Ebenen, mehrschichtige Bauteile, Prioritäts-Verschneidung), aber als
React/Three.js-App statt als Rhino-Aufsatz.
## Grundgedanke
Es gibt **ein semantisches Modell** als einzige Wahrheit. Jede Ansicht — 3D,
Grundriss, Schnitt, Ansicht — wird daraus **abgeleitet**. Darstellung
(Detailgrad, Linienstärken, Schraffuren) wird erst beim Rendern angewandt, nie
in die Geometrie eingebacken. Wer eine Wand verschiebt, verschiebt sie überall.
Konkret heißt das auch: Der Grundriss entsteht nicht aus einem zerschnittenen
3D-Mesh, sondern **analytisch aus den Bauteil-Parametern** (Wandachsen + Dicken →
Linien, Öffnungen → Lücken + Symbol). Schnitte und Ansichten brauchen später den
zweiten Weg — echte 3D-Projektion mit verdeckten Kanten.
## Stand heute
Ehrlich eingeordnet: aus dem Risiko-Spike ist ein **benutzbares 2D-CAD mit
abgeleiteter 3D-Sicht** geworden. Der einfachere Teil steht und ist per
Screenshot verifiziert; die schwierigen BIM-Kernrisiken sind bewusst noch offen.
**Funktioniert:**
- Semantisches Modell mit **mehrschichtigen Wänden** und automatischer
**L-Ecken-Gehrung** (`computeJoins`); dieselbe Logik speist Plan *und* 3D.
- **Dokumentmodell wie in DOSSIER:** Zeichnungsebenen (Geschosse + Schnitte/
Ansichten/Zeichnungen) × Kategorie-Ebenen (Code-Baum 1:1, `20 Wände`, `30 Decken` …).
- **Zeichnen & Editieren** im Grundriss: Wand, Linie, Polylinie, Rechteck, Kreis;
Snapping (Endpunkt/Mittelpunkt/Schnittpunkt/Lot/Raster/Ortho), Grips, Move/Copy/
Offset, Spiegeln/Drehen/Array.
- **Rhino-artiges Befehlssystem** mit getippten Koordinaten (`5,3` · `r5,3` ·
`5<45`) und Tab-Feld-Zyklus (Länge → Winkel …).
- **Resource Manager** (Component / Hatch / Line) — alles per id referenziert,
zentral änderbar.
- **Panel-System** (dockbar, stapelbar, floatend), Top-Bar + Statusleiste
(echter Maßstab 1:N, Cursor X/Y), eigenes Kontextmenü, **i18n** (de/en).
- **Import:** DXF (Konturen/Mesh) → Terrain-TIN; `.lin`/`.pat` für Linien/Schraffuren.
**Bewusst noch offen** (die eigentlich harten Teile):
- Echte **3D-Booleans** für Öffnungen — Türen/Fenster sind im Plan eine Lücke,
noch kein geschnittenes Volumen.
- **HLR** (verdeckte Kanten) für Schnitte und Ansichten — diese Views sind Stubs.
- **Prioritäts-T-/X-Stöße** mehrschichtiger Wände (Beton läuft durch, Putz
verbindet seitlich) — das berüchtigte Risiko #1.
Details und die Begründungen stehen im
[HANDOVER](HANDOVER.md) (laufendes Arbeitsprotokoll) und in der
[ROADMAP](ROADMAP.md) (Vision, Phasen, Backlog).
## Stack
Bewusst leichtgewichtig — Three.js ist reiner Display-Layer, kein schwerer
Geometrie-Kernel verfrüht eingezogen.
| | |
|---|---|
| Frontend | React + TypeScript + Vite |
| 3D | Three.js |
| 2D-Plan | eigener SVG-Renderer |
| Geometrie | eigener 2D-Kernel (`src/geometry/kernel2d.ts`), `delaunator` fürs Terrain |
| Import | `dxf-parser`, eigene `.lin`/`.pat`-Parser |
Geplant, aber noch nicht eingezogen: `rhino3dm` (NURBS / `.3dm`), web-ifc (IFC),
OpenCascade/Manifold (exakte Booleans + HLR). Siehe ROADMAP §4.
## Entwicklung
```bash
npm install
npm run dev # Vite, http://localhost:5173
npx tsc -b # Typecheck
npm run build # tsc -b && vite build
```
Verifiziert wird visuell: `node scripts/probe.mjs` rendert die App headless und
schreibt `scripts/probe.png` — Screenshot ansehen, Geometrie prüfen. Für
Firefox-Fälle gibt es `scripts/probe-ff*.mjs` (Playwright).
## Aufbau
```
src/
model/ semantisches Modell + Ableitungen (types, geometry, joins, terrain)
geometry/ 2D-Kernel (offset/trim/fillet/intersect)
commands/ Rhino-artiges Befehlssystem (engine, parser, registry, cmds/)
tools/ interaktive Zeichenwerkzeuge + Snapping + Transformationen
plan/ Grundriss-Ableitung (generatePlan) + PlanView (SVG)
viewport/ Viewport3D (Three.js)
panels/ dockbares Panel-System + die einzelnen Paletten
state/ Store + Slices (project/selection/view/layout)
ui/ Top-Bar, Statusleiste, Kontextmenü, Resource Manager, Command-Line
io/ Import/Export (DXF, .lin, .pat)
i18n/ Wörterbücher de/en
```
## Konventionen
- **Bezeichner im Code sind englisch**, an Vectorworks-Terminologie angelehnt
(Design Layer, Component, Hatch, Wall Style). **UI-Texte sind deutsch**, immer
über `t('key')` — keine hartcodierten Strings im JSX.
- Intern alles in **Metern**; Anzeige via `formatM`.
- `App.tsx` bleibt dünner Shell, Zustand lebt im Store. Verbindliches in
[CONVENTIONS.md](CONVENTIONS.md).
## Weiterlesen
- [ROADMAP.md](ROADMAP.md) — Produktvision, Architektur-Entscheidungen, Phasen 0–7, DOSSIER-Backlog
- [ARCHITECTURE.md](ARCHITECTURE.md) — Technische Architektur im Detail
- [HANDOVER.md](HANDOVER.md) — aktueller Arbeitsstand, Befunde, nächste Schritte
- [docs/](docs/) — Design-Specs (Befehlssystem, Zeichenwerkzeuge, Wand-Joins, Backend …)
---
Privates Projekt. Die UI ist deutsch; Schweizer Spezifika (SIA-416-Flächen,
Swisstopo-Geodaten) sind als Differenzierer eingeplant.
+24
View File
@@ -0,0 +1,24 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Material Symbols (Icon-Font) — DOSSIER-Look; ohne diese Font erschienen
die Ligaturnamen (z. B. „content_copy") als roher Text in Menüs/Toolbar. -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200"
/>
<link
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' rx='3' fill='%232f6df6'/%3E%3Cg fill='white'%3E%3Crect x='3' y='3' width='4' height='4'/%3E%3Crect x='9' y='3' width='4' height='4'/%3E%3Crect x='3' y='9' width='4' height='4'/%3E%3Crect x='9' y='9' width='4' height='4'/%3E%3C/g%3E%3C/svg%3E"
/>
<title>cad — Phase 0 Spike</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2260
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "cad",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@mlightcad/libredwg-web": "^0.7.7",
"delaunator": "^5.1.0",
"dxf-parser": "^1.1.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"three": "^0.169.0"
},
"devDependencies": {
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0",
"@types/three": "^0.169.0",
"@vitejs/plugin-react": "^4.3.2",
"playwright": "^1.61.1",
"puppeteer": "^25.2.1",
"typescript": "^5.6.2",
"vite": "^5.4.8"
}
}
+21
View File
@@ -0,0 +1,21 @@
import puppeteer from "puppeteer";
const browser = await puppeteer.launch({ headless: "new", args: ["--no-sandbox","--use-gl=swiftshader","--enable-unsafe-swiftshader"] });
const page = await browser.newPage();
await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 2 });
const logs=[]; page.on("pageerror",e=>logs.push("[PAGEERROR] "+e.message)); page.on("console",m=>logs.push("[console] "+m.text()));
try { await page.goto("http://localhost:5173/", { waitUntil:"networkidle0", timeout:8000 }); } catch(e){ logs.push("[goto] "+e.message); }
await page.evaluate(()=>{ try{localStorage.removeItem("cad.layout");}catch{} });
await page.reload({waitUntil:"domcontentloaded"}).catch(()=>{});
await page.waitForSelector(".plan-svg polygon",{timeout:20000});
await new Promise(r=>setTimeout(r,800));
const dom = await page.evaluate(()=>({
docks: [...document.querySelectorAll(".dock")].map(d=>d.className),
hasLeft: !!document.querySelector(".dock-left"),
hasRight: !!document.querySelector(".dock-right"),
resTriggerActive: document.querySelector(".res-trigger")?.classList.contains("active") ?? null,
layout: (()=>{try{return JSON.parse(localStorage.getItem("cad.layout"));}catch{return "ERR";}})(),
bodyChildren: [...(document.querySelector(".body")?.children||[])].map(c=>c.className),
}));
console.log(JSON.stringify(dom,null,2));
console.log("LOGS:\n"+logs.join("\n"));
await browser.close();
+10
View File
@@ -0,0 +1,10 @@
import puppeteer from "puppeteer";
const b = await puppeteer.launch({ headless:"new", args:["--no-sandbox","--use-gl=swiftshader","--enable-unsafe-swiftshader"]});
const p = await b.newPage(); await p.setViewport({width:1500,height:950,deviceScaleFactor:2});
const errs=[]; p.on("pageerror",e=>errs.push(e.message));
try{ await p.goto("http://localhost:5173/",{waitUntil:"networkidle0",timeout:15000}); }catch{}
await new Promise(r=>setTimeout(r,1000));
const info = await p.evaluate(()=>({rootLen:document.getElementById("root")?.innerHTML.length??-1, viteOverlay: !!document.querySelector("vite-error-overlay"), bodyText: document.body.innerText.slice(0,80)}));
await p.screenshot({path:"scripts/probe-now.png"});
console.log(JSON.stringify(info), "errs:", errs.slice(0,2).join(" | ")||"none");
await b.close();
+4
View File
@@ -0,0 +1,4 @@
// Bündel-Einstieg für check-terrain.mjs — re-exportiert die zu testenden
// Funktionen, damit esbuild ein einzelnes ESM-Bundle bauen kann.
export { parseDxf } from "../src/io/dxfParser";
export { generateTerrainFromContours } from "../src/model/terrain";
+21
View File
@@ -0,0 +1,21 @@
import puppeteer from "puppeteer";
const b=await puppeteer.launch({headless:"new",args:["--no-sandbox","--use-gl=swiftshader","--enable-unsafe-swiftshader"]});
const p=await b.newPage(); await p.setViewport({width:1500,height:950,deviceScaleFactor:2});
const errs=[]; p.on("pageerror",e=>errs.push(e.message));
try{await p.goto("http://localhost:5173/",{waitUntil:"networkidle0",timeout:15000});}catch{}
await new Promise(r=>setTimeout(r,1000));
// Browser-Kontextmenü unterdrückt?
const ctxPrevented=await p.evaluate(()=>{const e=new MouseEvent("contextmenu",{bubbles:true,cancelable:true});return !document.body.dispatchEvent(e);});
// Marquee: Links-Drag über den Plan
const svg=await p.$(".plan-svg"); const box=await svg.boundingBox();
await p.mouse.move(box.x+box.width*0.25,box.y+box.height*0.25);
await p.mouse.down(); await p.mouse.move(box.x+box.width*0.85,box.y+box.height*0.85,{steps:8}); await p.mouse.up();
await new Promise(r=>setTimeout(r,300));
const sel=await p.evaluate(()=>{const m=document.body.innerText.match(/Auswahl:?\s*(\d+)?\s*W\w*/i);return m?m[0]:"(keine)";});
await p.screenshot({path:"scripts/verify-de.png"});
// Englisch
const langOk=await p.evaluate(()=>{if(window.__i18n){window.__i18n.setLanguage("en");return true;}return false;});
await new Promise(r=>setTimeout(r,400));
await p.screenshot({path:"scripts/verify-en.png"});
console.log("ctxmenu unterdrückt:",ctxPrevented,"| Marquee-Auswahl:",sel,"| lang-switch:",langOk,"| errs:",errs.slice(0,2).join(" | ")||"keine");
await b.close();
+88
View File
@@ -0,0 +1,88 @@
// Node-seitige Geometrie-Prüfung (kein Browser): generiert eine minimale,
// gültige Test-DXF (Höhenlinien auf Z=400/401/402), schickt sie durch parseDxf
// → generateTerrainFromContours und bestätigt ein plausibles TIN.
//
// Die TS-Quellen werden mit esbuild on-the-fly zu einem ESM-Bundle gebaut und
// importiert (kein tsx nötig).
import { build } from "esbuild";
import { writeFileSync, mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
const outdir = mkdtempSync(join(tmpdir(), "cad-terrain-"));
const outfile = join(outdir, "bundle.mjs");
await build({
entryPoints: [join(process.cwd(), "scripts/_terrain-entry.ts")],
bundle: true,
format: "esm",
platform: "node",
outfile,
logLevel: "warning",
});
const mod = await import(pathToFileURL(outfile).href);
const { parseDxf, generateTerrainFromContours } = mod;
// ── Minimal gültige Test-DXF: drei LWPOLYLINE-Höhenlinien (Z via Elevation 38) ──
// Jede Kontur ist ein leicht versetztes Dreieck/Quadrat auf einer eigenen Höhe.
function ring(z, pts, layer) {
// LWPOLYLINE mit Elevation-Gruppe (38) und 90=Vertex-Zahl, 70=closed(1).
let s = `0\nLWPOLYLINE\n8\n${layer}\n38\n${z}\n90\n${pts.length}\n70\n1\n`;
for (const [x, y] of pts) s += `10\n${x}\n20\n${y}\n`;
return s;
}
const dxf =
`0\nSECTION\n2\nENTITIES\n` +
ring(400, [[0, 0], [10, 0], [10, 10], [0, 10]], "contours") +
ring(401, [[2, 2], [8, 2], [8, 8], [2, 8]], "contours") +
ring(402, [[4, 4], [6, 4], [6, 6], [4, 6]], "contours") +
`0\nENDSEC\n0\nEOF\n`;
const parsed = parseDxf(dxf);
const contourSets = parsed.contours;
const allContours = contourSets.flatMap((cs) => cs.contours);
console.log("=== DXF-Parse ===");
console.log("ContourSets:", contourSets.length, "Konturen:", allContours.length);
console.log("Z-Werte:", allContours.map((c) => c.z).join(", "));
console.log("Meshes (importiert):", parsed.meshes.length);
const terrain = generateTerrainFromContours(allContours);
const vertCount = terrain.positions.length / 3;
const triCount = terrain.indices.length / 3;
let minZ = Infinity, maxZ = -Infinity, nan = 0;
for (let i = 0; i < terrain.positions.length; i++) {
const v = terrain.positions[i];
if (!Number.isFinite(v)) nan++;
}
for (let i = 2; i < terrain.positions.length; i += 3) {
minZ = Math.min(minZ, terrain.positions[i]);
maxZ = Math.max(maxZ, terrain.positions[i]);
}
console.log("\n=== TIN ===");
console.log("Vertices:", vertCount, "Dreiecke:", triCount);
console.log("Z-Range:", minZ, "..", maxZ);
console.log("NaN/Infinity-Werte:", nan);
const ok =
contourSets.length === 1 &&
allContours.length === 3 &&
vertCount > 0 &&
triCount > 0 &&
nan === 0 &&
Math.abs(minZ - 400) < 1e-6 &&
Math.abs(maxZ - 402) < 1e-6;
// Zusatz: leere Eingabe → leeres Mesh (kein Crash).
const empty = generateTerrainFromContours([]);
const emptyOk = empty.positions.length === 0 && empty.indices.length === 0;
console.log("\nLeere-Eingabe → leeres Mesh:", emptyOk ? "OK" : "FEHLER");
console.log("\n" + (ok && emptyOk ? "✅ TIN plausibel" : "❌ TIN NICHT plausibel"));
process.exit(ok && emptyOk ? 0 : 1);
+51
View File
@@ -0,0 +1,51 @@
import puppeteer from "puppeteer";
const URL = process.env.PROBE_URL || "http://localhost:5173/";
const browser = await puppeteer.launch({
headless: "new",
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
});
const page = await browser.newPage();
await page.setViewport({ width: 1200, height: 800, deviceScaleFactor: 2 });
const logs = [];
page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`));
page.on("console", (m) => { if (m.text().includes("DBG")) logs.push(m.text()); });
try { await page.goto(URL, { waitUntil: "networkidle0", timeout: 20000 }); } catch (e) { logs.push(`[GOTO] ${e.message}`); }
await new Promise((r) => setTimeout(r, 600));
const clickTool = (label) => page.evaluate((l) => [...document.querySelectorAll("button")].find((x) => x.textContent.trim() === l)?.click(), label);
const click = async (x, y) => { await page.mouse.move(x, y); await page.mouse.click(x, y); await new Promise((r) => setTimeout(r, 60)); };
const box = await page.evaluate(() => { const r = document.querySelector(".plan-svg").getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; });
const cx = box.x + box.w / 2, cy = box.y + box.h / 2;
// Ein kleines Linien-Raster in den Raum zeichnen (3 horizontale + 3 vertikale).
await clickTool("Linie");
await new Promise((r) => setTimeout(r, 100));
const L = box.x + box.w * 0.34, Rr = box.x + box.w * 0.66;
const T = box.y + box.h * 0.42, Bb = box.y + box.h * 0.72;
for (let i = 0; i <= 2; i++) {
const y = T + ((Bb - T) * i) / 2;
await click(L, y); await click(Rr, y);
}
for (let i = 0; i <= 2; i++) {
const x = L + ((Rr - L) * i) / 2;
await click(x, T); await click(x, Bb);
}
await clickTool("Auswahl");
await new Promise((r) => setTimeout(r, 100));
const draw2dPlan = await page.evaluate(() => document.querySelectorAll(".plan-svg line.draw2d").length);
// Obergeschoss ausblenden (Augen-Icon), damit der EG-Boden mit dem Raster oben
// offen sichtbar ist.
await page.evaluate(() => {
let row = [...document.querySelectorAll("*")].find((e) => e.children.length === 0 && e.textContent.trim() === "Obergeschoss");
while (row && !row.querySelector?.(".eye")) row = row.parentElement;
row?.querySelector(".eye")?.click();
});
await new Promise((r) => setTimeout(r, 200));
// In die Perspektive wechseln und rendern lassen.
await clickTool("Perspektive");
await new Promise((r) => setTimeout(r, 1200));
await page.screenshot({ path: "scripts/probe-3d2d.png" });
console.log("draw2dPlan lines:", draw2dPlan);
console.log(logs.length ? logs.join("\n") : "(no errors)");
await browser.close();
+30
View File
@@ -0,0 +1,30 @@
import puppeteer from "puppeteer";
const URL = process.env.PROBE_URL || "http://localhost:5173/";
const b = await puppeteer.launch({ headless: "new", args: ["--no-sandbox","--use-gl=swiftshader","--enable-unsafe-swiftshader"] });
const page = await b.newPage();
await page.setViewport({ width: 1200, height: 800, deviceScaleFactor: 2 });
const logs = []; page.on("pageerror", e => logs.push(e.message));
page.on("dialog", async d => { await d.accept("3"); });
await page.goto(URL, { waitUntil: "networkidle0", timeout: 20000 }).catch(()=>{});
await new Promise(r=>setTimeout(r,600));
const clickTool = l => page.evaluate(x => [...document.querySelectorAll("button")].find(b=>b.textContent.trim()===x)?.click(), l);
const click = async (x,y)=>{ await page.mouse.move(x,y); await page.mouse.click(x,y); await new Promise(r=>setTimeout(r,70)); };
const nLines = () => page.evaluate(()=>document.querySelectorAll(".plan-svg line.draw2d").length);
const box = await page.evaluate(()=>{const r=document.querySelector(".plan-svg").getBoundingClientRect();return{x:r.x,y:r.y,w:r.width,h:r.height};});
const cx=box.x+box.w/2, cy=box.y+box.h/2;
await clickTool("Linie"); await new Promise(r=>setTimeout(r,80));
const A=[cx-120,cy-140], B=[cx+20,cy-140];
await click(...A); await click(...B);
await clickTool("Auswahl"); await new Promise(r=>setTimeout(r,60));
await click((A[0]+B[0])/2, A[1]); // select midpoint
const sel = await page.evaluate(()=>document.querySelectorAll(".plan-svg .plan-grip").length);
await page.keyboard.press("m"); await new Promise(r=>setTimeout(r,80));
await page.keyboard.press("o"); await new Promise(r=>setTimeout(r,200));
const mode = await page.evaluate(()=>document.querySelector(".transform-bar .tf-mode.active .tf-mode-label")?.textContent);
await click(A[0], A[1]); // base
await click(A[0], A[1]+70); // dest (down by ~70px each step)
await new Promise(r=>setTimeout(r,150));
const n = await nLines();
await page.screenshot({ path:"scripts/probe-array.png" });
console.log("selectedGrips:", sel, "activeMode:", mode, "lines(expect 4):", n, logs.length?logs.join("|"):"(no err)");
await b.close();
+93
View File
@@ -0,0 +1,93 @@
// Probe für das Rhino-artige Befehlssystem (Tier 0 + Line).
// Treibt: Tab → „line"⏎ → „0,0"⏎ → „r3,0"⏎ und screenshotet das Ergebnis.
// Erwartung: eine deutlich DUNKLE 3-m-Linie im Plan + Command-Line zeigt Prompts.
//
// Headless Puppeteer (swiftshader für WebGL). Zweiter Lauf mit „3<45" prüft die
// polare Eingabe. Vorlage: scripts/probe-tools.mjs.
import puppeteer from "puppeteer";
const URL = process.env.PROBE_URL || "http://localhost:5173/";
const browser = await puppeteer.launch({
headless: "new",
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
});
const page = await browser.newPage();
await page.setViewport({ width: 1200, height: 800, deviceScaleFactor: 2 });
const logs = [];
page.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`));
page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`));
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
try {
await page.goto(URL, { waitUntil: "networkidle0", timeout: 20000 });
} catch (e) {
logs.push(`[GOTO-ERROR] ${e.message}`);
}
await sleep(600);
// Zählt 2D-Zeichenlinien + liefert die computed stroke der ersten (Tinte-Check).
async function inkInfo() {
return page.evaluate(() => {
const lines = [...document.querySelectorAll(".plan-svg line.draw2d")];
const first = lines[0];
const stroke = first ? getComputedStyle(first).stroke : null;
return { count: lines.length, stroke };
});
}
// Tippt einen Befehl/Eine Zeile in die fokussierte Command-Line + Enter.
async function typeLine(text) {
await page.type(".cmdline-input", text, { delay: 12 });
await page.keyboard.press("Enter");
await sleep(150);
}
// 1) Tab → Command-Line fokussieren (globaler Tab-Handler in App).
// Erst in den Body klicken, damit Tab nicht von einem anderen Feld geschluckt wird.
await page.mouse.click(600, 400);
await sleep(80);
await page.keyboard.press("Tab");
await sleep(120);
const focused1 = await page.evaluate(
() => document.activeElement?.className || "",
);
// 2) Befehl + zwei getippte Koordinaten. Startpunkt 1,7 liegt ÜBER dem Raum
// (freies Papier), damit die 3-m-Linie eindeutig sichtbar ist (nicht von einer
// Wandkante überdeckt). Verlangt: 1,7 ⏎ r3,0 ⏎ → 3-m-Linie nach rechts.
await typeLine("line");
const promptAfterLine = await page.evaluate(
() => document.querySelector(".cmdline-prompt")?.textContent || "",
);
await typeLine("1,2");
const promptAfterStart = await page.evaluate(
() => document.querySelector(".cmdline-prompt")?.textContent || "",
);
await typeLine("r3,0");
await sleep(200);
const ink1 = await inkInfo();
await page.screenshot({ path: "scripts/probe-command-line.png" });
// 3) Zweiter Lauf: polare Eingabe 3<45 (neue Linie ab Ursprung).
await page.mouse.click(600, 400);
await sleep(80);
await page.keyboard.press("Tab");
await sleep(120);
await typeLine("line");
await typeLine("1,1");
await typeLine("3<45");
await sleep(200);
const ink2 = await inkInfo();
await page.screenshot({ path: "scripts/probe-command-line-polar.png" });
console.log("focused after Tab:", focused1);
console.log("prompt after 'line':", JSON.stringify(promptAfterLine));
console.log("prompt after '0,0':", JSON.stringify(promptAfterStart));
console.log("ink (after 0,0 + r3,0):", JSON.stringify(ink1));
console.log("ink (after polar 3<45):", JSON.stringify(ink2));
console.log("=== Logs ===");
console.log(logs.length ? logs.join("\n") : "(keine)");
await browser.close();
+11
View File
@@ -0,0 +1,11 @@
import puppeteer from "puppeteer";
const b=await puppeteer.launch({headless:"new",args:["--no-sandbox","--use-gl=swiftshader","--enable-unsafe-swiftshader"]});
const p=await b.newPage(); await p.setViewport({width:1280,height:820,deviceScaleFactor:1.5});
await p.goto("http://localhost:5187/",{waitUntil:"networkidle0",timeout:20000}).catch(()=>{});
await new Promise(r=>setTimeout(r,1200));
const opened=await p.evaluate(()=>{const l=[...document.querySelectorAll(".nav-label")].find(e=>/Erdgeschoss|Obergeschoss|Detail|Ansicht|Schnitt/.test(e.textContent||""));if(!l)return "no-row";const row=l.parentElement;const r=row.getBoundingClientRect();const ev=new MouseEvent("contextmenu",{bubbles:true,cancelable:true,clientX:r.x+20,clientY:r.y+r.height/2,button:2});row.dispatchEvent(ev);return "dispatched";});
await new Promise(r=>setTimeout(r,400));
const info=await p.evaluate(()=>{const m=document.querySelector(".ctx-menu");if(!m)return{menu:false};const icons=[...m.querySelectorAll(".ctx-item-icon .material-symbols-outlined")];const cs=icons[0]?getComputedStyle(icons[0]):null;return{menu:true,nItems:m.querySelectorAll(".ctx-item").length,nIcons:icons.length,iconPx:cs?.fontSize,glyphFont:/Material Symbols/.test(cs?.fontFamily||""),ligature:icons[0]?.textContent,hoverBg:getComputedStyle(m.querySelector(".ctx-item")).getPropertyValue("--ctx-hover")};});
await p.screenshot({path:"scripts/probe-ctx.png"});
console.log("trigger:",opened,JSON.stringify(info));
await b.close();
+11
View File
@@ -0,0 +1,11 @@
import puppeteer from "puppeteer";
const b=await puppeteer.launch({headless:"new",args:["--no-sandbox","--use-gl=swiftshader","--enable-unsafe-swiftshader"]});
const p=await b.newPage(); await p.setViewport({width:1400,height:880,deviceScaleFactor:1.5});
await p.goto("http://localhost:5187/",{waitUntil:"networkidle0",timeout:20000}).catch(()=>{});
await new Promise(r=>setTimeout(r,1000));
// ersten Dropdown-Trigger (Detailgrad) klicken
const tr=await p.$(".tb-dd-trigger"); if(tr){const r=await tr.boundingBox();await p.mouse.click(r.x+r.width/2,r.y+r.height/2);await new Promise(t=>setTimeout(t,350));}
const info=await p.evaluate(()=>{const menu=document.querySelector(".tb-dd-menu, .ctx-menu");if(!menu)return{open:false};const item=menu.querySelector(".ctx-item, .tb-dd-item");const cs=item?getComputedStyle(item):null;const ic=menu.querySelector(".ctx-item-icon, .material-symbols-outlined");return{open:true,itemFont:cs?.fontSize,itemPad:cs?.padding,menuFont:getComputedStyle(menu).fontSize,radius:getComputedStyle(menu).borderRadius,iconFont:ic?getComputedStyle(ic).fontSize:"(none)"};});
await p.screenshot({path:"scripts/probe-dd-size.png"});
console.log(JSON.stringify(info));
await b.close();
+67
View File
@@ -0,0 +1,67 @@
// Probe: belegt den kontextabhängigen „Darstellung"-Dropdown.
// 1) Grundriss + „Schwarz-Weiss" (mono) → scripts/probe-plan-mono.png
// 2) Grundriss + „Farbig" (Referenz) → scripts/probe-plan-color.png
// 3) Perspektive + „Weiss" (Clay) → scripts/probe-3d-white.png
// 4) Perspektive + „Schattiert" (Ref.) → scripts/probe-3d-shaded.png
import puppeteer from "puppeteer";
const URL = process.env.PROBE_URL || "http://localhost:5173/";
const browser = await puppeteer.launch({
headless: "new",
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
});
const page = await browser.newPage();
await page.setViewport({ width: 1400, height: 900, deviceScaleFactor: 1 });
const logs = [];
page.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`));
page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`));
await page.goto(URL, { waitUntil: "networkidle0", timeout: 20000 });
// Den Ansichts-Umschalter-Button per sichtbarem Text klicken.
const clickByText = async (sel, text) => {
const handle = await page.evaluateHandle(
(sel, text) =>
[...document.querySelectorAll(sel)].find(
(el) => el.textContent.trim() === text,
),
sel,
text,
);
const el = handle.asElement();
if (!el) throw new Error(`Button "${text}" (${sel}) nicht gefunden`);
await el.click();
};
// Den „Darstellung"-Dropdown (per title) auf einen Wert setzen.
const setStyle = async (value) => {
const sel = await page.$('select.layout-menu[title*="Darstellungsart der"]');
if (!sel) throw new Error("Darstellung-Dropdown nicht gefunden");
await sel.select(value);
await new Promise((r) => setTimeout(r, 600));
};
const shot = (name) => page.screenshot({ path: `scripts/${name}` });
// 1) Grundriss aktiv (Default), Schwarz-Weiss.
await clickByText("button.view-btn", "Grundriss");
await new Promise((r) => setTimeout(r, 400));
await setStyle("color");
await shot("probe-plan-color.png");
await setStyle("mono");
await shot("probe-plan-mono.png");
// 2) Perspektive, Weiss vs. Schattiert.
await clickByText("button.view-btn", "Perspektive");
await new Promise((r) => setTimeout(r, 800));
await setStyle("shaded");
await shot("probe-3d-shaded.png");
await setStyle("white");
await new Promise((r) => setTimeout(r, 600));
await shot("probe-3d-white.png");
console.log("Screenshots geschrieben: probe-plan-color/mono, probe-3d-shaded/white");
console.log("\n=== Logs ===");
console.log(logs.length ? logs.join("\n") : "(keine)");
await browser.close();
+15
View File
@@ -0,0 +1,15 @@
import puppeteer from "puppeteer";
const b = await puppeteer.launch({ headless:"new", args:["--no-sandbox","--use-gl=swiftshader","--enable-unsafe-swiftshader"] });
const p = await b.newPage(); await p.setViewport({width:1400,height:900,deviceScaleFactor:2});
const logs=[]; p.on("pageerror",e=>logs.push(e.message));
await p.goto("http://localhost:5174/",{waitUntil:"networkidle0",timeout:20000}).catch(()=>{});
await new Promise(r=>setTimeout(r,800));
// Default = Display (Haarlinien). Screenshot.
await p.screenshot({ path:"scripts/probe-display.png" });
// Auf Print umschalten.
await p.evaluate(()=>[...document.querySelectorAll("button")].find(x=>x.textContent.trim()==="Print")?.click());
await new Promise(r=>setTimeout(r,400));
await p.screenshot({ path:"scripts/probe-print.png" });
const dispActive = await p.evaluate(()=>!![...document.querySelectorAll(".view-btn.active")].find(b=>b.textContent.trim()==="Print"));
console.log("printActive:", dispActive, logs.length?logs.join("|"):"(no err)");
await b.close();
+48
View File
@@ -0,0 +1,48 @@
import puppeteer from "puppeteer";
// Wie probe.mjs, aber klickt zusätzlich die Zeichnungsebene "Grundriss OG"
// im Navigator und schießt scripts/probe-doc.png.
const URL = process.env.PROBE_URL || "http://localhost:5173/";
const browser = await puppeteer.launch({
headless: "new",
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
});
const page = await browser.newPage();
await page.setViewport({ width: 1200, height: 800, deviceScaleFactor: 2 });
const logs = [];
page.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`));
page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`));
try {
await page.goto(URL, { waitUntil: "networkidle0", timeout: 20000 });
} catch (e) {
logs.push(`[GOTO-ERROR] ${e.message}`);
}
// Navigator-Zeile "Grundriss OG" anklicken.
const clicked = await page.evaluate(() => {
const rows = [...document.querySelectorAll(".nav-row")];
const target = rows.find((r) => r.textContent.includes("Grundriss OG"));
if (target) {
target.click();
return true;
}
return false;
});
logs.push(clicked ? "[INFO] 'Grundriss OG' geklickt" : "[WARN] 'Grundriss OG' nicht gefunden");
await new Promise((r) => setTimeout(r, 800));
await page.screenshot({ path: "scripts/probe-doc.png" });
const info = await page.evaluate(() => {
const title = document.querySelector(".content-single .pane-title")?.textContent ?? "none";
const caption = document.querySelector(".content-single .plan-legend")?.textContent ?? "none";
const polygons = document.querySelectorAll(".content-single .plan-svg polygon").length;
return { title, caption, polygons };
});
console.log("=== Drawing-Layer-Ansicht ===", JSON.stringify(info));
console.log("\n=== Logs ===");
console.log(logs.length ? logs.join("\n") : "(keine)");
await browser.close();
+134
View File
@@ -0,0 +1,134 @@
// Probe für das DOSSIER-Dokumentmodell (Augen-Navigator + Mehrgeschoss-3D).
// Erfasst fünf Zustände in separate PNGs (deviceScaleFactor 2):
// (a) Standard: EG Grundriss mit Augen-Navigator (EG aktiv, andere gedimmt).
// (b) Perspektive mit EG + OG sichtbar → zwei Räume vertikal gestapelt.
// (c) OG-Auge ausgeschaltet → nur EG bleibt im 3D.
// (d) EG Geschosshöhe auf 3.5 → größerer Abstand zu OG; "+ Ebene" anklicken
// → neue Kategorie erscheint im Navigator.
// (e) Zeichnungsebene "Detail 1" → leerer Zeichnungs-Platzhalter.
//
// Aufruf: node scripts/probe-dossier.mjs
import puppeteer from "puppeteer";
const URL = process.env.PROBE_URL || "http://localhost:5173/";
const OUT = "scripts";
const browser = await puppeteer.launch({
headless: "new",
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
});
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 820, deviceScaleFactor: 2 });
const logs = [];
page.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`));
page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`));
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Klick auf das Element, dessen Textinhalt `text` enthält (innerhalb selector).
async function clickByText(selector, text) {
const handle = await page.evaluateHandle(
(sel, t) =>
[...document.querySelectorAll(sel)].find((el) =>
el.textContent.includes(t),
),
selector,
text,
);
const el = handle.asElement();
if (!el) throw new Error(`Element nicht gefunden: ${selector} ~ "${text}"`);
await el.click();
}
// Klick auf das Augen-Symbol einer Zeichnungsebene mit gegebenem Namen.
async function toggleLevelEyeByName(name) {
const ok = await page.evaluate((n) => {
const row = [...document.querySelectorAll(".nav-row")].find((r) =>
r.querySelector(".nav-label")?.textContent.trim().includes(n),
);
const eye = row?.querySelector(".eye");
if (!eye) return false;
eye.click();
return true;
}, name);
if (!ok) throw new Error(`Augen-Schalter nicht gefunden für Ebene: ${name}`);
}
// Diagnose: aktueller Navigator-Zustand (für Querprüfung der Screenshots).
const navState = () =>
page.evaluate(() => {
const levels = [...document.querySelectorAll(".nav-row")].map((r) => ({
name: r.querySelector(".nav-label")?.textContent.trim(),
active: r.classList.contains("active"),
dimmed: r.classList.contains("hidden"),
elev: r.querySelector(".nav-elev")?.textContent.trim() ?? null,
}));
const cats = [...document.querySelectorAll(".cat-row")].map((r) =>
r.querySelector(".cat-name")?.textContent.trim(),
);
const canvas = document.querySelector(".viewport canvas");
return {
levels,
catCount: cats.length,
catNames: cats,
hasCanvas: !!canvas,
egHeight: document.querySelector(".props-strip input")?.value ?? null,
};
});
try {
await page.goto(URL, { waitUntil: "networkidle0", timeout: 20000 });
} catch (e) {
logs.push(`[GOTO-ERROR] ${e.message}`);
}
await sleep(700);
// ── (a) Standard: EG Grundriss mit Augen-Navigator ──────────────────────────
await page.screenshot({ path: `${OUT}/probe-a-eg-grundriss.png` });
logs.push("(a) STATE " + JSON.stringify(await navState()));
// ── (b) Perspektive mit EG + OG sichtbar ───────────────────────────────────
await clickByText(".view-btn", "Perspektive");
await sleep(1400);
await page.screenshot({ path: `${OUT}/probe-b-eg-og-perspektive.png` });
logs.push("(b) STATE " + JSON.stringify(await navState()));
// ── (c) OG-Auge ausschalten → nur EG bleibt im 3D ──────────────────────────
await toggleLevelEyeByName("Obergeschoss");
await sleep(1400);
await page.screenshot({ path: `${OUT}/probe-c-only-eg-3d.png` });
logs.push("(c) STATE " + JSON.stringify(await navState()));
// OG wieder einblenden, damit (d) den Stapel-Abstand zeigt.
await toggleLevelEyeByName("Obergeschoss");
await sleep(400);
// ── (d) EG Geschosshöhe auf 3.5 → größerer Abstand zu OG ───────────────────
// EG ist die Standardauswahl; die props-strip zeigt das aktive Geschoss.
await page.evaluate(() => {
const input = document.querySelector(".props-strip input");
if (!input) throw new Error("Geschosshöhe-Feld nicht gefunden");
const setter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value",
).set;
setter.call(input, "3.5");
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("change", { bubbles: true }));
});
await sleep(1400);
// "+ Ebene" anklicken → neue Kategorie erscheint im Navigator.
await clickByText(".nav-add", "+ Ebene");
await sleep(500);
await page.screenshot({ path: `${OUT}/probe-d-height-and-addlayer.png` });
logs.push("(d) STATE " + JSON.stringify(await navState()));
// ── (e) Zeichnungsebene "Detail 1" → leerer Zeichnungs-Platzhalter ─────────
await clickByText(".nav-label", "Detail 1");
await sleep(500);
await page.screenshot({ path: `${OUT}/probe-e-detail-drawing.png` });
logs.push("(e) STATE " + JSON.stringify(await navState()));
console.log(logs.join("\n"));
await browser.close();
+36
View File
@@ -0,0 +1,36 @@
import puppeteer from "puppeteer";
const URL=process.env.PROBE_URL||"http://localhost:5173/";
const b=await puppeteer.launch({headless:"new",args:["--no-sandbox","--use-gl=swiftshader","--enable-unsafe-swiftshader"]});
const p=await b.newPage(); await p.setViewport({width:1280,height:820,deviceScaleFactor:1});
const errs=[]; p.on("pageerror",e=>errs.push(e.message));
await p.goto(URL,{waitUntil:"networkidle0",timeout:20000}).catch(()=>{});
await new Promise(r=>setTimeout(r,700));
const lineCount=()=>p.$$eval(".plan-svg line",ls=>ls.length).catch(()=>-1);
const focusCmd=async()=>{ await p.mouse.click(640,760); await p.keyboard.press("Tab"); await new Promise(r=>setTimeout(r,80)); };
const typeLine=async t=>{ await p.type(".cmdline-input",t,{delay:8}); await p.keyboard.press("Enter"); await new Promise(r=>setTimeout(r,90)); };
const prompt=()=>p.evaluate(()=>document.querySelector(".cmdline-prompt")?.textContent||"");
const l0=await lineCount();
// Polyline (offener Zug, Enter beendet)
await focusCmd(); await typeLine("polyline");
const pp=await prompt();
await typeLine("1,1"); await typeLine("r2,0"); await typeLine("r0,1.5");
await p.keyboard.press("Enter"); await new Promise(r=>setTimeout(r,120)); // beenden
const l1=await lineCount();
// Rectangle
await focusCmd(); await typeLine("rect");
const rp=await prompt();
await typeLine("0.5,3"); await typeLine("r3,1.5");
const l2=await lineCount();
// Circle (Radius getippt)
await focusCmd(); await typeLine("circle");
const cp=await prompt();
await typeLine("4,4"); await typeLine("0.8");
const l3=await lineCount();
await p.screenshot({path:"scripts/probe-draw-commands.png"});
console.log(JSON.stringify({
polyPrompt:pp, rectPrompt:rp, circPrompt:cp,
lines_start:l0, after_polyline:l1, after_rect:l2, after_circle:l3,
polyAdded:l1-l0, rectAdded:l2-l1, circleAdded:l3-l2, errs
},null,2));
await b.close();
+50
View File
@@ -0,0 +1,50 @@
import puppeteer from "puppeteer";
const PORT = process.env.PORT || "5173";
const b = await puppeteer.launch({
headless: "new",
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
});
const p = await b.newPage();
await p.setViewport({ width: 1400, height: 420, deviceScaleFactor: 2 });
await p.goto(`http://localhost:${PORT}/`, { waitUntil: "networkidle0", timeout: 20000 }).catch(() => {});
await new Promise((r) => setTimeout(r, 800));
// Detailgrad-Dropdown finden (Trigger mit aria-label = Detailgrad-Hint) und öffnen.
// Wir klicken den Trigger in der Detailgrad-Gruppe.
const opened = await p.evaluate(() => {
const groups = [...document.querySelectorAll(".tb-group")];
const detailGroup = groups.find((g) =>
(g.getAttribute("aria-label") || "").includes("Detailgrad"),
);
const trig = detailGroup?.querySelector(".tb-dd-trigger");
if (!trig) return { ok: false };
trig.click();
return { ok: true, label: trig.textContent };
});
await new Promise((r) => setTimeout(r, 250));
const info = await p.evaluate(() => {
const menu = document.querySelector(".tb-dd-menu");
if (!menu) return { menu: false };
const cs = getComputedStyle(menu);
const items = [...menu.querySelectorAll(".tb-dd-item")].map((it) => ({
label: it.querySelector(".ctx-item-label")?.textContent,
active: it.getAttribute("aria-selected") === "true",
isActive: it.classList.contains("is-active"),
}));
return {
menu: true,
bg: cs.backgroundColor,
radius: cs.borderRadius,
items,
};
});
const stillOpen = await p.evaluate(() => !!document.querySelector(".tb-dd-menu"));
console.log(JSON.stringify({ opened, info, stillOpen }, null, 2));
// Mount-Animation (ctx-pop) für den Screenshot deaktivieren, damit das Popover
// im Headless-Capture sicher voll deckend gerendert ist.
await p.addStyleTag({ content: ".tb-dd-menu{animation:none !important;}" });
await new Promise((r) => setTimeout(r, 120));
await p.screenshot({ path: "scripts/probe-dropdown.png", clip: { x: 0, y: 0, width: 1400, height: 320 } });
await b.close();
+68
View File
@@ -0,0 +1,68 @@
// Browser-Laufzeittest: echter DWG-Import über den App-Import-Pfad.
// Lädt eine echte DWG über das versteckte File-Input (uploadFile), wartet bis
// LibreDWG-WASM geparst hat, und screenshotet den Import-Dialog mit der ECHTEN
// Zusammenfassung (Konturen/Layer) statt des ODA-Hinweises.
import puppeteer from "puppeteer";
const URL = process.env.URL || "http://localhost:5173/";
const SAMPLE =
"/tmp/claude-1000/-home-karim-cad/65983130-c639-428b-bd68-85eafeb48d98/scratchpad/sample.dwg";
const b = await puppeteer.launch({
headless: "new",
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
});
const p = await b.newPage();
await p.setViewport({ width: 1300, height: 900, deviceScaleFactor: 2 });
const logs = [];
p.on("pageerror", (e) => logs.push("ERR:" + e.message));
p.on("console", (m) => {
if (m.type() === "error") logs.push("CONSOLE:" + m.text());
});
await p.goto(URL, { waitUntil: "networkidle0", timeout: 20000 }).catch(() => {});
await new Promise((r) => setTimeout(r, 700));
// File-Input mit accept=".dxf,.dwg" finden und die echte DWG hochladen.
const input = await p.$('input[type=file][accept*=".dwg"]');
if (!input) {
console.log("FAIL: DWG-File-Input nicht gefunden.");
await b.close();
process.exit(1);
}
await input.uploadFile(SAMPLE);
// Auf Ladehinweis screenshoten (kann sehr kurz sein), dann auf Ergebnis warten.
await new Promise((r) => setTimeout(r, 150));
await p.screenshot({ path: "scripts/probe-dwg-loading.png" }).catch(() => {});
// Warten, bis der Dialog die echte Zusammenfassung zeigt (oder bis Timeout).
let summaryText = "";
for (let i = 0; i < 60; i++) {
await new Promise((r) => setTimeout(r, 250));
summaryText = await p.evaluate(() => {
const sum = document.querySelector(".imp-summary");
const hint = document.querySelector(".imp-dwg-hint");
return JSON.stringify({
summary: sum ? sum.textContent : null,
hint: hint ? hint.textContent : null,
});
});
const parsed = JSON.parse(summaryText);
if (parsed.summary) break; // echte Zusammenfassung erschienen
}
await p.screenshot({ path: "scripts/probe-dwg-import.png" });
const parsed = JSON.parse(summaryText);
console.log("Dialog-Zusammenfassung:", parsed.summary);
console.log("ODA-Hinweis (sollte null sein bei Erfolg):", parsed.hint);
console.log("Logs:", logs.length ? logs.join(" | ") : "(keine)");
if (!parsed.summary) {
console.log("FAIL: keine echte Zusammenfassung — DWG nicht geparst.");
await b.close();
process.exit(1);
}
console.log("OK: DWG über den Import-Pfad geparst, echte Zusammenfassung sichtbar.");
await b.close();
+95
View File
@@ -0,0 +1,95 @@
// Node-Laufzeittest für den echten DWG-Import-Pfad (LibreDWG-WASM).
//
// Bündelt src/io/dwgParser.ts (samt Model-Typen) per esbuild zu einem ESM-Modul
// und ruft parseDwg() mit echten DWG-Beispieldateien auf. Loggt die erkannten
// Konturen/Meshes — spiegelt damit exakt das, was der Import-Dialog anzeigen
// würde (dieselbe parseDwg-Funktion, die auch der Browser-Import nutzt).
//
// Aufruf: node scripts/probe-dwg.mjs [pfad/zur.dwg ...]
// Ohne Argumente werden einige bekannte System-Beispiele probiert.
import { build } from "esbuild";
import { readFile, writeFile, unlink, access } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = resolve(__dirname, "..");
// parseDwg + Abhängigkeiten zu einem temporären ESM-Bundle backen. WASM-Lib
// bleibt extern (wird zur Laufzeit aus node_modules geladen → echtes WASM).
const outFile = resolve(root, "scripts/_dwgparser.bundle.mjs");
await build({
entryPoints: [resolve(root, "src/io/dwgParser.ts")],
bundle: true,
format: "esm",
platform: "node",
outfile: outFile,
external: ["@mlightcad/libredwg-web"],
logLevel: "warning",
});
const { parseDwg } = await import(outFile + "?t=" + Date.now());
const candidates =
process.argv.slice(2).length > 0
? process.argv.slice(2)
: [
"/home/karim/Downloads/02-15-cad-blocks-net-bushes-elevation.dwg",
"/home/karim/ssd/Nextcloud2 (Copy)/00.2 Situation Fluhmatt Bodengeschosse.dwg",
"/tmp/probe.dwg",
];
let anyOk = false;
for (const path of candidates) {
try {
await access(path);
} catch {
console.log(`SKIP (nicht gefunden): ${path}`);
continue;
}
try {
const buf = await readFile(path);
const ab = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
const t0 = Date.now();
const res = await parseDwg(ab);
const dt = Date.now() - t0;
const contourSets = res.contours;
const contourCount = contourSets.reduce(
(s, set) => s + set.contours.length,
0,
);
const layers = new Set();
for (const set of contourSets)
for (const c of set.contours) if (c.layer) layers.add(c.layer);
const triCount = res.meshes.reduce((s, m) => s + m.indices.length / 3, 0);
console.log(`\nOK ${path} (${dt} ms)`);
console.log(` Konturen-Sätze: ${contourSets.length}`);
console.log(` Konturen: ${contourCount}`);
console.log(` Meshes: ${res.meshes.length} (${triCount} Dreiecke)`);
console.log(
` Layer: ${
layers.size ? [...layers].slice(0, 12).join(", ") : "(keine)"
}`,
);
// Beispiel-Kontur (erste mit ≥2 Punkten) ausgeben.
const sample = contourSets
.flatMap((s) => s.contours)
.find((c) => c.pts.length >= 2);
if (sample) {
console.log(
` Beispiel-Kontur: ${sample.pts.length} Pkt, closed=${sample.closed}, z=${sample.z}, layer=${sample.layer ?? "-"}`,
);
}
anyOk = true;
} catch (err) {
console.log(`\nFEHLER ${path}:`, err?.message ?? err);
}
}
await unlink(outFile).catch(() => {});
if (!anyOk) {
console.log("\nKeine DWG erfolgreich gelesen.");
process.exit(1);
}
+46
View File
@@ -0,0 +1,46 @@
import puppeteer from "puppeteer";
const URL = process.env.PROBE_URL || "http://localhost:5173/";
const browser = await puppeteer.launch({
headless: "new",
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
});
const page = await browser.newPage();
await page.setViewport({ width: 1200, height: 800, deviceScaleFactor: 2 });
const logs = [];
page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`));
try { await page.goto(URL, { waitUntil: "networkidle0", timeout: 20000 }); } catch (e) { logs.push(`[GOTO] ${e.message}`); }
await new Promise((r) => setTimeout(r, 700));
const click = async (x, y) => { await page.mouse.move(x, y); await page.mouse.click(x, y); await new Promise((r) => setTimeout(r, 100)); };
const box = await page.evaluate(() => { const r = document.querySelector(".plan-svg").getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; });
// 1) Auswahl-Werkzeug ist Default: die OBERE Wand des Beispiel-Raums anklicken
// → Eckpunkt-Griffe (Quadrate) + 1 dreieckiger Kanten-Anfasser erscheinen.
const tx = box.x + box.w * 0.5;
const ty = box.y + box.h * 0.26; // mittig auf das obere Wandband
await click(tx, ty);
const c1 = await page.evaluate(() => ({
grips: document.querySelectorAll(".plan-svg .plan-grip").length,
edges: document.querySelectorAll(".plan-svg .plan-edge-grip").length,
}));
await page.screenshot({ path: "scripts/probe-edge-grips-selected.png" });
// 2) Den Kanten-Anfasser (sitzt etwas OBERHALB der Kante, da Außennormale nach
// oben zeigt) nach OBEN ziehen → die ganze Wand verschiebt sich senkrecht.
const handleX = tx;
const handleY = ty - 13; // EDGE_GRIP_OFFSET_PX nach außen (oben)
await page.mouse.move(handleX, handleY);
await page.mouse.down();
await page.mouse.move(handleX, handleY - 100, { steps: 10 });
await page.mouse.up();
await new Promise((r) => setTimeout(r, 200));
const c2 = await page.evaluate(() => ({
grips: document.querySelectorAll(".plan-svg .plan-grip").length,
edges: document.querySelectorAll(".plan-svg .plan-edge-grip").length,
}));
await page.screenshot({ path: "scripts/probe-edge-grips-dragged.png" });
console.log("after select:", JSON.stringify(c1));
console.log("after drag:", JSON.stringify(c2));
console.log(logs.length ? logs.join("\n") : "(no errors)");
await browser.close();
+43
View File
@@ -0,0 +1,43 @@
import puppeteer from "puppeteer";
const b=await puppeteer.launch({headless:"new",args:["--no-sandbox","--use-gl=swiftshader","--enable-unsafe-swiftshader"]});
const p=await b.newPage(); await p.setViewport({width:1280,height:820,deviceScaleFactor:1});
const errs=[]; p.on("pageerror",e=>errs.push(e.message));
await p.goto("http://localhost:5173/",{waitUntil:"networkidle0",timeout:20000}).catch(()=>{});
await new Promise(r=>setTimeout(r,700));
// Screen-bbox aller Wand-Polygone bestimmen, dann auf die obere Bandkante klicken.
const bb=await p.evaluate(()=>{
const polys=[...document.querySelectorAll(".plan-svg polygon")];
if(!polys.length) return null;
let minX=1e9,minY=1e9,maxX=-1e9,maxY=-1e9;
for(const pl of polys){ const r=pl.getBoundingClientRect(); minX=Math.min(minX,r.left);minY=Math.min(minY,r.top);maxX=Math.max(maxX,r.right);maxY=Math.max(maxY,r.bottom);}
return {minX,minY,maxX,maxY};
});
let sel=false;
if(bb){
const cx=(bb.minX+bb.maxX)/2;
// ein paar Punkte auf der oberen Bandkante probieren
for(const dy of [4,8,12,16]){
await p.mouse.click(cx, bb.minY+dy); await new Promise(r=>setTimeout(r,120));
const k=await p.evaluate(()=>document.querySelector(".objinfo-kind")?.textContent||"");
if(/Wand/.test(k)){ sel=true; break; }
}
}
const dims=()=>p.evaluate(()=>[...document.querySelectorAll(".objinfo-input")].map(i=>i.value));
const before=await dims();
const tri=await p.evaluate(()=>{
const el=document.querySelector(".plan-edge-grip"); if(!el) return null;
const pts=el.getAttribute("points").trim().split(/\s+/).map(s=>s.split(",").map(Number));
const svg=el.ownerSVGElement; const ctm=svg.getScreenCTM();
const cs=pts.map(([x,y])=>{const pt=svg.createSVGPoint();pt.x=x;pt.y=y;const sp=pt.matrixTransform(ctm);return [sp.x,sp.y];});
return {cx:cs.reduce((a,c)=>a+c[0],0)/3, cy:cs.reduce((a,c)=>a+c[1],0)/3, count:document.querySelectorAll(".plan-edge-grip").length};
});
let after=before, dragged=false;
if(tri){
await p.mouse.move(tri.cx,tri.cy); await p.mouse.down();
for(let i=1;i<=6;i++){ await p.mouse.move(tri.cx, tri.cy - i*8); await new Promise(r=>setTimeout(r,20)); }
await p.mouse.up(); await new Promise(r=>setTimeout(r,200));
after=await dims(); dragged=true;
}
await p.screenshot({path:"scripts/probe-edge2.png"});
console.log(JSON.stringify({wallSelected:sel, edgeGripCount:tri&&tri.count, dims_before:before, dims_after:after, errs}, null, 2));
await b.close();
+248
View File
@@ -0,0 +1,248 @@
// Probe für die Tier-1-Editierbefehle: Move / Copy / Offset.
//
// Treibt das Rhino-artige Befehlssystem headless und prüft die EXAKTE Geometrie
// aus dem gerenderten SVG (Drawing2D → `line.draw2d`, Screen→Modell via
// model = {x: x/90, y: -y/90}; PX_PER_M=90, Y-flip). Schritte:
// 1) line „0,0"→„r4,0" → eine waagerechte 4-m-Linie bei y=0
// 2) Linie anklicken (selektieren) → move „0,0"→„0,2" → Linie liegt bei y=2
// 3) copy „0,0"→„r2,0" → zwei Linien (Original + Kopie 2 m rechts)
// 4) eine Polylinie zeichnen + selektieren → offset Distanz 0.5 + Seite
// → zweite, parallel um 0.5 versetzte Kurve
//
// Vorlage: scripts/probe-command-line.mjs / probe-draw-commands.mjs.
import puppeteer from "puppeteer";
const URL = process.env.PROBE_URL || "http://localhost:5173/";
const browser = await puppeteer.launch({
headless: "new",
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
});
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 820, deviceScaleFactor: 1 });
const logs = [];
page.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`));
page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`));
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
try {
await page.goto(URL, { waitUntil: "networkidle0", timeout: 20000 });
} catch (e) {
logs.push(`[GOTO-ERROR] ${e.message}`);
}
await sleep(600);
const PX = 90; // PX_PER_M
// Liest alle Drawing2D-Liniensegmente als Modell-Koordinaten (Screen→Modell).
async function segs() {
return page.evaluate((PX) => {
const out = [];
for (const ln of document.querySelectorAll(".plan-svg line.draw2d")) {
// Bildschirm-CTM: SVG-User → Screen. Wir wollen Modell, daher rechnen wir
// über die Element-Endpunkte im SVG-User-Raum (x1/y1/x2/y2) zurück.
const x1 = parseFloat(ln.getAttribute("x1"));
const y1 = parseFloat(ln.getAttribute("y1"));
const x2 = parseFloat(ln.getAttribute("x2"));
const y2 = parseFloat(ln.getAttribute("y2"));
out.push({
a: { x: x1 / PX, y: -y1 / PX },
b: { x: x2 / PX, y: -y2 / PX },
});
}
return out;
}, PX);
}
// Klick-Zentrum eines .draw2d-Segments in Client-Pixeln (über BoundingClientRect),
// damit die Auswahl pan/zoom-unabhängig trifft. `idx` = welches Segment.
async function clickSegment(idx) {
const box = await page.evaluate((idx) => {
const lines = document.querySelectorAll(".plan-svg line.draw2d");
const ln = lines[idx];
if (!ln) return null;
const r = ln.getBoundingClientRect();
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
}, idx);
if (!box) {
logs.push(`[WARN] Segment ${idx} nicht gefunden (übersprungen)`);
return false;
}
await page.mouse.click(box.x, box.y);
await sleep(120);
return true;
}
// Fokussiert die Command-Line DIREKT (ohne Plan-Klick — ein Plan-Klick würde die
// Auswahl löschen, die Move/Copy/Offset gerade brauchen). Das Input ist immer im
// DOM; ein Fokus genügt, danach landet getippter Text in der Engine.
const focusCmd = async () => {
await page.focus(".cmdline-input");
await sleep(60);
};
const typeLine = async (t) => {
await page.type(".cmdline-input", t, { delay: 8 });
await page.keyboard.press("Enter");
await sleep(110);
};
const prompt = () =>
page.evaluate(() => document.querySelector(".cmdline-prompt")?.textContent || "");
// Hilfsfunktion: nahe Gleichheit.
const near = (a, b, eps = 0.02) => Math.abs(a - b) <= eps;
// ── 1) Linie zeichnen: 0,0 → r4,0 (waagerecht, y=0) ─────────────────────────
await focusCmd();
await typeLine("line");
await typeLine("0,0");
await typeLine("r4,0");
await sleep(150);
const afterLine = await segs();
// ── 2) Linie selektieren + Move 0,0 → 0,2 ───────────────────────────────────
await clickSegment(0); // die eine Linie wählen
const gripsAfterClick = await page.evaluate(
() => document.querySelectorAll(".plan-svg .plan-grip").length,
);
await focusCmd();
const gripsAfterFocus = await page.evaluate(
() => document.querySelectorAll(".plan-svg .plan-grip").length,
);
const moveSelInfo = gripsAfterClick;
logs.push(`[DBG] grips afterClick=${gripsAfterClick} afterFocus=${gripsAfterFocus}`);
await typeLine("move");
const movePrompt = await prompt();
await typeLine("0,0");
const moveTargetPrompt = await prompt();
await typeLine("0,2");
await sleep(150);
const afterMove = await segs();
await page.screenshot({ path: "scripts/probe-edit-move.png" });
// ── 3) Copy: 0,0 → r2,0 (Auswahl bleibt nach Move bestehen) ─────────────────
// Move hält die Auswahl (selectedDrawingId unverändert). Falls nicht, neu wählen.
await clickSegment(0);
await focusCmd();
await typeLine("copy");
const copyPrompt = await prompt();
await typeLine("0,0");
logs.push(`[DBG] copy after base: segs=${(await segs()).length}`);
await typeLine("r2,0");
logs.push(`[DBG] copy after r2,0: segs=${(await segs()).length}`);
await page.keyboard.press("Enter"); // Copy-Serie beenden
await sleep(150);
const afterCopy = await segs();
logs.push(`[DBG] copy after Enter: segs=${afterCopy.length}`);
await page.screenshot({ path: "scripts/probe-edit-copy.png" });
// ── 4) Offset einer Polylinie um 0.5 ────────────────────────────────────────
// Frische Polylinie: ein offener L-Zug (3,-3)→(7,-3)→(7,-1). Tief unten (y<0),
// damit sie sich nicht mit den obigen Linien überlagert.
await focusCmd();
await typeLine("polyline");
await typeLine("3,-3");
await typeLine("7,-3");
await typeLine("7,-1");
await page.keyboard.press("Enter"); // offenen Zug beenden
await sleep(150);
const afterPoly = await segs();
logs.push(`[DBG] afterPoly segs=${JSON.stringify(afterPoly.map((s) => ({ a: { x: +s.a.x.toFixed(2), y: +s.a.y.toFixed(2) }, b: { x: +s.b.x.toFixed(2), y: +s.b.y.toFixed(2) } })))}`);
// Eines der Polylinien-Segmente selektieren (das letzte gezeichnete Segment).
// Wir suchen ein Segment mit y≈-3 (die untere waagerechte Kante).
const polyIdx = await page.evaluate((PX) => {
const lines = [...document.querySelectorAll(".plan-svg line.draw2d")];
for (let i = 0; i < lines.length; i++) {
const ln = lines[i];
const y1 = -parseFloat(ln.getAttribute("y1")) / PX;
const y2 = -parseFloat(ln.getAttribute("y2")) / PX;
if (Math.abs(y1 + 3) < 0.05 && Math.abs(y2 + 3) < 0.05) return i;
}
return -1;
}, PX);
await clickSegment(polyIdx);
const offsetSelGrips = await page.evaluate(
() => document.querySelectorAll(".plan-svg .plan-grip").length,
);
await focusCmd();
await typeLine("offset");
const offsetPrompt = await prompt();
await typeLine("0.5"); // Distanz tippen (Seite via Vorzeichen/Default links)
await sleep(150);
const afterOffset = await segs();
await page.screenshot({ path: "scripts/probe-edit-offset.png" });
// ── Auswertung ───────────────────────────────────────────────────────────────
function summarize(list) {
return list.map((s) => ({
a: { x: +s.a.x.toFixed(3), y: +s.a.y.toFixed(3) },
b: { x: +s.b.x.toFixed(3), y: +s.b.y.toFixed(3) },
}));
}
// Move-Check: nach Move soll genau eine Linie existieren, beide Endpunkte y≈2.
const moveLine = afterMove.find(
(s) => near(s.a.y, 2) && near(s.b.y, 2) && (near(s.a.x, 0) || near(s.b.x, 0)),
);
const moveOk =
afterLine.length === afterMove.length && // gleiche Anzahl (kein neues Element)
!!moveLine &&
near(Math.abs(moveLine.b.x - moveLine.a.x), 4); // Länge bleibt 4 m
// Copy-Check: nach Copy zwei waagerechte Linien bei y≈2; eine bei x∈[0,4], eine
// um 2 m nach rechts (x∈[2,6]).
const horizAtY2 = afterCopy.filter((s) => near(s.a.y, 2) && near(s.b.y, 2));
const xsMin = horizAtY2.map((s) => Math.min(s.a.x, s.b.x)).sort((p, q) => p - q);
const copyOk =
afterCopy.length === afterMove.length + 1 && // genau ein Segment dazu
horizAtY2.length === 2 &&
near(xsMin[0], 0) &&
near(xsMin[1], 2);
// Offset-Check: das Offset fügt eine versetzte Polylinie hinzu. Wir prüfen, dass
// eine NEUE untere Kante bei y≈-3±0.5 existiert (parallel, Abstand 0.5). Die
// Seite (oben/unten) ergibt sich aus der Default-Klickseite; wir akzeptieren
// beide Vorzeichen, prüfen aber den Abstand = 0.5.
const addedByOffset = afterOffset.length - afterPoly.length;
// Untere Original-Kante: y=-3, von x=3..7. Versetzte Kante: y=-3±0.5.
const bottomEdges = afterOffset.filter(
(s) => near(s.a.y, s.b.y) && Math.abs(s.a.x - s.b.x) > 1.0, // waagerechtes Segment
);
const ysBottom = [...new Set(bottomEdges.map((s) => +s.a.y.toFixed(3)))].sort(
(p, q) => p - q,
);
// Erwartung: y=-3 (Original) + eine parallele Kante 0.5 entfernt.
const hasParallel = ysBottom.some(
(y) => near(Math.abs(y - -3), 0.5) && !near(y, -3),
);
const offsetOk = addedByOffset >= 2 && hasParallel;
console.log(
JSON.stringify(
{
prompts: { movePrompt, moveTargetPrompt, copyPrompt, offsetPrompt },
counts: {
afterLine: afterLine.length,
afterMove: afterMove.length,
afterCopy: afterCopy.length,
afterPoly: afterPoly.length,
afterOffset: afterOffset.length,
addedByOffset,
},
grips: { moveSelInfo, offsetSelGrips },
moveLine: moveLine ? summarize([moveLine])[0] : null,
horizAtY2: summarize(horizAtY2),
ysBottom,
checks: { moveOk, copyOk, offsetOk },
verdict: moveOk && copyOk && offsetOk ? "PASS" : "FAIL",
},
null,
2,
),
);
if (logs.length) {
console.log("=== Logs ===");
console.log(logs.join("\n"));
}
await browser.close();
+69
View File
@@ -0,0 +1,69 @@
import puppeteer from "puppeteer";
const URL = process.env.PROBE_URL || "http://localhost:5173/";
const browser = await puppeteer.launch({ headless: "new", args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"] });
const page = await browser.newPage();
await page.setViewport({ width: 1200, height: 800, deviceScaleFactor: 2 });
const logs = [];
page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`));
try { await page.goto(URL, { waitUntil: "networkidle0", timeout: 20000 }); } catch (e) { logs.push(`[GOTO] ${e.message}`); }
await new Promise((r) => setTimeout(r, 600));
const clickTool = (label) => page.evaluate((l) => [...document.querySelectorAll("button")].find((x) => x.textContent.trim() === l)?.click(), label);
const click = async (x, y, opts) => { await page.mouse.move(x, y); await page.mouse.click(x, y, opts); await new Promise((r) => setTimeout(r, 60)); };
const box = await page.evaluate(() => { const r = document.querySelector(".plan-svg").getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; });
const cx = box.x + box.w / 2, cy = box.y + box.h / 2;
// ── 1) Aktive Ebene auf "60 Plangrafik" (#c0a040) setzen, Linie zeichnen ──
await page.evaluate(() => {
const sel = [...document.querySelectorAll("select")].find((s) => [...s.options].some((o) => o.value === "60"));
if (sel) sel.id = "ebene-test";
});
await page.select("#ebene-test", "60");
await new Promise((r) => setTimeout(r, 100));
await clickTool("Linie");
await new Promise((r) => setTimeout(r, 80));
await click(cx - 150, cy - 120); await click(cx + 120, cy - 60);
await clickTool("Auswahl");
await new Promise((r) => setTimeout(r, 80));
const lineColor = await page.evaluate(() => document.querySelector(".plan-svg line.draw2d")?.getAttribute("stroke"));
// ── 2) Parallel verschieben: Linie selektieren, Körper greifen + ziehen ──
const mid = [(cx - 150 + cx + 120) / 2, (cy - 120 + cy - 60) / 2];
await click(mid[0], mid[1]); // selektieren
const before = await page.evaluate(() => { const l = document.querySelector(".plan-svg line.draw2d"); return { x1: +l.getAttribute("x1"), y1: +l.getAttribute("y1") }; });
await page.mouse.move(mid[0], mid[1]); await page.mouse.down();
await page.mouse.move(mid[0] + 60, mid[1] + 120, { steps: 6 }); await page.mouse.up();
await new Promise((r) => setTimeout(r, 100));
const after = await page.evaluate(() => { const l = document.querySelector(".plan-svg line.draw2d"); return { x1: +l.getAttribute("x1"), y1: +l.getAttribute("y1") }; });
const moved = Math.hypot(after.x1 - before.x1, after.y1 - before.y1) > 5;
// ── 3) Shift-Ortho: Endpunkt-Griff mit Shift ziehen → achsparallel ──
// Neue Linie zeichnen.
await clickTool("Linie");
await new Promise((r) => setTimeout(r, 60));
const a = [cx - 180, cy + 90], b = [cx - 40, cy + 140];
await click(...a); await click(...b);
await clickTool("Auswahl");
await new Promise((r) => setTimeout(r, 60));
await click((a[0] + b[0]) / 2, (a[1] + b[1]) / 2); // selektieren
// Endpunkt b mit Shift senkrecht-ish wegziehen → sollte exakt H oder V werden.
await page.keyboard.down("Shift");
await page.mouse.move(b[0], b[1]); await page.mouse.down();
await page.mouse.move(b[0] + 140, b[1] + 20, { steps: 8 }); // fast horizontal
await page.mouse.up();
await page.keyboard.up("Shift");
await new Promise((r) => setTimeout(r, 100));
// Die gerade bearbeitete Linie ist die ZWEITE draw2d-Linie.
const orthoLine = await page.evaluate(() => {
const ls = document.querySelectorAll(".plan-svg line.draw2d");
const l = ls[ls.length - 1];
return { x1: +l.getAttribute("x1"), y1: +l.getAttribute("y1"), x2: +l.getAttribute("x2"), y2: +l.getAttribute("y2") };
});
const dx = Math.abs(orthoLine.x2 - orthoLine.x1), dy = Math.abs(orthoLine.y2 - orthoLine.y1);
const isOrtho = dx < 1 || dy < 1; // exakt H oder V
await page.screenshot({ path: "scripts/probe-edit2.png" });
console.log("lineColor:", lineColor, "(expect #c0a040)");
console.log("parallelMoved:", moved);
console.log("shiftOrtho dx,dy:", dx.toFixed(2), dy.toFixed(2), "isOrtho:", isOrtho);
console.log(logs.length ? logs.join("\n") : "(no errors)");
await browser.close();
+14
View File
@@ -0,0 +1,14 @@
import { firefox } from "playwright";
const browser = await firefox.launch({ firefoxUserPrefs: { "webgl.disabled": true } });
const page = await browser.newPage();
const logs = [];
page.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`));
page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`));
try { await page.goto("http://localhost:5173/", { waitUntil: "networkidle", timeout: 20000 }); }
catch (e) { logs.push(`[GOTO-ERROR] ${e.message}`); }
await page.waitForTimeout(1500);
const root = await page.evaluate(() => { const r = document.getElementById("root"); return r ? r.innerHTML.length : -1; });
console.log("=== mit WebGL DEAKTIVIERT ===");
console.log("#root innerHTML Länge:", root, root === 0 ? "→ KOMPLETT WEISS (reproduziert!)" : "");
console.log(logs.join("\n"));
await browser.close();
+21
View File
@@ -0,0 +1,21 @@
import { firefox } from "playwright";
const browser = await firefox.launch({ firefoxUserPrefs: { "webgl.disabled": true } });
const page = await browser.newPage({ viewport: { width: 1200, height: 760 } });
const logs = [];
page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`));
await page.goto("http://localhost:5173/", { waitUntil: "networkidle", timeout: 20000 });
await page.waitForTimeout(1200);
const info = await page.evaluate(() => ({
rootLen: document.getElementById("root")?.innerHTML.length ?? -1,
hasFallback: !!document.querySelector(".viewport-fallback"),
hasPlanSvg: !!document.querySelector(".plan-svg polygon"),
header: document.querySelector(".brand")?.textContent?.trim().slice(0, 20),
}));
await page.screenshot({ path: "scripts/probe-ff-verify.png" });
console.log("=== Firefox OHNE WebGL — nach Fix ===");
console.log("root innerHTML Länge:", info.rootLen, info.rootLen > 0 ? "✅ nicht weiß" : "❌ weiß");
console.log("Fallback-Meldung sichtbar:", info.hasFallback ? "✅" : "❌");
console.log("Grundriss-SVG gerendert:", info.hasPlanSvg ? "✅" : "❌");
console.log("Header:", JSON.stringify(info.header));
console.log("PageErrors:", logs.length ? logs.join("\n") : "✅ keine");
await browser.close();
+31
View File
@@ -0,0 +1,31 @@
import { firefox } from "playwright";
const URL = process.env.PROBE_URL || "http://localhost:5173/";
const browser = await firefox.launch();
const page = await browser.newPage({ viewport: { width: 1200, height: 800 }, deviceScaleFactor: 2 });
const logs = [];
page.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`));
page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}\n${e.stack || ""}`));
page.on("requestfailed", (r) =>
logs.push(`[REQFAIL] ${r.url()} :: ${r.failure()?.errorText}`),
);
try {
await page.goto(URL, { waitUntil: "networkidle", timeout: 20000 });
} catch (e) {
logs.push(`[GOTO-ERROR] ${e.message}`);
}
await page.waitForTimeout(1500);
const rootHtml = await page.evaluate(() => {
const r = document.getElementById("root");
return r ? r.innerHTML.slice(0, 300) : "NO #root";
});
await page.screenshot({ path: "scripts/probe-ff.png" });
console.log("=== FIREFOX CONSOLE / ERRORS ===");
console.log(logs.length ? logs.join("\n") : "(keine Logs)");
console.log("\n=== #root innerHTML (erste 300 Zeichen) ===");
console.log(rootHtml);
await browser.close();
+65
View File
@@ -0,0 +1,65 @@
import puppeteer from "puppeteer";
const URL = process.env.PROBE_URL || "http://localhost:5173/";
const browser = await puppeteer.launch({
headless: "new",
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
});
const page = await browser.newPage();
await page.setViewport({ width: 1200, height: 800, deviceScaleFactor: 2 });
const logs = [];
page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`));
try { await page.goto(URL, { waitUntil: "networkidle0", timeout: 20000 }); } catch (e) { logs.push(`[GOTO] ${e.message}`); }
await new Promise((r) => setTimeout(r, 600));
const clickTool = (label) =>
page.evaluate((l) => [...document.querySelectorAll("button")].find((x) => x.textContent.trim() === l)?.click(), label);
const click = async (x, y, opts) => { await page.mouse.move(x, y); await page.mouse.click(x, y, opts); await new Promise((r) => setTimeout(r, 80)); };
const box = await page.evaluate(() => { const r = document.querySelector(".plan-svg").getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; });
const cx = box.x + box.w / 2, cy = box.y + box.h / 2;
// 1) Eine Wand zeichnen (A→B), Rechtsklick beendet.
await clickTool("Wand");
await new Promise((r) => setTimeout(r, 120));
const A = [cx - 160, cy - 150], B = [cx + 160, cy - 150];
await click(...A); await click(...B);
await page.mouse.click(B[0], B[1], { button: "right" });
await new Promise((r) => setTimeout(r, 150));
// 2) Auswahl-Werkzeug, Wand in der Mitte anklicken → Griffe erscheinen.
await clickTool("Auswahl");
await new Promise((r) => setTimeout(r, 120));
await click(cx, cy - 150);
const gripsAfterSelect = await page.evaluate(() => document.querySelectorAll(".plan-svg .plan-grip").length);
await page.screenshot({ path: "scripts/probe-grips-selected.png" });
// 3) Den End-Griff bei B nach unten ziehen.
await page.mouse.move(B[0], B[1]);
await page.mouse.down();
await page.mouse.move(B[0], B[1] + 90, { steps: 6 });
await page.mouse.move(B[0] - 30, B[1] + 130, { steps: 6 });
await page.mouse.up();
await new Promise((r) => setTimeout(r, 150));
await page.screenshot({ path: "scripts/probe-grips-dragged.png" });
// 4) Eine Linie zeichnen, auswählen, Endpunkt-Griff prüfen.
await clickTool("Linie");
await new Promise((r) => setTimeout(r, 100));
const L1 = [cx - 180, cy + 120], L2 = [cx + 150, cy + 160];
await click(...L1); await click(...L2);
await clickTool("Auswahl");
await new Promise((r) => setTimeout(r, 100));
// Mittig auf die Linie klicken (Mittelpunkt der beiden Endpunkte).
await click((L1[0] + L2[0]) / 2, (L1[1] + L2[1]) / 2);
const gripsLine = await page.evaluate(() => document.querySelectorAll(".plan-svg .plan-grip").length);
await page.screenshot({ path: "scripts/probe-grips-line.png" });
// 5) Linie löschen (Entf).
await page.keyboard.press("Delete");
await new Promise((r) => setTimeout(r, 120));
const draw2dAfterDelete = await page.evaluate(() => document.querySelectorAll(".plan-svg line.draw2d").length);
console.log("gripsAfterSelect:", gripsAfterSelect);
console.log("gripsLine:", gripsLine);
console.log("draw2dAfterDelete:", draw2dAfterDelete);
console.log(logs.length ? logs.join("\n") : "(no errors)");
await browser.close();
+132
View File
@@ -0,0 +1,132 @@
// Probe: DXF-Import-Dialog. Erzeugt eine kleine Test-DXF (LWPOLYLINE auf 2
// Layern), füttert sie in das versteckte Datei-Input, screenshotet den offenen
// Dialog, wählt „Neue Zeichnung" + Importieren und screenshotet den Grundriss.
import puppeteer from "puppeteer";
import { writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const URL = process.env.PROBE_URL || "http://localhost:5174/";
// ── Minimale Test-DXF: 2 LWPOLYLINE auf Layern „Waende" und „Moeblierung". ──
const dxf = `0
SECTION
2
ENTITIES
0
LWPOLYLINE
8
Waende
90
5
70
1
10
0
20
0
10
8
20
0
10
8
20
5
10
0
20
5
10
0
20
0
0
LWPOLYLINE
8
Moeblierung
90
4
70
0
10
2
20
1
10
4
20
1
10
4
20
3
10
2
20
3
0
ENDSEC
0
EOF
`;
const dxfPath = join(tmpdir(), "probe-import.dxf");
writeFileSync(dxfPath, dxf);
const browser = await puppeteer.launch({
headless: "new",
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
});
const page = await browser.newPage();
await page.setViewport({ width: 1200, height: 800, deviceScaleFactor: 1 });
const logs = [];
page.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`));
page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`));
await page.goto(URL, { waitUntil: "networkidle0", timeout: 20000 });
// Datei in das versteckte DXF-Input legen → onChange öffnet den Dialog.
const input = await page.$('input[type="file"][accept*=".dxf"]');
if (!input) throw new Error("DXF-Datei-Input nicht gefunden");
await input.uploadFile(dxfPath);
await page.waitForSelector(".imp-dialog", { timeout: 5000 });
await new Promise((r) => setTimeout(r, 300));
await page.screenshot({ path: "scripts/probe-import-dialog.png" });
console.log("Dialog-Screenshot: scripts/probe-import-dialog.png");
// Zusammenfassung auslesen (Konturen/Layer sichtbar?).
const summary = await page.$$eval(".imp-summary li", (lis) =>
lis.map((l) => l.textContent),
);
console.log("Zusammenfassung:", JSON.stringify(summary));
// Sicherstellen, dass „Neue Zeichnung" gewählt ist (Default), dann Importieren.
const importBtn = await page.$(".imp-btn.primary");
const disabled = await page.evaluate((b) => b.disabled, importBtn);
console.log("Import-Button disabled?", disabled);
await importBtn.click();
await new Promise((r) => setTimeout(r, 600));
// Dialog sollte geschlossen sein; Grundriss/Drawing-Ansicht der neuen Ebene.
const dialogGone = (await page.$(".imp-dialog")) === null;
console.log("Dialog geschlossen?", dialogGone);
// Anzahl Zeichnungsebenen-Zeilen + neuer „Zeichnung"-Eintrag prüfen (best effort).
const drawingCount = await page.evaluate(() => {
const project = window.__store?.getState?.()?.project;
if (!project) return null;
return {
drawingLevels: project.drawingLevels.map((z) => `${z.name}/${z.kind}`),
drawings2d: project.drawings2d.length,
};
});
console.log("Projekt:", JSON.stringify(drawingCount));
await page.screenshot({ path: "scripts/probe-import-plan.png" });
console.log("Plan-Screenshot: scripts/probe-import-plan.png");
console.log("\n=== Logs ===");
console.log(logs.length ? logs.join("\n") : "(keine)");
await browser.close();
+20
View File
@@ -0,0 +1,20 @@
import puppeteer from "puppeteer";
const b = await puppeteer.launch({ headless:"new", args:["--no-sandbox","--use-gl=swiftshader","--enable-unsafe-swiftshader"] });
const p = await b.newPage(); await p.setViewport({width:1300,height:850,deviceScaleFactor:2});
const logs=[]; p.on("pageerror",e=>logs.push(e.message));
await p.goto("http://localhost:5174/",{waitUntil:"networkidle0",timeout:20000}).catch(()=>{});
await new Promise(r=>setTimeout(r,700));
// Ressourcen öffnen.
await p.evaluate(()=>[...document.querySelectorAll("button")].find(x=>x.textContent.trim().toLowerCase().includes("ressourc"))?.click());
await new Promise(r=>setTimeout(r,300));
// Tab "Linien".
await p.evaluate(()=>[...document.querySelectorAll(".res-tab-btn")].find(x=>/Linien|Lines/.test(x.textContent))?.click());
await new Promise(r=>setTimeout(r,200));
const before = await p.evaluate(()=>document.querySelectorAll(".res-line-preview").length);
const input = await p.$('.res-tab input[type=file]');
await input.uploadFile("/tmp/sample.lin");
await new Promise(r=>setTimeout(r,400));
const after = await p.evaluate(()=>document.querySelectorAll(".res-line-preview").length);
await p.screenshot({ path:"scripts/probe-import.png" });
console.log("lineStyles before:", before, "after:", after, "(expect +3)", logs.length?logs.join("|"):"(no err)");
await b.close();
+12
View File
@@ -0,0 +1,12 @@
import puppeteer from "puppeteer";
const b=await puppeteer.launch({headless:"new",args:["--no-sandbox","--use-gl=swiftshader","--enable-unsafe-swiftshader"]});
const p=await b.newPage(); await p.setViewport({width:1280,height:820,deviceScaleFactor:1});
const errs=[]; p.on("pageerror",e=>errs.push(e.message));
await p.goto("http://localhost:5173/",{waitUntil:"networkidle0",timeout:20000}).catch(()=>{});
await new Promise(r=>setTimeout(r,700));
const clickText=async t=>{const h=await p.evaluateHandle(t=>[...document.querySelectorAll("button")].find(b=>b.textContent.trim()===t),t);const el=h.asElement();if(el){await el.click();return true;}return false;};
await clickText("Perspektive"); await new Promise(r=>setTimeout(r,500));
await clickText("Isometrie"); await new Promise(r=>setTimeout(r,800));
await p.screenshot({path:"scripts/probe-iso.png"});
console.log("iso done, errs:", errs.join("|")||"none");
await b.close();
+30
View File
@@ -0,0 +1,30 @@
import puppeteer from "puppeteer";
const URL = process.env.PROBE_URL || "http://localhost:5173/";
const b = await puppeteer.launch({ headless:"new", args:["--no-sandbox","--use-gl=swiftshader","--enable-unsafe-swiftshader"] });
const p = await b.newPage();
await p.setViewport({ width:1200, height:800, deviceScaleFactor:2 });
await p.emulateMediaFeatures([{ name:"prefers-color-scheme", value:"dark" }]);
const logs=[]; p.on("pageerror",e=>logs.push(e.message));
await p.goto(URL,{waitUntil:"networkidle0",timeout:20000}).catch(()=>{});
await new Promise(r=>setTimeout(r,600));
// Auf den "Ebenen"-Tab wechseln (TabStrip braucht Pointer-Events).
await p.evaluate(()=>{ const t=[...document.querySelectorAll(".tab")].find(e=>e.textContent.trim()==="Ebenen"); ["pointerdown","pointerup","click"].forEach(ev=>t.dispatchEvent(new MouseEvent(ev,{bubbles:true}))); });
await new Promise(r=>setTimeout(r,250));
const initialActive = await p.evaluate(()=>document.querySelector(".cat-row.active .cat-name")?.textContent ?? null);
// "Türen/Fenster" anklicken.
await p.evaluate(()=>{ const r=[...document.querySelectorAll(".cat-name")].find(e=>e.textContent.includes("Türen"))?.closest(".cat-row"); r?.click(); });
await new Promise(r=>setTimeout(r,250));
const afterActive = await p.evaluate(()=>document.querySelector(".cat-row.active .cat-name")?.textContent ?? null);
const statusLayer = await p.evaluate(()=>{ const f=[...document.querySelectorAll(".sb-field")].find(x=>x.textContent.trim().startsWith("EBENE")||x.textContent.includes("Ebene")); return f?.querySelector(".sb-val")?.textContent; });
// Jetzt eine Linie zeichnen → sollte auf Kategorie 21 (blau #5080c8) landen.
await p.evaluate(()=>[...document.querySelectorAll("button")].find(x=>x.textContent.trim()==="Linie")?.click());
await new Promise(r=>setTimeout(r,80));
const box = await p.evaluate(()=>{const r=document.querySelector(".plan-svg").getBoundingClientRect();return{x:r.x,y:r.y,w:r.width,h:r.height};});
const cx=box.x+box.w/2, cy=box.y+box.h/2;
for(const [x,y] of [[cx-120,cy-30],[cx+120,cy+30]]){ await p.mouse.move(x,y); await p.mouse.click(x,y); await new Promise(r=>setTimeout(r,60)); }
await p.evaluate(()=>[...document.querySelectorAll("button")].find(x=>x.textContent.trim()==="Auswahl")?.click());
await new Promise(r=>setTimeout(r,80));
const lineColor = await p.evaluate(()=>document.querySelector(".plan-svg line.draw2d")?.getAttribute("stroke"));
await p.screenshot({ path:"scripts/probe-layer.png" });
console.log("initialActive:", initialActive, "afterActive:", afterActive, "statusLayer:", statusLayer, "lineColor(expect #5080c8):", lineColor, logs.length?logs.join("|"):"(no err)");
await b.close();
+28
View File
@@ -0,0 +1,28 @@
import puppeteer from "puppeteer";
const URL = process.env.PROBE_URL || "http://localhost:5173/";
const browser = await puppeteer.launch({
headless: "new",
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
});
const page = await browser.newPage();
await page.setViewport({ width: 1200, height: 800, deviceScaleFactor: 2 });
const logs = [];
page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`));
try { await page.goto(URL, { waitUntil: "networkidle0", timeout: 20000 }); } catch (e) { logs.push(`[GOTO] ${e.message}`); }
await new Promise((r) => setTimeout(r, 600));
await page.evaluate(() => [...document.querySelectorAll("button")].find((x) => x.textContent.trim() === "Linie")?.click());
await new Promise((r) => setTimeout(r, 150));
const box = await page.evaluate(() => { const r = document.querySelector(".plan-svg").getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; });
const cx = box.x + box.w / 2, cy = box.y + box.h / 2;
// Zwei-Klick-Strecke quer durch den Raum.
for (const [x, y] of [[cx - 200, cy - 40], [cx + 200, cy + 90]]) {
await page.mouse.move(x, y); await page.mouse.down(); await page.mouse.up();
await new Promise((r) => setTimeout(r, 100));
}
await page.evaluate(() => [...document.querySelectorAll("button")].find((x) => x.textContent.trim() === "Auswahl")?.click());
await new Promise((r) => setTimeout(r, 200));
const draw2d = await page.evaluate(() => document.querySelectorAll(".plan-svg line.draw2d").length);
await page.screenshot({ path: "scripts/probe-line.png" });
console.log("draw2d lines:", draw2d);
console.log(logs.length ? logs.join("\n") : "(no errors)");
await browser.close();
+30
View File
@@ -0,0 +1,30 @@
import puppeteer from "puppeteer";
const b=await puppeteer.launch({headless:"new",args:["--no-sandbox","--use-gl=swiftshader","--enable-unsafe-swiftshader"]});
const p=await b.newPage(); await p.setViewport({width:1280,height:820,deviceScaleFactor:1});
const errs=[]; p.on("pageerror",e=>errs.push(e.message));
await p.goto("http://localhost:5173/",{waitUntil:"networkidle0",timeout:20000}).catch(()=>{});
await new Promise(r=>setTimeout(r,700));
const focusCmd=async()=>{ const el=await p.$(".cmdline-input"); const bx=await el.boundingBox(); await p.mouse.click(bx.x+bx.width/2, bx.y+bx.height/2); await new Promise(r=>setTimeout(r,60)); };
const typeLine=async t=>{ await p.type(".cmdline-input",t,{delay:8}); await p.keyboard.press("Enter"); await new Promise(r=>setTimeout(r,90)); };
// 1) Linie zeichnen (1,4)->(4,4)
await focusCmd(); await typeLine("line"); await typeLine("1,2.5"); await typeLine("r3,0");
// 2) gerenderte draw2d-Linie finden, Mittelpunkt klicken (selektieren)
const mid=await p.evaluate(()=>{
const l=[...document.querySelectorAll(".plan-svg line.draw2d")][0]; if(!l) return null;
const svg=l.ownerSVGElement, ctm=svg.getScreenCTM();
const pt=(x,y)=>{const q=svg.createSVGPoint();q.x=x;q.y=y;const s=q.matrixTransform(ctm);return[s.x,s.y];};
const a=pt(+l.getAttribute("x1"),+l.getAttribute("y1")), b=pt(+l.getAttribute("x2"),+l.getAttribute("y2"));
return {x:(a[0]+b[0])/2, y:(a[1]+b[1])/2};
});
if(mid){ await p.mouse.click(mid.x, mid.y); await new Promise(r=>setTimeout(r,150)); }
const selKind=await p.evaluate(()=>document.querySelector(".objinfo-kind")?.textContent||"none");
// 3) offset 0.5
await focusCmd(); await typeLine("offset"); await typeLine("0.5");
// 4) Linien (Modellkoordinaten) auslesen
const segs=await p.evaluate(()=>[...document.querySelectorAll(".plan-svg line.draw2d")].map(l=>{
// toScreen: x*90, -y*90 → invert: model = (vbx/90, -vby/90)
return {x1:+l.getAttribute("x1")/90, y1:-l.getAttribute("y1")/90, x2:+l.getAttribute("x2")/90, y2:-l.getAttribute("y2")/90};
}));
await p.screenshot({path:"scripts/probe-offset2.png"});
console.log(JSON.stringify({selKind, segCount:segs.length, segs, errs},null,2));
await b.close();
+58
View File
@@ -0,0 +1,58 @@
import puppeteer from "puppeteer";
const URL = process.env.PROBE_URL || "http://localhost:5173/";
const browser = await puppeteer.launch({
headless: "new",
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
});
const page = await browser.newPage();
await page.setViewport({ width: 1200, height: 800, deviceScaleFactor: 2 });
const logs = [];
page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`));
try { await page.goto(URL, { waitUntil: "networkidle0", timeout: 20000 }); } catch (e) { logs.push(`[GOTO] ${e.message}`); }
await new Promise((r) => setTimeout(r, 600));
const clickTool = (label) =>
page.evaluate((l) => [...document.querySelectorAll("button")].find((x) => x.textContent.trim() === l)?.click(), label);
const svgBox = () => page.evaluate(() => { const r = document.querySelector(".plan-svg").getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; });
const clickAt = async (x, y) => { await page.mouse.move(x, y); await page.mouse.down(); await page.mouse.up(); await new Promise((r) => setTimeout(r, 70)); };
const box = await svgBox();
const cx = box.x + box.w / 2, cy = box.y + box.h / 2;
// 1) Rechteck (zwei Ecken).
await clickTool("Rechteck");
await new Promise((r) => setTimeout(r, 120));
await clickAt(cx - 210, cy - 110);
await clickAt(cx - 40, cy + 40);
// 2) Polylinie (offen, 4 Punkte, Rechtsklick beendet).
await clickTool("Polylinie");
await new Promise((r) => setTimeout(r, 120));
const poly = [[cx + 40, cy - 100], [cx + 200, cy - 100], [cx + 200, cy + 30], [cx + 90, cy + 60]];
for (const [x, y] of poly) await clickAt(x, y);
await page.mouse.move(cx + 90, cy + 60);
await page.mouse.click(cx + 90, cy + 60, { button: "right" });
await new Promise((r) => setTimeout(r, 150));
// 3) Snap-Menü öffnen (Fang) und screenshoten.
await page.evaluate(() => {
const b = [...document.querySelectorAll("button")].find((x) => x.textContent.trim().startsWith("Fang"));
if (b) b.click();
});
await new Promise((r) => setTimeout(r, 150));
const popover = await page.evaluate(() => !!document.querySelector(".snap-popover"));
await page.screenshot({ path: "scripts/probe-phase2-snapmenu.png" });
// Menü schließen.
await page.keyboard.press("Escape");
await new Promise((r) => setTimeout(r, 100));
await clickTool("Auswahl");
await new Promise((r) => setTimeout(r, 150));
const counts = await page.evaluate(() => ({
draw2dLines: document.querySelectorAll(".plan-svg line.draw2d").length,
}));
await page.screenshot({ path: "scripts/probe-phase2.png" });
console.log("popoverOpened:", popover);
console.log("counts:", JSON.stringify(counts));
console.log(logs.length ? logs.join("\n") : "(no errors)");
await browser.close();
+42
View File
@@ -0,0 +1,42 @@
import puppeteer from "puppeteer";
const browser = await puppeteer.launch({
headless: "new",
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
});
const page = await browser.newPage();
await page.setViewport({ width: 1400, height: 900, deviceScaleFactor: 2 });
const errs = [];
page.on("pageerror", (e) => errs.push(`[PAGEERROR] ${e.message}`));
try { await page.goto("http://localhost:5173/", { waitUntil: "networkidle0", timeout: 20000 }); }
catch { /* networkidle timeout ok */ }
await new Promise((r) => setTimeout(r, 1200));
// 1) Plan zoom: wheel-in over the plan center, then screenshot
const svg = await page.$(".plan-svg");
if (svg) {
const box = await svg.boundingBox();
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.wheel({ deltaY: -600 });
await new Promise((r) => setTimeout(r, 300));
}
await page.screenshot({ path: "scripts/probe-zoom.png" });
// 2) Open Ressourcen manager, screenshot the table
const opened = await page.evaluate(() => {
const el = [...document.querySelectorAll("button, .res-trigger, [role=button]")]
.find((b) => /Ressourcen/i.test(b.textContent || ""));
if (el) { el.click(); return true; }
return false;
});
await new Promise((r) => setTimeout(r, 600));
await page.screenshot({ path: "scripts/probe-rm.png" });
const info = await page.evaluate(() => ({
managerOpen: !!document.querySelector(".res-drawer, .res-table, [class*=res-]"),
hasTable: !!document.querySelector(".res-table, table, .res-thead"),
triggerFound: [...document.querySelectorAll("button")].some((b)=>/Ressourcen/i.test(b.textContent||"")),
}));
console.log("opened trigger:", opened, JSON.stringify(info));
console.log("pageerrors:", errs.length ? errs.join("\n") : "none");
await browser.close();
+204
View File
@@ -0,0 +1,204 @@
// Probe für die Migration auf Component/HatchStyle/LineStyle + den Ressourcen-
// Manager. Erfasst (deviceScaleFactor 2, headless, SwiftShader) vier Zustände:
//
// (a) Standard-App: EG-Grundriss (mehrschichtige Wände, Schraffuren, saubere
// Ecken, Tür) — beweist, dass die Modell-Migration das Verhalten erhält.
// (b) Ressourcen-Manager → Tab „Bauteile": Liste der Components MIT ihren
// joinPriority-Werten.
// (c) Live-Edit: Farbe eines Bauteils (Backstein) ändern → der Grundriss
// aktualisiert sich live auf die neue Farbe (Vorher/Nachher + DOM-Fills).
// (d) Tab „Schraffuren" und Tab „Linien" mit editierbaren Feldern.
//
// Aufruf: node scripts/probe-resources.mjs
// Hinweis: networkidle-GOTO-Timeout wird ignoriert (Vite-HMR hält Verbindungen
// offen); wir warten stattdessen auf konkrete DOM-Knoten.
import puppeteer from "puppeteer";
const URL = process.env.PROBE_URL || "http://localhost:5173/";
const OUT = "scripts";
const browser = await puppeteer.launch({
headless: "new",
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
});
const page = await browser.newPage();
await page.setViewport({ width: 1360, height: 900, deviceScaleFactor: 2 });
const logs = [];
page.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`));
page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`));
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Klick auf das Element, dessen Textinhalt `text` enthält (innerhalb selector).
async function clickByText(selector, text) {
const handle = await page.evaluateHandle(
(sel, t) =>
[...document.querySelectorAll(sel)].find((el) =>
el.textContent.includes(t),
),
selector,
text,
);
const el = handle.asElement();
if (!el) throw new Error(`Element nicht gefunden: ${selector} ~ "${text}"`);
await el.click();
}
// Setzt den Wert eines Inputs so, dass Reacts onChange feuert.
async function setInputValue(elHandle, value) {
await page.evaluate(
(el, v) => {
const proto =
el.type === "color"
? window.HTMLInputElement.prototype
: window.HTMLInputElement.prototype;
const setter = Object.getOwnPropertyDescriptor(proto, "value").set;
setter.call(el, v);
el.dispatchEvent(new Event("input", { bubbles: true }));
el.dispatchEvent(new Event("change", { bubbles: true }));
},
elHandle,
value,
);
}
// Liest die Füllfarben aller Grundriss-Polygone (Component-Poché) + Anzahl.
const planFills = () =>
page.evaluate(() => {
const polys = [...document.querySelectorAll(".plan-svg polygon")];
const fills = polys
.map((p) => p.getAttribute("fill"))
.filter((f) => f && !f.startsWith("url("));
const patterns = [...document.querySelectorAll(".plan-svg defs pattern")]
.length;
const arcs = document.querySelectorAll(".plan-svg .door-swing").length;
const leaves = document.querySelectorAll(".plan-svg .door-leaf").length;
return {
polyCount: polys.length,
fills,
uniqueFills: [...new Set(fills)],
patternDefs: patterns,
doorArcs: arcs,
doorLeaves: leaves,
};
});
// Liest die Bauteil-Tabelle des Managers: Name + joinPriority je Zeile.
const componentRows = () =>
page.evaluate(() => {
const rows = [...document.querySelectorAll(".res-list .res-row")];
return rows.map((r) => {
const name = r.querySelector('input[type="text"]')?.value ?? null;
const prio = r.querySelector(".res-prio input")?.value ?? null;
const color = r.querySelector('input[type="color"]')?.value ?? null;
const hatch =
r.querySelector("select")?.selectedOptions?.[0]?.textContent ?? null;
return { name, joinPriority: prio, color, hatch };
});
});
// Liest die aktiven Tab-Beschriftungen + welcher Tab aktiv ist.
const tabState = () =>
page.evaluate(() => {
const tabs = [...document.querySelectorAll(".res-tab-btn")].map((b) => ({
label: b.textContent.trim(),
active: b.classList.contains("active"),
}));
const rowCount = document.querySelectorAll(".res-list .res-row").length;
const fieldLabels = [
...document.querySelectorAll(".res-list .res-row:first-child .res-field-label"),
].map((s) => s.textContent.trim());
const inputCount = document.querySelectorAll(
".res-list .res-row:first-child input, .res-list .res-row:first-child select",
).length;
return { tabs, rowCount, firstRowFieldLabels: fieldLabels, firstRowInputCount: inputCount };
});
try {
await page.goto(URL, { waitUntil: "networkidle0", timeout: 8000 });
} catch (e) {
logs.push(`[GOTO-INFO ignoriert] ${e.message}`);
}
// Auf den gerenderten Grundriss warten (statt networkidle).
await page.waitForSelector(".plan-svg polygon", { timeout: 15000 });
await sleep(500);
// ── (a) Standard: EG-Grundriss ──────────────────────────────────────────────
const fillsBefore = await planFills();
logs.push("(a) PLAN-VORHER " + JSON.stringify(fillsBefore));
await page.screenshot({ path: `${OUT}/probe-resources-a-grundriss.png` });
// ── (b) Ressourcen-Manager öffnen → Bauteile-Tab ────────────────────────────
await clickByText(".res-trigger", "Ressourcen");
await page.waitForSelector(".res-drawer", { timeout: 8000 });
await page.waitForSelector(".res-list .res-row", { timeout: 8000 });
await sleep(300);
logs.push("(b) TAB " + JSON.stringify(await tabState()));
logs.push("(b) BAUTEILE " + JSON.stringify(await componentRows()));
await page.screenshot({ path: `${OUT}/probe-resources-b-bauteile.png` });
// ── (c) Live-Edit: Backstein-Farbe auf knalliges Magenta ────────────────────
// Backstein-Zeile finden (Name-Textfeld == "Backstein"), dessen color-Input setzen.
const NEW_COLOR = "#ff00cc";
const colorHandle = await page.evaluateHandle((target) => {
const row = [...document.querySelectorAll(".res-list .res-row")].find(
(r) => r.querySelector('input[type="text"]')?.value === target,
);
return row?.querySelector('input[type="color"]') ?? null;
}, "Backstein");
if (!colorHandle.asElement())
throw new Error("Backstein-Farbfeld nicht gefunden");
await setInputValue(colorHandle.asElement(), NEW_COLOR);
await sleep(300);
logs.push("(c) BAUTEILE-NACH-EDIT " + JSON.stringify(await componentRows()));
// Screenshot mit offenem Manager (zeigt neuen Swatch im Manager).
await page.screenshot({ path: `${OUT}/probe-resources-c1-manager-edit.png` });
// Manager schließen → Grundriss frei sichtbar, Live-Update prüfen.
await page.evaluate(() => {
const overlay = document.querySelector(".res-overlay");
// auf das Overlay (außerhalb der Schublade) klicken schließt den Manager
overlay?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
// Falls der Overlay-Klick nicht griff, den ×-Knopf nutzen.
await sleep(200);
const stillOpen = await page.$(".res-drawer");
if (stillOpen) {
await page.evaluate(() => document.querySelector(".res-close")?.click());
}
await page.waitForSelector(".res-drawer", { hidden: true, timeout: 5000 }).catch(() => {});
await sleep(400);
const fillsAfter = await planFills();
logs.push("(c) PLAN-NACHHER " + JSON.stringify(fillsAfter));
const colorApplied = fillsAfter.fills.includes(NEW_COLOR);
logs.push(
`(c) LIVE-UPDATE ${colorApplied ? "OK" : "FEHLT"} — neue Farbe ${NEW_COLOR} ` +
`${colorApplied ? "im" : "NICHT im"} Grundriss; ` +
`uniqueFills vorher=${JSON.stringify(fillsBefore.uniqueFills)} ` +
`nachher=${JSON.stringify(fillsAfter.uniqueFills)}`,
);
await page.screenshot({ path: `${OUT}/probe-resources-c2-plan-updated.png` });
// ── (d) Schraffuren-Tab und Linien-Tab ──────────────────────────────────────
await clickByText(".res-trigger", "Ressourcen");
await page.waitForSelector(".res-drawer", { timeout: 8000 });
await sleep(200);
await clickByText(".res-tab-btn", "Schraffuren");
await page.waitForSelector(".res-list .res-row", { timeout: 8000 });
await sleep(300);
logs.push("(d) SCHRAFFUREN " + JSON.stringify(await tabState()));
await page.screenshot({ path: `${OUT}/probe-resources-d1-schraffuren.png` });
await clickByText(".res-tab-btn", "Linien");
await page.waitForSelector(".res-list .res-row", { timeout: 8000 });
await sleep(300);
logs.push("(d) LINIEN " + JSON.stringify(await tabState()));
await page.screenshot({ path: `${OUT}/probe-resources-d2-linien.png` });
console.log(logs.join("\n"));
await browser.close();
+27
View File
@@ -0,0 +1,27 @@
import puppeteer from "puppeteer";
const b=await puppeteer.launch({headless:"new",args:["--no-sandbox","--use-gl=swiftshader","--enable-unsafe-swiftshader"]});
const p=await b.newPage(); await p.setViewport({width:1280,height:820,deviceScaleFactor:1});
const errs=[]; p.on("pageerror",e=>errs.push(e.message));
await p.goto("http://localhost:5187/",{waitUntil:"networkidle0",timeout:20000}).catch(()=>{});
await new Promise(r=>setTimeout(r,700));
const focusCmd=async()=>{const el=await p.$(".cmdline-input");const bx=await el.boundingBox();await p.mouse.click(bx.x+bx.width/2,bx.y+bx.height/2);await new Promise(r=>setTimeout(r,60));};
const typeLine=async t=>{await p.type(".cmdline-input",t,{delay:6});await p.keyboard.press("Enter");await new Promise(r=>setTimeout(r,80));};
const lineCount=()=>p.$$eval(".plan-svg line.draw2d",ls=>ls.length).catch(()=>-1);
// zwei Linien zeichnen
await focusCmd(); await typeLine("line"); await typeLine("1,2.2"); await typeLine("r3,0");
await focusCmd(); await typeLine("line"); await typeLine("1,2.8"); await typeLine("r3,0");
const drawn=await lineCount();
// Screen-bbox der draw2d-Linien
const bb=await p.evaluate(()=>{const ls=[...document.querySelectorAll(".plan-svg line.draw2d")];const svg=ls[0].ownerSVGElement,ctm=svg.getScreenCTM();const pt=(x,y)=>{const q=svg.createSVGPoint();q.x=x;q.y=y;const s=q.matrixTransform(ctm);return[s.x,s.y];};let xs=[],ys=[];for(const l of ls){const a=pt(+l.getAttribute("x1"),+l.getAttribute("y1")),c=pt(+l.getAttribute("x2"),+l.getAttribute("y2"));xs.push(a[0],c[0]);ys.push(a[1],c[1]);}return{minX:Math.min(...xs),maxX:Math.max(...xs),minY:Math.min(...ys),maxY:Math.max(...ys)};});
// Marquee links→rechts (window) um beide Linien
await p.mouse.move(bb.minX-20, bb.minY-20); await p.mouse.down();
await p.mouse.move(bb.maxX+20, bb.maxY+20, {steps:6}); await new Promise(r=>setTimeout(r,60));
await p.mouse.up(); await new Promise(r=>setTimeout(r,200));
const selHi=await p.$$eval(".plan-svg .plan-sel-draw",e=>e.length).catch(()=>0);
const statusSel=await p.evaluate(()=>document.querySelector("footer,.statusbar")?.textContent?.match(/Auswahl[^|]*/)?.[0]||"");
// Löschen
await p.keyboard.press("Delete"); await new Promise(r=>setTimeout(r,200));
const afterDel=await lineCount();
await p.screenshot({path:"scripts/probe-sel.png"});
console.log(JSON.stringify({drawnLines:drawn, selHighlightPrims:selHi, statusSel, linesAfterDelete:afterDel, errs},null,2));
await b.close();
+300
View File
@@ -0,0 +1,300 @@
// Probe für Shell-Phase: native-App-Verhalten, Marquee-Auswahl, i18n.
// Headless, dpr 2, swiftshader. Schreibt PNGs nach scripts/ und meldet je
// Test (a)-(e) ein Ergebnis als JSON auf stdout (letzte Zeile).
import puppeteer from "puppeteer";
const TARGET = "http://localhost:5173/";
const OUT = new URL("./", import.meta.url).pathname;
const results = {};
const log = (k, v) => {
results[k] = v;
console.log(`[${k}]`, JSON.stringify(v));
};
const browser = await puppeteer.launch({
headless: "new",
args: [
"--no-sandbox",
"--use-gl=swiftshader",
"--enable-unsafe-swiftshader",
"--disable-dev-shm-usage",
],
});
try {
const page = await browser.newPage();
await page.setViewport({ width: 1400, height: 900, deviceScaleFactor: 2 });
page.on("pageerror", (e) => console.log("[pageerror]", e.message));
page.on("console", (m) => {
const t = m.text();
if (m.type() === "error") console.log("[console.error]", t);
});
try {
await page.goto(TARGET, { waitUntil: "networkidle0", timeout: 8000 });
} catch {
// networkidle-Timeout ignorieren (Dev-Server hält Verbindungen offen).
}
await page.waitForSelector(".plan-svg", { timeout: 15000 });
await new Promise((r) => setTimeout(r, 800));
// ── (a) Deutsch: Sichtbarer Text unverändert ────────────────────────────
const ger = await page.evaluate(() => {
const body = document.body.innerText;
return {
grundriss: body.includes("Grundriss"),
perspektive: body.includes("Perspektive"),
auswahl: body.includes("Auswahl"),
lang: window.__i18n?.getLanguage?.() ?? null,
};
});
await page.screenshot({ path: OUT + "probe-shell-a-de.png" });
log("a_german", {
...ger,
ok: ger.grundriss && ger.perspektive && ger.lang === "de",
});
// ── (b) Native-App: contextmenu unterdrückt, user-select ────────────────
const native = await page.evaluate(() => {
const ev = new MouseEvent("contextmenu", {
bubbles: true,
cancelable: true,
});
const prevented = !document.body.dispatchEvent(ev);
const bodySel = getComputedStyle(document.body).userSelect ||
getComputedStyle(document.body).webkitUserSelect;
return { defaultPrevented: prevented, bodyUserSelect: bodySel };
});
log("b_native_contextmenu", native);
// Ressourcen-Fenster öffnen, dann ein <input> auf user-select:text prüfen.
// Die Ressourcen-Aktion in der Topbar anklicken.
const openedResources = await page.evaluate(() => {
const btns = [...document.querySelectorAll("button, [role=button], .nav-tab, .topbar *")];
const hit = btns.find((b) => /Ressourcen/i.test(b.textContent || ""));
if (hit) { hit.click(); return true; }
return false;
});
await new Promise((r) => setTimeout(r, 500));
// Falls noch kein input sichtbar, einen Manager-Tab öffnen (Bauteile etc.).
const inputSel = await page.evaluate(() => {
const inp = document.querySelector("input");
if (!inp) return { found: false };
const cs = getComputedStyle(inp);
return {
found: true,
userSelect: cs.userSelect || cs.webkitUserSelect,
};
});
await page.screenshot({ path: OUT + "probe-shell-b-resources.png" });
log("b_input_userselect", {
openedResources,
...inputSel,
ok:
native.defaultPrevented === true &&
native.bodyUserSelect === "none" &&
inputSel.userSelect === "text",
});
// Ressourcen-Fenster wieder schließen, damit der Plan frei liegt (ESC).
await page.keyboard.press("Escape");
await new Promise((r) => setTimeout(r, 300));
// ── (c) Marquee: Links-Drag über den Raum wählt mehrere Wände ───────────
const planBox = await page.evaluate(() => {
const svg = document.querySelector(".plan-svg");
const r = svg.getBoundingClientRect();
return { x: r.x, y: r.y, w: r.width, h: r.height };
});
// Crossing-Marquee (rechts→rechts unten nach links oben): ein großes Rechteck,
// das den ganzen Raum kreuzt und damit ALLE vier Wände berührt. Rechts→links
// = crossing window (berührend), robust gegen exakte Wandlage.
const cx = planBox.x + planBox.w / 2;
const cy = planBox.y + planBox.h / 2;
const x0 = cx + planBox.w * 0.42;
const y0 = cy + planBox.h * 0.4;
const x1 = cx - planBox.w * 0.42;
const y1 = cy - planBox.h * 0.4;
await page.mouse.move(x0, y0);
await page.mouse.down({ button: "left" });
// Mehrere Zwischenschritte, damit pointermove + Schwelle greifen.
for (let i = 1; i <= 8; i++) {
await page.mouse.move(
x0 + ((x1 - x0) * i) / 8,
y0 + ((y1 - y0) * i) / 8,
);
await new Promise((r) => setTimeout(r, 20));
}
await page.mouse.up({ button: "left" });
await new Promise((r) => setTimeout(r, 400));
const marquee = await page.evaluate(() => {
const highlights = document.querySelectorAll(".plan-selected").length;
const footer = document.body.innerText;
const m = footer.match(/Auswahl:\s*(\d+)\s*(Wand|Wände)/);
return {
highlightPolys: highlights,
footerMatch: m ? m[0] : null,
footerN: m ? Number(m[1]) : 0,
};
});
await page.screenshot({ path: OUT + "probe-shell-c-marquee.png" });
log("c_marquee", {
...marquee,
ok: marquee.footerN >= 2 && marquee.highlightPolys >= 1,
});
// Einzel-Klick wählt genau eine Wand. Eine echte Wandfläche im DOM finden
// (gefülltes <polygon> innerhalb der SVG, das nicht Hintergrund/Auswahl ist),
// dann auf ihren Schwerpunkt in Client-Koordinaten klicken — deterministisch.
const wallPoint = await page.evaluate(() => {
const svg = document.querySelector(".plan-svg");
const polys = [...svg.querySelectorAll("polygon")].filter(
(p) =>
!p.classList.contains("plan-selected") &&
!p.classList.contains("plan-bg"),
);
const ctm = svg.getScreenCTM();
let best = null;
let bestArea = 0;
for (const poly of polys) {
const pts = poly.getAttribute("points");
if (!pts) continue;
const nums = pts.trim().split(/[\s,]+/).map(Number);
let cxv = 0,
cyv = 0,
n = 0;
let area = 0;
for (let i = 0; i + 1 < nums.length; i += 2) {
cxv += nums[i];
cyv += nums[i + 1];
n++;
}
if (n < 3) continue;
// grobe Bounding-Box-Fläche als Größenmaß
const xs = nums.filter((_, i) => i % 2 === 0);
const ys = nums.filter((_, i) => i % 2 === 1);
area =
(Math.max(...xs) - Math.min(...xs)) *
(Math.max(...ys) - Math.min(...ys));
if (area > bestArea) {
bestArea = area;
// SVG-User-Space → Client über CTM transformieren.
const ptObj = svg.createSVGPoint();
ptObj.x = cxv / n;
ptObj.y = cyv / n;
const sp = ptObj.matrixTransform(ctm);
best = { x: sp.x, y: sp.y };
}
}
return best;
});
if (wallPoint) {
await page.mouse.click(wallPoint.x, wallPoint.y, { button: "left" });
await new Promise((r) => setTimeout(r, 350));
}
const single = await page.evaluate(() => {
const footer = document.body.innerText;
const m = footer.match(/Auswahl:\s*(\d+)\s*(Wand|Wände)/);
return {
footerMatch: m ? m[0] : null,
footerN: m ? Number(m[1]) : 0,
highlightPolys: document.querySelectorAll(".plan-selected").length,
};
});
await page.screenshot({ path: OUT + "probe-shell-c2-single.png" });
log("c_single_click", { ...single, ok: single.footerN === 1 });
// ── (d) Rechtsklick auf den Plan öffnet UNSER ContextMenu (.ctx-menu) ────
// Erst sicherstellen, dass eine Wand gewählt ist (Einzelklick oben hat das
// getan). Rechtsklick auf den Plan.
await page.evaluate(
({ x, y }) => {
const svg = document.querySelector(".plan-svg");
const ev = new MouseEvent("contextmenu", {
bubbles: true,
cancelable: true,
clientX: x,
clientY: y,
button: 2,
});
svg.dispatchEvent(ev);
},
{ x: cx, y: planBox.y + planBox.h * 0.22 },
);
await new Promise((r) => setTimeout(r, 300));
const planCtx = await page.evaluate(() => ({
ctxMenu: document.querySelectorAll(".ctx-menu").length,
}));
await page.screenshot({ path: OUT + "probe-shell-d-plan-context.png" });
log("d_plan_context", { ...planCtx, ok: planCtx.ctxMenu >= 1 });
// Menü schliessen.
await page.mouse.click(planBox.x + 4, planBox.y + 4, { button: "left" });
await new Promise((r) => setTimeout(r, 200));
// Rechtsklick auf eine Ebenen-Zeile (linke Liste) öffnet ebenfalls .ctx-menu.
const layerCtx = await page.evaluate(() => {
const row =
document.querySelector(".nav-list .cat-name")?.closest("div") ||
document.querySelector(".nav-list > *");
if (!row) return { found: false, ctxMenu: 0 };
const r = row.getBoundingClientRect();
const ev = new MouseEvent("contextmenu", {
bubbles: true,
cancelable: true,
clientX: r.x + 10,
clientY: r.y + r.height / 2,
button: 2,
});
row.dispatchEvent(ev);
return { found: true };
});
await new Promise((r) => setTimeout(r, 300));
const layerCtxCount = await page.evaluate(() => ({
ctxMenu: document.querySelectorAll(".ctx-menu").length,
}));
await page.screenshot({ path: OUT + "probe-shell-d2-layer-context.png" });
log("d_layer_context", {
...layerCtx,
...layerCtxCount,
ok: layerCtx.found && layerCtxCount.ctxMenu >= 1,
});
await page.mouse.click(planBox.x + 4, planBox.y + 4, { button: "left" });
await new Promise((r) => setTimeout(r, 200));
// ── (e) i18n auf Englisch schalten + Screenshot ─────────────────────────
const enFlip = await page.evaluate(() => {
if (!window.__i18n?.setLanguage) return { exposed: false };
window.__i18n.setLanguage("en");
return { exposed: true, lang: window.__i18n.getLanguage() };
});
await new Promise((r) => setTimeout(r, 400));
const enText = await page.evaluate(() => {
const body = document.body.innerText;
return {
plan: body.includes("Plan"),
perspective: body.includes("Perspective"),
stillGerman: body.includes("Grundriss"),
};
});
await page.screenshot({ path: OUT + "probe-shell-e-en.png" });
log("e_english", {
...enFlip,
...enText,
ok:
enFlip.exposed === true &&
enFlip.lang === "en" &&
enText.perspective &&
!enText.stillGerman,
});
// Zurück auf Deutsch.
await page.evaluate(() => window.__i18n?.setLanguage("de"));
const allOk = Object.values(results).every((r) => r.ok !== false);
console.log("\nRESULT " + JSON.stringify({ allOk, results }));
} finally {
await browser.close();
}
+23
View File
@@ -0,0 +1,23 @@
import puppeteer from "puppeteer";
const b=await puppeteer.launch({headless:"new",args:["--no-sandbox","--use-gl=swiftshader","--enable-unsafe-swiftshader"]});
const p=await b.newPage(); await p.setViewport({width:1280,height:820,deviceScaleFactor:1});
const errs=[]; p.on("pageerror",e=>errs.push(e.message));
await p.goto("http://localhost:5187/",{waitUntil:"networkidle0",timeout:20000}).catch(()=>{});
await new Promise(r=>setTimeout(r,800));
const focus=async()=>{const el=await p.$(".cmdline-input");const bx=await el.boundingBox();await p.mouse.click(bx.x+bx.width/2,bx.y+bx.height/2);await new Promise(r=>setTimeout(r,50));};
const cmd=async t=>{await p.type(".cmdline-input",t,{delay:5});await p.keyboard.press("Enter");await new Promise(r=>setTimeout(r,70));};
await focus();await cmd("line");await cmd("1,2.2");await cmd("4,2.2");
await focus();await cmd("line");await cmd("1,2.8");await cmd("4,2.8");
// Bildschirmkoordinaten der zwei Linien
const mids=await p.evaluate(()=>{const ls=[...document.querySelectorAll(".plan-svg line.draw2d")];const svg=ls[0].ownerSVGElement,ctm=svg.getScreenCTM();const pt=(x,y)=>{const q=svg.createSVGPoint();q.x=x;q.y=y;const s=q.matrixTransform(ctm);return[s.x,s.y];};return ls.map(l=>{const a=pt(+l.getAttribute("x1"),+l.getAttribute("y1")),c=pt(+l.getAttribute("x2"),+l.getAttribute("y2"));return[(a[0]+c[0])/2,(a[1]+c[1])/2];});});
const selCount=()=>p.$$eval(".plan-svg .plan-sel-draw",e=>e.length).catch(()=>0);
// Linie A klicken
await p.mouse.click(mids[0][0],mids[0][1]); await new Promise(r=>setTimeout(r,150));
const after1=await selCount();
// Linie B shift-klicken
await p.keyboard.down("Shift"); await p.mouse.click(mids[1][0],mids[1][1]); await p.keyboard.up("Shift");
await new Promise(r=>setTimeout(r,150));
const after2=await selCount();
await p.screenshot({path:"scripts/probe-shiftsel.png"});
console.log(JSON.stringify({afterClickA:after1, afterShiftClickB:after2, errs:errs.slice(0,2)}));
await b.close();
+166
View File
@@ -0,0 +1,166 @@
// Verifikation Split / Join / Segment-Löschen (Ctrl+S / Ctrl+J / Alt+Klick).
// Liest das Modell über den TEMP-Hook window.__drawings2d (id + geom).
import puppeteer from "puppeteer";
const b = await puppeteer.launch({
headless: "new",
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
});
const p = await b.newPage();
await p.setViewport({ width: 1280, height: 820, deviceScaleFactor: 1 });
const errs = [];
p.on("pageerror", (e) => errs.push(e.message));
await p.goto("http://localhost:5187/", { waitUntil: "networkidle0", timeout: 20000 }).catch(() => {});
await new Promise((r) => setTimeout(r, 800));
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const focusCmd = async () => {
const el = await p.$(".cmdline-input");
const bx = await el.boundingBox();
await p.mouse.click(bx.x + bx.width / 2, bx.y + bx.height / 2);
await sleep(60);
};
const typeLine = async (t) => {
await p.type(".cmdline-input", t, { delay: 5 });
await p.keyboard.press("Enter");
await sleep(80);
};
// Laufenden Befehl beenden (sonst übernimmt er die linke Maustaste der Plan-
// Ansicht und Klicks selektieren nicht). Escape im Eingabefeld bricht ab.
const endCommand = async () => {
// Eingabefeld behält Fokus nach typeLine → Escape bricht den Befehl ab.
await p.keyboard.press("Escape");
await sleep(80);
await p.keyboard.press("Escape");
await sleep(80);
};
const drawings = () => p.evaluate(() => window.__drawings2d || []);
// Modellpunkt (Meter) → Client-Pixel über das aktuelle SVG-CTM (umgekehrt).
const modelToClient = (mx, my) =>
p.evaluate(
(mx, my) => {
const svg = document.querySelector(".plan-svg");
// toScreen aus PlanView: x*40, -y*40 (PX_PER_M=40, Y gespiegelt). Wir nutzen
// direkt das CTM: viewBox-Punkt (x*40, -y*40) → Bildschirm.
const ctm = svg.getScreenCTM();
const q = svg.createSVGPoint();
q.x = mx * 90;
q.y = -my * 90;
const s = q.matrixTransform(ctm);
return [s.x, s.y];
},
mx,
my,
);
const ctrlPress = async (key) => {
await p.keyboard.down("Control");
await p.keyboard.press(key);
await p.keyboard.up("Control");
await sleep(250);
};
const isClosed = (g) =>
g.shape === "rect" || (g.shape === "polyline" && g.closed);
// ── 1) SPLIT RECHTECK ────────────────────────────────────────────────────────
// Rechteck (0,0)-(4,4) + quer kreuzende vertikale Linie x=2.
await focusCmd();
await typeLine("rect");
await typeLine("0,0");
await typeLine("4,4");
await focusCmd();
await typeLine("line");
await typeLine("2,-1");
await typeLine("2,5");
await endCommand();
await sleep(150);
const before = await drawings();
// Rechteck wählen: auf eine Rechteck-Kante klicken (untere Kante y=0, x=1).
let [cx, cy] = await modelToClient(1, 0);
await p.mouse.click(cx, cy);
await sleep(150);
await ctrlPress("s");
await sleep(200);
const afterSplit = await drawings();
// Erwartung: Rechteck (1 closed) ersetzt durch 2 closed Elemente; Linie bleibt.
const closedAfter = afterSplit.filter((d) => isClosed(d.geom));
await p.screenshot({ path: "scripts/probe-splitjoin-1-split-rect.png" });
// ── 2) JOIN zwei Linien ──────────────────────────────────────────────────────
await p.reload({ waitUntil: "networkidle0" });
await sleep(800);
await focusCmd();
await typeLine("line");
await typeLine("-3,-3");
await typeLine("0,-3");
await focusCmd();
await typeLine("line");
await typeLine("0,-3");
await typeLine("0,0");
await endCommand();
await sleep(150);
const beforeJoin = await drawings();
// Beide wählen: erste klicken, dann Shift+zweite.
let [ax, ay] = await modelToClient(-1.5, -3);
await p.mouse.click(ax, ay);
await sleep(100);
let [bx2, by2] = await modelToClient(0, -1.5);
await p.keyboard.down("Shift");
await p.mouse.click(bx2, by2);
await p.keyboard.up("Shift");
await sleep(150);
await ctrlPress("j");
await sleep(200);
const afterJoin = await drawings();
const polylines = afterJoin.filter((d) => d.geom.shape === "polyline");
await p.screenshot({ path: "scripts/probe-splitjoin-2-join.png" });
// ── 3) ALT-SEGMENT (Schere) ──────────────────────────────────────────────────
await p.reload({ waitUntil: "networkidle0" });
await sleep(800);
await focusCmd();
await typeLine("rect");
await typeLine("0,0");
await typeLine("4,4");
await endCommand();
await sleep(150);
const beforeCut = await drawings();
// Alt+Klick auf die untere Kante (y=0, x=2).
let [ex, ey] = await modelToClient(2, 0);
await p.keyboard.down("Alt");
await p.mouse.click(ex, ey);
await p.keyboard.up("Alt");
await sleep(200);
const afterCut = await drawings();
const cutGeom = afterCut[0]?.geom;
await p.screenshot({ path: "scripts/probe-splitjoin-3-alt-cut.png" });
console.log(
JSON.stringify(
{
split: {
beforeCount: before.length,
afterCount: afterSplit.length,
closedCountAfter: closedAfter.length,
geomsAfter: afterSplit.map((d) => ({ shape: d.geom.shape, closed: isClosed(d.geom) })),
},
join: {
beforeCount: beforeJoin.length,
afterCount: afterJoin.length,
polylineCount: polylines.length,
joinedPts: polylines.map((d) => d.geom.pts?.length),
},
altCut: {
beforeShape: beforeCut[0]?.geom.shape,
afterCount: afterCut.length,
afterShape: cutGeom?.shape,
afterClosed: cutGeom ? isClosed(cutGeom) : null,
afterPts: cutGeom?.pts?.length,
},
errs,
},
null,
2,
),
);
await b.close();
+161
View File
@@ -0,0 +1,161 @@
// Probe für den Tab-Feld-Zyklus (Rhino-Präzisionseingabe §2.7).
//
// Treibt headless:
// A) Tab → „line"⏎ → Startpunkt „0,0"⏎ → „3"⏎ (lockt Länge, weiter zu Winkel)
// → „45"⏎ (lockt Winkel ⇒ committet) ⇒ EXAKTE 3-m-Linie unter 45°.
// B) Tab → „rect"⏎ → erste Ecke „0,-3"⏎ → Maus nach rechts-oben (Quadrant)
// → „4"⏎ (Breite) → „2"⏎ (Höhe ⇒ committet) ⇒ 4×2-Rechteck.
//
// Verifikation: liest die gerenderten `.plan-svg line.draw2d`-Endpunkte. Die
// Plan-Transform ist toScreen(p) = { x: p.x*90, y: -p.y*90 } (PX_PER_M=90, Y
// geflippt), also gilt für Modell-Längen/-Winkel:
// modelLen = pixelLen / 90
// modelAngle = atan2(-(y2-y1), x2-x1) (Y-Flip rückgängig)
// Screenshot scripts/probe-tab-fields.png zeigt Linie + Rechteck + Feld-Boxen.
import puppeteer from "puppeteer";
const URL = process.env.PROBE_URL || "http://localhost:5173/";
const PX_PER_M = 90;
const browser = await puppeteer.launch({
headless: "new",
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
});
const page = await browser.newPage();
await page.setViewport({ width: 1200, height: 800, deviceScaleFactor: 2 });
const logs = [];
page.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`));
page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`));
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
try {
await page.goto(URL, { waitUntil: "networkidle0", timeout: 20000 });
} catch (e) {
logs.push(`[GOTO-ERROR] ${e.message}`);
}
await sleep(600);
// Liest die draw2d-Segmente (in toScreen-Koordinaten = x*90 / -y*90) und rechnet
// in Modell-Länge/-Winkel zurück.
async function segments() {
const raw = await page.evaluate(() =>
[...document.querySelectorAll(".plan-svg line.draw2d")].map((l) => ({
x1: +l.getAttribute("x1"),
y1: +l.getAttribute("y1"),
x2: +l.getAttribute("x2"),
y2: +l.getAttribute("y2"),
})),
);
return raw.map((s) => {
const dxPx = s.x2 - s.x1;
const dyPx = s.y2 - s.y1;
const lenM = Math.hypot(dxPx, dyPx) / PX_PER_M;
// Y-Flip rückgängig: Modell-dy = -(Screen-dy).
let ang = (Math.atan2(-dyPx, dxPx) * 180) / Math.PI;
if (ang <= -0.0001) ang += 360; // 0..360
return { lenM: +lenM.toFixed(4), angleDeg: +ang.toFixed(2) };
});
}
// Liest die aktuellen Feld-Boxen der Command-Line (Label + Wert/„—" + aktiv).
async function fieldBoxes() {
return page.evaluate(() =>
[...document.querySelectorAll(".cmdline-field")].map((f) => ({
label: f.querySelector(".cmdline-field-label")?.textContent || "",
val: f.querySelector(".cmdline-field-val")?.textContent || "",
active: f.classList.contains("active"),
})),
);
}
async function focusCmdline() {
await page.mouse.click(600, 400); // in den Body, damit Tab nicht ein Feld trifft
await sleep(80);
await page.keyboard.press("Tab");
await sleep(120);
}
async function typeLine(text) {
await page.type(".cmdline-input", text, { delay: 12 });
await page.keyboard.press("Enter");
await sleep(140);
}
const box = await page.evaluate(() => {
const r = document.querySelector(".plan-svg").getBoundingClientRect();
return { x: r.x, y: r.y, w: r.width, h: r.height };
});
const cx = box.x + box.w / 2;
const cy = box.y + box.h / 2;
// ── A) Linie über Felder: Länge 3, Winkel 45° ───────────────────────────────
await focusCmdline();
await typeLine("line");
const promptLine = await page.evaluate(
() => document.querySelector(".cmdline-prompt")?.textContent || "",
);
await typeLine("0,0"); // Startpunkt → Schritt „end" (Feld-Modus aktiv)
const fieldsAtStart = await fieldBoxes();
// Maus woanders hin (zeigt: ungelockte Felder folgen der Maus, gelockte nicht).
await page.mouse.move(cx + 120, cy - 30);
await sleep(80);
await typeLine("3"); // lockt Länge → weiter zu Winkel
const fieldsAfterLen = await fieldBoxes();
// Screenshot MITTEN im Befehl: zeigt die Feld-Boxen (Länge=3, Winkel aktiv „—")
// in der Command-Line + die schon gelockte Vorschau-Linie.
await page.screenshot({ path: "scripts/probe-tab-fields-midcmd.png" });
await typeLine("45"); // lockt Winkel ⇒ committet die Linie
await sleep(150);
const segsA = await segments();
await page.screenshot({ path: "scripts/probe-tab-fields.png" });
// ── B) Rechteck über Felder: Breite 4, Höhe 2 ───────────────────────────────
await focusCmdline();
await typeLine("rect");
await typeLine("0,-3"); // erste Ecke (unter dem Ursprung, freie Fläche)
// Maus nach rechts-oben → Quadrant +x,+y (bestimmt Vorzeichen der Locks).
await page.mouse.move(cx + 150, cy - 80);
await sleep(80);
await typeLine("4"); // Breite
const rectFieldsAfterW = await fieldBoxes();
await typeLine("2"); // Höhe ⇒ committet
await sleep(150);
// Rechteck wird als 4 draw2d-Linien gerendert; sammeln + auf Auswahl umschalten,
// damit das PNG das fertige Rechteck zeigt.
const allSegs = await segments();
await page.screenshot({ path: "scripts/probe-tab-fields-rect.png" });
console.log("prompt after 'line':", JSON.stringify(promptLine));
console.log("fields at end-step start:", JSON.stringify(fieldsAtStart));
console.log("fields after locking length=3:", JSON.stringify(fieldsAfterLen));
console.log("LINE segments (model len/angle):", JSON.stringify(segsA));
console.log("rect fields after width=4:", JSON.stringify(rectFieldsAfterW));
console.log("ALL segments after rect (len/angle):", JSON.stringify(allSegs));
// ── Assertions ───────────────────────────────────────────────────────────────
const line3 = segsA.find((s) => Math.abs(s.lenM - 3) < 0.02 && Math.abs(s.angleDeg - 45) < 0.5);
console.log(
line3
? `PASS: line length≈3 (${line3.lenM}) angle≈45 (${line3.angleDeg})`
: "FAIL: no 3m/45° line found",
);
// Rechteck: zwei horizontale Kanten der Länge 4 + zwei vertikale der Länge 2.
const horiz4 = allSegs.filter(
(s) => Math.abs(s.lenM - 4) < 0.03 && (Math.abs(s.angleDeg) < 1 || Math.abs(s.angleDeg - 180) < 1 || Math.abs(s.angleDeg - 360) < 1),
);
const vert2 = allSegs.filter(
(s) => Math.abs(s.lenM - 2) < 0.03 && (Math.abs(s.angleDeg - 90) < 1 || Math.abs(s.angleDeg - 270) < 1),
);
console.log(
horiz4.length >= 2 && vert2.length >= 2
? `PASS: rect 4×2 (horiz4=${horiz4.length}, vert2=${vert2.length})`
: `FAIL: rect not 4×2 (horiz4=${horiz4.length}, vert2=${vert2.length})`,
);
console.log("=== Logs ===");
console.log(logs.length ? logs.join("\n") : "(keine)");
await browser.close();
+136
View File
@@ -0,0 +1,136 @@
// Probe: Kontext/Gelände-Integration (Phase 2, UI).
//
// 1) Erzeugt eine kleine Test-DXF (LWPOLYLINE-Höhenlinien Z=400/401/402).
// 2) Treibt den Import über das versteckte DXF-<input type=file> (uploadFile) —
// derselbe Pfad wie der Panel-Button „DXF importieren".
// 3) Screenshot Grundriss: Konturen als dezente Linien sichtbar.
// 4) Site-Panel-Tab aktivieren, „Gelände erzeugen" klicken.
// 5) In die Perspektive/Isometrie wechseln, orbiten, Screenshot: TIN im 3D.
import puppeteer from "puppeteer";
import { writeFileSync, mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const URL = process.env.PROBE_URL || "http://localhost:5173/";
// ── Test-DXF: drei geschlossene LWPOLYLINE-Höhenlinien (Elevation-Gruppe 38) ──
function ring(z, pts, layer) {
let s = `0\nLWPOLYLINE\n8\n${layer}\n38\n${z}\n90\n${pts.length}\n70\n1\n`;
for (const [x, y] of pts) s += `10\n${x}\n20\n${y}\n`;
return s;
}
// Etwas größere Konturen, damit sie im Plan gut sichtbar sind (Meter).
const dxf =
`0\nSECTION\n2\nENTITIES\n` +
ring(400, [[0, 0], [20, 0], [20, 20], [0, 20]], "contours") +
ring(401, [[4, 4], [16, 4], [16, 16], [4, 16]], "contours") +
ring(402, [[8, 8], [12, 8], [12, 12], [8, 12]], "contours") +
`0\nENDSEC\n0\nEOF\n`;
const dir = mkdtempSync(join(tmpdir(), "cad-dxf-"));
const dxfPath = join(dir, "contours.dxf");
writeFileSync(dxfPath, dxf);
const browser = await puppeteer.launch({
headless: "new",
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
});
const page = await browser.newPage();
await page.setViewport({ width: 1400, height: 900, deviceScaleFactor: 1 });
const logs = [];
page.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`));
page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`));
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
await page.goto(URL, { waitUntil: "networkidle0", timeout: 20000 });
await wait(600);
// Frisches Default-Layout erzwingen (neuer „site"-Tab), localStorage leeren.
await page.evaluate(() => {
try { localStorage.clear(); } catch { /* ignore */ }
});
await page.reload({ waitUntil: "networkidle0", timeout: 20000 });
await wait(600);
const clickByText = async (sel, re) => {
const el = (
await page.evaluateHandle(
(sel, reSrc) => {
const re = new RegExp(reSrc);
return [...document.querySelectorAll(sel)].find((e) => re.test(e.textContent.trim()));
},
sel,
re.source,
)
).asElement();
if (!el) throw new Error(`kein ${sel} matcht ${re}`);
await el.click();
return el;
};
// ── 2) Import über das versteckte DXF-Input ───────────────────────────────────
const input = await page.$('input[type=file][accept*=".dxf"]');
if (!input) throw new Error("DXF-Datei-Input nicht gefunden");
await input.uploadFile(dxfPath);
await wait(700);
// ── 3) Grundriss-Screenshot (Konturen sichtbar) ──────────────────────────────
// „Einpassen" über die Plan-Zoom-Buttons, damit die Konturen gerahmt werden.
await page.evaluate(() => {
const btn = [...document.querySelectorAll("button")].find((b) =>
/einpassen|fit|⊡|⤢/i.test(b.textContent || b.title || ""),
);
btn?.click();
});
await wait(500);
await page.screenshot({ path: "scripts/probe-terrain-ui-a-plan.png" });
// ── 4) Site-Panel öffnen + „Gelände erzeugen" ────────────────────────────────
// Den „Gelände/Terrain"-Tab in der rechten Gruppe aktivieren (TabStrip nutzt
// .tab-Buttons; ein Klick aktiviert den Tab über den Drag-Controller).
await clickByText(".tab", /^(Gelände|Terrain)$/);
await wait(400);
await page.screenshot({ path: "scripts/probe-terrain-ui-b-panel.png" });
// „Gelände erzeugen"-Button im Site-Panel klicken.
const genClicked = await page.evaluate(() => {
const btn = [...document.querySelectorAll(".site-panel .site-btn")].find((b) =>
/Gelände erzeugen|Generate terrain/i.test(b.textContent),
);
if (btn) { btn.click(); return true; }
return false;
});
console.log("Gelände-erzeugen geklickt:", genClicked);
await wait(600);
// Kontext-Objekte zählen (Liste im Site-Panel).
const rows = await page.evaluate(
() => document.querySelectorAll(".site-panel .site-row").length,
);
const badges = await page.evaluate(() =>
[...document.querySelectorAll(".site-panel .site-badge")].map((b) => b.textContent.trim()),
);
console.log("Site-Listenzeilen:", rows, "Badges:", JSON.stringify(badges));
// ── 5) Isometrie → 3D-Screenshot (TIN sichtbar) ──────────────────────────────
// Der „Isometrie"-view-btn setzt view3d=iso UND wechselt zugleich in die
// Perspektive (echter Ansichtswechsel) — ein Klick genügt.
await clickByText("button.view-btn", /^Isometrie$|^Isometric$/);
await wait(1200);
const vp = await page.evaluate(() => {
const r = document.querySelector(".viewport")?.getBoundingClientRect();
return r ? { x: r.x, y: r.y, w: r.width, h: r.height } : null;
});
if (vp) {
const cx = vp.x + vp.w / 2, cy = vp.y + vp.h / 2;
await page.mouse.move(cx, cy);
await page.mouse.down({ button: "middle" });
await page.mouse.move(cx + 120, cy + 30, { steps: 12 });
await page.mouse.up({ button: "middle" });
await wait(500);
}
await page.screenshot({ path: "scripts/probe-terrain-ui-c-3d.png" });
console.log("logs:", logs.length ? logs.join("\n") : "(keine)");
await browser.close();
+66
View File
@@ -0,0 +1,66 @@
// Probe: Gelände-TIN (Kontext-Schicht) im 3D sichtbar.
// Seedet über window.__seedTerrain() einen Höhenlinien-Satz + abgeleitetes TIN,
// wechselt in die Perspektive, orbitet ein wenig und screenshotet.
// Erwartung: eine plausible Geländefläche (TIN) im 3D.
import puppeteer from "puppeteer";
const URL = process.env.PROBE_URL || "http://localhost:5173/";
const browser = await puppeteer.launch({
headless: "new",
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
});
const page = await browser.newPage();
await page.setViewport({ width: 1400, height: 900, deviceScaleFactor: 1 });
const logs = [];
page.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`));
page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`));
await page.goto(URL, { waitUntil: "networkidle0", timeout: 20000 });
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
// 1) Gelände seeden (echte siteSlice-Actions über window.__seedTerrain).
const seed = await page.evaluate(() => window.__seedTerrain?.());
console.log("seed:", JSON.stringify(seed));
await wait(400);
// 2) In die Perspektive wechseln (löst Szenen-Neuaufbau inkl. Kontext aus).
const clickByText = async (sel, text) => {
const handle = await page.evaluateHandle(
(sel, text) =>
[...document.querySelectorAll(sel)].find((el) => el.textContent.trim() === text),
sel,
text,
);
const el = handle.asElement();
if (!el) throw new Error(`Button "${text}" (${sel}) nicht gefunden`);
await el.click();
};
await clickByText("button.view-btn", "Perspektive");
await wait(1000);
// 3) Isometrie für einen guten Blick auf die Geländefläche.
await clickByText("button.view-btn", "Isometrie");
await wait(900);
// 4) Etwas orbiten (mittlere Maustaste über dem Viewport).
const vp = await page.evaluate(() => {
const r = document.querySelector(".viewport")?.getBoundingClientRect();
return r ? { x: r.x, y: r.y, w: r.width, h: r.height } : null;
});
if (vp) {
const cx = vp.x + vp.w / 2;
const cy = vp.y + vp.h / 2;
await page.mouse.move(cx, cy);
await page.mouse.down({ button: "middle" });
await page.mouse.move(cx + 120, cy + 40, { steps: 12 });
await page.mouse.up({ button: "middle" });
await wait(500);
}
await page.screenshot({ path: "scripts/probe-terrain.png" });
// Diagnostik: Anzahl Meshes in der Szene ist im Headless nicht direkt lesbar;
// wir berichten Seed-Resultat + Konsolenfehler.
console.log("logs:", logs.length ? logs.join("\n") : "(keine)");
await browser.close();
+18
View File
@@ -0,0 +1,18 @@
import puppeteer from "puppeteer";
const b = await puppeteer.launch({ headless:"new", args:["--no-sandbox","--use-gl=swiftshader","--enable-unsafe-swiftshader"] });
const p = await b.newPage(); await p.setViewport({width:1300,height:850,deviceScaleFactor:2});
const logs=[]; p.on("pageerror",e=>logs.push(e.message));
await p.goto("http://localhost:5174/",{waitUntil:"networkidle0",timeout:20000}).catch(()=>{});
await new Promise(r=>setTimeout(r,800));
const info = await p.evaluate(()=>({
toolsPanel: !!document.querySelector(".tools-panel"),
toolRows: document.querySelectorAll(".tools-panel .tool-row").length,
activeTab: [...document.querySelectorAll(".tab")].find(t=>t.classList.contains("active"))?.textContent?.trim() ?? null,
}));
// Wand-Werkzeug im Panel klicken → wallType-Dropdown sollte erscheinen.
await p.evaluate(()=>{ const r=[...document.querySelectorAll(".tools-panel .tool-row")].find(x=>/Wand/.test(x.textContent)); r?.click(); });
await new Promise(r=>setTimeout(r,200));
const afterWall = await p.evaluate(()=>({ active: [...document.querySelectorAll(".tool-row.active")].map(x=>x.textContent.trim()), hasWallTypeSelect: !!document.querySelector(".tools-panel select") }));
await p.screenshot({ path:"scripts/probe-tools-panel.png" });
console.log("info:", JSON.stringify(info), "afterWall:", JSON.stringify(afterWall), logs.length?logs.join("|"):"(no err)");
await b.close();
+80
View File
@@ -0,0 +1,80 @@
import puppeteer from "puppeteer";
const URL = process.env.PROBE_URL || "http://localhost:5173/";
const browser = await puppeteer.launch({
headless: "new",
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
});
const page = await browser.newPage();
await page.setViewport({ width: 1200, height: 800, deviceScaleFactor: 2 });
const logs = [];
page.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`));
page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`));
try {
await page.goto(URL, { waitUntil: "networkidle0", timeout: 20000 });
} catch (e) {
logs.push(`[GOTO-ERROR] ${e.message}`);
}
await new Promise((r) => setTimeout(r, 600));
// 1) Wand-Werkzeug aktivieren (Button per Text finden).
const clickedWall = await page.evaluate(() => {
const btns = [...document.querySelectorAll("button")];
const b = btns.find((x) => x.textContent.trim() === "Wand");
if (b) { b.click(); return true; }
return false;
});
await new Promise((r) => setTimeout(r, 200));
// 2) SVG-Box ermitteln und eine L-Polylinie zeichnen (3 Punkte + Rechtsklick).
const box = await page.evaluate(() => {
const svg = document.querySelector(".plan-svg");
const r = svg.getBoundingClientRect();
return { x: r.x, y: r.y, w: r.width, h: r.height };
});
const cx = box.x + box.w / 2;
const cy = box.y + box.h / 2;
const pts = [
[cx - 160, cy - 120],
[cx - 160, cy + 60],
[cx + 80, cy + 60],
];
for (const [x, y] of pts) {
await page.mouse.move(x, y);
await page.mouse.down();
await page.mouse.up();
await new Promise((r) => setTimeout(r, 80));
}
// Hover, damit das letzte lebende Segment + Snap-Marker zu sehen sind.
await page.mouse.move(cx + 80, cy - 60);
await new Promise((r) => setTimeout(r, 120));
await page.screenshot({ path: "scripts/probe-tools-drawing.png" });
// Rechtsklick = Polylinie abschließen (commit).
await page.mouse.click(cx + 80, cy - 60, { button: "right" });
await new Promise((r) => setTimeout(r, 200));
// 3) Zurück auf Auswahl und Ergebnis screenshoten.
await page.evaluate(() => {
const b = [...document.querySelectorAll("button")].find(
(x) => x.textContent.trim() === "Auswahl",
);
if (b) b.click();
});
await new Promise((r) => setTimeout(r, 200));
// Wand-/Drawing-Zählung aus dem DOM (Polygone mit Wandbezug sind schwer zu
// zählen; stattdessen Anzahl Pfade/Polygone als grober Indikator).
const counts = await page.evaluate(() => ({
polygons: document.querySelectorAll(".plan-svg polygon").length,
lines: document.querySelectorAll(".plan-svg line").length,
}));
await page.screenshot({ path: "scripts/probe-tools.png" });
console.log("clickedWall:", clickedWall);
console.log("counts:", JSON.stringify(counts));
console.log("=== Logs ===");
console.log(logs.length ? logs.join("\n") : "(keine)");
await browser.close();
+16
View File
@@ -0,0 +1,16 @@
import puppeteer from "puppeteer";
const b=await puppeteer.launch({headless:"new",args:["--no-sandbox","--use-gl=swiftshader","--enable-unsafe-swiftshader"]});
const p=await b.newPage(); await p.setViewport({width:1400,height:200,deviceScaleFactor:3});
await p.goto("http://localhost:5187/",{waitUntil:"networkidle0",timeout:20000}).catch(()=>{});
await new Promise(r=>setTimeout(r,700));
const tb=await p.$(".topbar"); const box=await tb.boundingBox();
await p.screenshot({path:"scripts/probe-topbar.png", clip:{x:0,y:0,width:1400,height:Math.ceil(box.height)+8}});
// zähle die Icon-Buttons + ihre computed Größe/Farbe
const info=await p.evaluate(()=>{
const icons=[...document.querySelectorAll(".view-icon")];
const svg=icons[0]?.querySelector("svg");
const cs=icons[0]?getComputedStyle(icons[0]):null;
return { count:icons.length, cellW:cs?.width, cellH:cs?.height, color:cs?.color, svgW:svg?.getAttribute("width"), gridRows: document.querySelector(".view-grid")?getComputedStyle(document.querySelector(".view-grid")).gridTemplateColumns:null };
});
console.log(JSON.stringify(info));
await b.close();
+62
View File
@@ -0,0 +1,62 @@
import puppeteer from "puppeteer";
const URL = process.env.PROBE_URL || "http://localhost:5173/";
const browser = await puppeteer.launch({ headless: "new", args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"] });
const page = await browser.newPage();
await page.setViewport({ width: 1200, height: 800, deviceScaleFactor: 2 });
const logs = [];
page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`));
page.on("dialog", async (d) => { await d.accept("3"); }); // Kopienanzahl-Prompt → 3
try { await page.goto(URL, { waitUntil: "networkidle0", timeout: 20000 }); } catch (e) { logs.push(`[GOTO] ${e.message}`); }
await new Promise((r) => setTimeout(r, 600));
const clickTool = (l) => page.evaluate((x) => [...document.querySelectorAll("button")].find((b) => b.textContent.trim() === x)?.click(), l);
const click = async (x, y) => { await page.mouse.move(x, y); await page.mouse.click(x, y); await new Promise((r) => setTimeout(r, 60)); };
const nLines = () => page.evaluate(() => document.querySelectorAll(".plan-svg line.draw2d").length);
const box = await page.evaluate(() => { const r = document.querySelector(".plan-svg").getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; });
const cx = box.x + box.w / 2, cy = box.y + box.h / 2;
// Eine Linie zeichnen + selektieren.
await clickTool("Linie"); await new Promise((r) => setTimeout(r, 80));
const A = [cx - 160, cy - 120], B = [cx - 40, cy - 120];
await click(...A); await click(...B);
await clickTool("Auswahl"); await new Promise((r) => setTimeout(r, 60));
await click((A[0] + B[0]) / 2, A[1]); // selektieren
const n0 = await nLines();
// ── Modusleiste: 'm' drücken → Bar erscheint (Screenshot) ──
await page.keyboard.press("m");
await new Promise((r) => setTimeout(r, 120));
const barVisible = await page.evaluate(() => !!document.querySelector(".transform-bar"));
await page.screenshot({ path: "scripts/probe-transform-bar.png" });
// ── Move (U, default): Basispunkt → Ziel ──
await click(A[0], A[1]); // Basispunkt
await click(A[0] + 80, A[1] + 140); // Ziel
await new Promise((r) => setTimeout(r, 120));
const nMove = await nLines();
const movedPos = await page.evaluate(() => { const l = document.querySelector(".plan-svg line.draw2d"); return +l.getAttribute("y1"); });
// ── Copy (I): m, i, base, dest → +1 ──
await click((A[0] + B[0]) / 2 + 80, A[1] + 140); // selektieren (verschobene Linie)
await page.keyboard.press("m");
await page.keyboard.press("i");
await new Promise((r) => setTimeout(r, 60));
await click(cx, cy); await click(cx + 100, cy + 40);
await new Promise((r) => setTimeout(r, 120));
const nCopy = await nLines();
// ── Array (O=3): select a line, m, o(→3), base, dest → +3 ──
await click(cx + 50, cy + 20); // selektieren (eine der Linien)
await page.keyboard.press("m");
await page.keyboard.press("o");
await new Promise((r) => setTimeout(r, 150));
await click(cx - 50, cy + 120); await click(cx - 20, cy + 150);
await new Promise((r) => setTimeout(r, 150));
const nArray = await nLines();
await page.screenshot({ path: "scripts/probe-transform.png" });
console.log("n0(initial):", n0, "barVisible:", barVisible);
console.log("nMove(expect ==n0):", nMove, "movedY:", movedPos);
console.log("nCopy(expect n0+1):", nCopy);
console.log("nArray(expect nCopy+3):", nArray);
console.log(logs.length ? logs.join("\n") : "(no errors)");
await browser.close();
+54
View File
@@ -0,0 +1,54 @@
import puppeteer from "puppeteer";
const browser = await puppeteer.launch({
headless: "new",
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
});
const page = await browser.newPage();
await page.setViewport({ width: 1500, height: 950, deviceScaleFactor: 2 });
const errs = [];
page.on("pageerror", (e) => errs.push(`[PAGEERROR] ${e.message}`));
try { await page.goto("http://localhost:5173/", { waitUntil: "networkidle0", timeout: 20000 }); } catch {}
await new Promise((r) => setTimeout(r, 1200));
const footerScale = () => page.evaluate(() => {
const t = document.body.innerText;
const m = t.match(/MASSSTAB\s*(1:\d+)/i);
return m ? m[1] : "?";
});
const before = await footerScale();
// Massstab-Select auf 1:50 setzen
const changed = await page.evaluate(() => {
const selects = [...document.querySelectorAll("select")];
for (const s of selects) {
const opt = [...s.options].find((o) => /1:50\b/.test(o.textContent || o.value));
if (opt) {
s.value = opt.value;
s.dispatchEvent(new Event("change", { bubbles: true }));
return true;
}
}
return false;
});
await new Promise((r) => setTimeout(r, 500));
const after = await footerScale();
// Ressourcen öffnen
const resOpened = await page.evaluate(() => {
const b = [...document.querySelectorAll("button")].find((x) => /Ressourcen/i.test(x.textContent || ""));
if (b) { b.click(); return true; }
return false;
});
await new Promise((r) => setTimeout(r, 500));
const resInfo = await page.evaluate(() => {
const ov = document.querySelector(".res-drawer, .res-overlay, [class*=overlay], [class*=drawer]");
const z = ov ? getComputedStyle(ov).zIndex : null;
return { hasOverlay: !!ov, z };
});
await page.screenshot({ path: "scripts/probe-ressourcen-window.png" });
console.log("Massstab-Select gefunden:", changed);
console.log("Footer Massstab vorher:", before, "→ nachher:", after, after !== before ? "✅ ändert sich" : "⚠️ gleich");
console.log("Ressourcen geöffnet:", resOpened, "Overlay:", resInfo.hasOverlay, "z-index:", resInfo.z);
console.log("PageErrors:", errs.length ? errs.join("\n") : "keine");
await browser.close();
+84
View File
@@ -0,0 +1,84 @@
// Probe: belegt die Ansichts-3D-Presets (Front/Top/Seite/Persp./Iso) +
// Kamera-FOV. Wechselt in die Perspektive, klickt je Preset den Button und
// screenshotet. Die PNGs müssen sich deutlich unterscheiden.
// probe-view3d-perspective.png — 3/4-Blick
// probe-view3d-top.png — Draufsicht
// probe-view3d-front.png — frontal
// probe-view3d-side.png — seitlich
// probe-view3d-iso.png — isometrisch
// probe-view3d-fov.png — Kamera-Popover mit FOV-Regler (weit)
import puppeteer from "puppeteer";
const URL = process.env.PROBE_URL || "http://localhost:5173/";
const browser = await puppeteer.launch({
headless: "new",
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
});
const page = await browser.newPage();
await page.setViewport({ width: 1400, height: 900, deviceScaleFactor: 1 });
const logs = [];
page.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`));
page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`));
await page.goto(URL, { waitUntil: "networkidle0", timeout: 20000 });
const clickByText = async (sel, text) => {
const handle = await page.evaluateHandle(
(sel, text) =>
[...document.querySelectorAll(sel)].find(
(el) => el.textContent.trim() === text,
),
sel,
text,
);
const el = handle.asElement();
if (!el) throw new Error(`Button "${text}" (${sel}) nicht gefunden`);
await el.click();
};
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const shot = (name) => page.screenshot({ path: `scripts/${name}` });
// In die Perspektive wechseln.
await clickByText("button.view-btn", "Perspektive");
await wait(1000);
// Default-Preset (perspective) — Referenz.
await shot("probe-view3d-perspective.png");
// Presets durchschalten (per sichtbarem Button-Text in der 3D-Gruppe).
for (const [label, file] of [
["Oben", "probe-view3d-top.png"],
["Front", "probe-view3d-front.png"],
["Seite", "probe-view3d-side.png"],
["Isometrie", "probe-view3d-iso.png"],
["Perspektive", "probe-view3d-perspective2.png"],
]) {
await clickByText("button.view-btn", label);
await wait(700);
await shot(file);
}
// Kamera-Popover öffnen + FOV weit aufdrehen (sichtbarer Effekt).
await clickByText("button.tb-btn", "Kamera");
await wait(300);
const range = await page.$(".tb-popover input[type=range]");
if (range) {
await page.evaluate((el) => {
const setter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value",
).set;
setter.call(el, "90");
el.dispatchEvent(new Event("input", { bubbles: true }));
el.dispatchEvent(new Event("change", { bubbles: true }));
}, range);
await wait(500);
}
await shot("probe-view3d-fov.png");
console.log("Screenshots: probe-view3d-{perspective,top,front,side,iso,fov}.png");
console.log("\n=== Logs ===");
console.log(logs.length ? logs.join("\n") : "(keine)");
await browser.close();
+13
View File
@@ -0,0 +1,13 @@
import puppeteer from "puppeteer";
const b=await puppeteer.launch({headless:"new",args:["--no-sandbox","--use-gl=swiftshader","--enable-unsafe-swiftshader"]});
const p=await b.newPage(); await p.setViewport({width:1400,height:880,deviceScaleFactor:1.25});
const errs=[]; p.on("pageerror",e=>errs.push(e.message));
await p.goto("http://localhost:5187/",{waitUntil:"networkidle0",timeout:20000}).catch(()=>{});
await new Promise(r=>setTimeout(r,1200));
// auf eine Wandkante des Rechtecks klicken (linke Wand)
const hit=await p.evaluate(()=>{const polys=[...document.querySelectorAll(".plan-svg polygon, .plan-svg path")];if(!polys.length)return null;const svg=document.querySelector(".plan-svg");const r=svg.getBoundingClientRect();return{x:r.x+r.width*0.34,y:r.y+r.height*0.5};});
if(hit){await p.mouse.click(hit.x,hit.y);await new Promise(r=>setTimeout(r,400));}
const hasWallSec=await p.evaluate(()=>!!document.querySelector(".objinfo-panel")&&/Aufbau|Referenz|UK|OK/.test(document.querySelector(".objinfo-panel")?.textContent||""));
await p.screenshot({path:"scripts/probe-wall-sel.png"});
console.log("wallSection:",hasWallSec,"errs:",errs.slice(0,2));
await b.close();
+186
View File
@@ -0,0 +1,186 @@
import puppeteer from "puppeteer";
const URL = process.env.PROBE_URL || "http://localhost:5187/";
const b = await puppeteer.launch({
headless: "new",
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
});
const p = await b.newPage();
await p.setViewport({ width: 1400, height: 900, deviceScaleFactor: 1 });
const errs = [];
p.on("pageerror", (e) => errs.push(e.message));
p.on("console", (m) => { if (m.type() === "error") errs.push("[console] " + m.text()); });
await p.goto(URL, { waitUntil: "networkidle0", timeout: 20000 }).catch(() => {});
await new Promise((r) => setTimeout(r, 800));
const clickTab = (label) =>
p.evaluate((x) => {
const el = [...document.querySelectorAll("button, .tab, [role=tab]")].find(
(b) => b.textContent.trim() === x,
);
el?.click();
return !!el;
}, label);
// Object-Info-Tab aktivieren (falls vorhanden).
const tabOk = await clickTab("Objekt-Info");
await new Promise((r) => setTimeout(r, 200));
// Eine Wand im Grundriss anklicken (obere Bandkante eines Wandpolygons).
async function selectWall() {
const bb = await p.evaluate(() => {
const polys = [...document.querySelectorAll(".plan-svg polygon")];
if (!polys.length) return null;
let minX = 1e9, minY = 1e9, maxX = -1e9, maxY = -1e9;
for (const pl of polys) {
const r = pl.getBoundingClientRect();
minX = Math.min(minX, r.left); minY = Math.min(minY, r.top);
maxX = Math.max(maxX, r.right); maxY = Math.max(maxY, r.bottom);
}
return { minX, minY, maxX, maxY };
});
if (!bb) return false;
const cx = (bb.minX + bb.maxX) / 2;
for (const dy of [4, 8, 12, 16, 20]) {
await p.mouse.click(cx, bb.minY + dy);
await new Promise((r) => setTimeout(r, 120));
const k = await p.evaluate(
() => document.querySelector(".objinfo-kind")?.textContent || "",
);
if (/Wand/.test(k)) return true;
}
return false;
}
const selected = await selectWall();
// (1) Wand-Abschnitt sichtbar?
const hasWallSection = await p.evaluate(() =>
[...document.querySelectorAll(".objinfo-section-label")].some(
(e) => e.textContent.trim() === "Wand",
),
);
const hasRefDropdown = await p.evaluate(
() => !!document.querySelector(".objinfo-head-ref"),
);
await p.screenshot({ path: "scripts/probe-wallattr-1-panel.png" });
// Plan-Footprint VORHER (bbox aller Wandpolygone).
const planBBox = () =>
p.evaluate(() => {
const polys = [...document.querySelectorAll(".plan-svg polygon")];
let minY = 1e9, maxY = -1e9;
for (const pl of polys) {
const r = pl.getBoundingClientRect();
minY = Math.min(minY, r.top); maxY = Math.max(maxY, r.bottom);
}
return { minY: Math.round(minY), maxY: Math.round(maxY) };
});
const beforeRef = await planBBox();
// (2) Referenzlinie auf „Außen (links)" stellen über das Kopf-Dropdown.
async function pickDropdown(triggerSel, optionText) {
const ok = await p.evaluate((sel) => {
const t = document.querySelector(sel);
if (!t) return false;
t.click();
return true;
}, triggerSel);
if (!ok) return false;
await new Promise((r) => setTimeout(r, 150));
const picked = await p.evaluate((txt) => {
const item = [...document.querySelectorAll(".tb-dd-menu .tb-dd-item")].find(
(i) => i.textContent.trim().includes(txt),
);
item?.click();
return !!item;
}, optionText);
await new Promise((r) => setTimeout(r, 200));
return picked;
}
const refPicked = await pickDropdown(
".objinfo-head-ref .tb-dd-trigger",
"Außen",
);
await new Promise((r) => setTimeout(r, 250));
const afterRef = await planBBox();
await p.screenshot({ path: "scripts/probe-wallattr-2-refline-aussen.png" });
// (3) OK auf „eigene Höhe" → Z-Wert ändern, dann 3D.
// Den OK-Block (zweite objinfo-wall-anchor) auf „Eigene Höhe" schalten.
const okCustom = await p.evaluate(() => {
const blocks = [...document.querySelectorAll(".objinfo-wall-anchor")];
const okBlock = blocks[1];
if (!okBlock) return false;
const btn = [...okBlock.querySelectorAll(".objinfo-seg-btn")].find(
(b) => b.textContent.trim() === "Eigene Höhe",
);
btn?.click();
return !!btn;
});
await new Promise((r) => setTimeout(r, 200));
// Z-Feld setzen (5.0 m) im OK-Block.
const heightBefore = await p.evaluate(
() =>
[...document.querySelectorAll(".objinfo-field")]
.find((f) => f.querySelector(".objinfo-flabel")?.textContent === "Höhe")
?.querySelector(".objinfo-fval")?.textContent || "",
);
const setZ = await p.evaluate(() => {
const blocks = [...document.querySelectorAll(".objinfo-wall-anchor")];
const okBlock = blocks[1];
const input = okBlock?.querySelector("input.objinfo-input");
if (!input) return false;
const setter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value",
).set;
setter.call(input, "5");
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
input.blur();
return true;
});
await new Promise((r) => setTimeout(r, 250));
const heightAfter = await p.evaluate(
() =>
[...document.querySelectorAll(".objinfo-field")]
.find((f) => f.querySelector(".objinfo-flabel")?.textContent === "Höhe")
?.querySelector(".objinfo-fval")?.textContent || "",
);
await p.screenshot({ path: "scripts/probe-wallattr-3-ok-custom.png" });
// 3D (Isometrie) umschalten und Screenshot — der Umschalter ist ein Icon-Button
// mit title „Isometrie …" in der Oberleiste (kein Tab).
await p.evaluate(() => {
const el = [...document.querySelectorAll("button[title]")].find((b) =>
(b.getAttribute("title") || "").startsWith("Isometrie"),
);
el?.click();
});
await new Promise((r) => setTimeout(r, 1000));
await p.screenshot({ path: "scripts/probe-wallattr-4-3d.png" });
console.log(
JSON.stringify(
{
tabOk,
selected,
hasWallSection,
hasRefDropdown,
refPicked,
beforeRef,
afterRef,
footprintShifted: beforeRef.minY !== afterRef.minY || beforeRef.maxY !== afterRef.maxY,
okCustom,
setZ,
heightBefore,
heightAfter,
errs,
},
null,
2,
),
);
await b.close();
+47
View File
@@ -0,0 +1,47 @@
import puppeteer from "puppeteer";
const URL = process.env.PROBE_URL || "http://localhost:5173/";
const browser = await puppeteer.launch({
headless: "new",
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
});
const page = await browser.newPage();
// HiDPI simulieren — das ist die Bedingung, die die Schleife auslöste.
await page.setViewport({ width: 1200, height: 800, deviceScaleFactor: 2 });
const logs = [];
page.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`));
page.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`));
try {
await page.goto(URL, { waitUntil: "networkidle0", timeout: 20000 });
} catch (e) {
logs.push(`[GOTO-ERROR] ${e.message}`);
}
const measure = () =>
page.evaluate(() => {
const c = document.querySelector(".viewport canvas");
const v = document.querySelector(".viewport");
const r = c?.getBoundingClientRect();
return {
canvasCss: r ? `${Math.round(r.width)}x${Math.round(r.height)}` : "none",
canvasBuffer: c ? `${c.width}x${c.height}` : "none",
viewport: v ? `${Math.round(v.clientWidth)}x${Math.round(v.clientHeight)}` : "none",
bodyScrollH: document.body.scrollHeight,
};
});
const m1 = await measure();
await new Promise((r) => setTimeout(r, 1200));
const m2 = await measure();
await page.screenshot({ path: "scripts/probe.png" });
const stable = JSON.stringify(m1) === JSON.stringify(m2);
console.log("=== Messung 1 ===", JSON.stringify(m1));
console.log("=== Messung 2 ===", JSON.stringify(m2));
console.log(stable ? "✅ STABIL — keine Schleife" : "❌ INSTABIL — wächst/schrumpft noch");
console.log("\n=== Logs ===");
console.log(logs.length ? logs.join("\n") : "(keine)");
await browser.close();
+192
View File
@@ -0,0 +1,192 @@
// Numerische Verifikation des 2D-Kernels (esbuild → node). Kein UI nötig.
import {
segmentIntersect,
offsetPolyline,
offsetSegment,
trimSegment,
extendSegment,
filletCorner,
pointSegmentDistance,
lineCircleIntersect,
circleCircleIntersect,
signedArea,
isCCW,
joinChains,
removeSegment,
splitAtIntersections,
splitClosedByChord,
splitPolylineAtParam,
vecEqual,
} from "../src/geometry/kernel2d.ts";
let pass = 0;
let fail = 0;
const approx = (a: number, b: number, eps = 1e-6) => Math.abs(a - b) <= eps;
function check(name: string, cond: boolean, detail?: unknown) {
if (cond) { pass++; console.log(" ok " + name); }
else { fail++; console.log(" FAIL " + name, JSON.stringify(detail)); }
}
// 1) Strecken-Schnitt: X durch (1,1)
{
const h = segmentIntersect({x:0,y:0},{x:2,y:2},{x:0,y:2},{x:2,y:0});
check("segmentIntersect cross at (1,1)", !!h && approx(h!.point.x,1) && approx(h!.point.y,1) && approx(h!.t,0.5), h);
}
// 2) parallele Strecken → null
{
const h = segmentIntersect({x:0,y:0},{x:2,y:0},{x:0,y:1},{x:2,y:1});
check("segmentIntersect parallel -> null", h === null);
}
// 3) kein Schnitt innerhalb der Segmente → null
{
const h = segmentIntersect({x:0,y:0},{x:1,y:0},{x:2,y:-1},{x:2,y:1});
check("segmentIntersect outside -> null", h === null);
}
// 4) Offset einer Strecke nach links (d>0): +y
{
const [a,b] = offsetSegment({x:0,y:0},{x:1,y:0},0.5);
check("offsetSegment left +0.5y", approx(a.y,0.5) && approx(b.y,0.5) && approx(a.x,0) && approx(b.x,1), {a,b});
}
// 5) Offset geschlossenes Quadrat: Konvention +leftNormal zeigt bei CCW NACH
// INNEN (CONVENTIONS.md), also d>0 = innen → (1,1)..(3,3); d<0 = außen → (-1,-1)..(5,5).
{
const sq = [{x:0,y:0},{x:4,y:0},{x:4,y:4},{x:0,y:4}]; // CCW
const inn = offsetPolyline(sq, 1, true);
const okIn = inn.length===4 &&
approx(inn[0].x,1)&&approx(inn[0].y,1)&&
approx(inn[1].x,3)&&approx(inn[1].y,1)&&
approx(inn[2].x,3)&&approx(inn[2].y,3)&&
approx(inn[3].x,1)&&approx(inn[3].y,3);
check("offsetPolyline square inward by 1 (CCW +n=inside)", okIn, inn);
const out = offsetPolyline(sq, -1, true);
const okOut = out.length===4 && approx(out[0].x,-1)&&approx(out[0].y,-1)&&approx(out[2].x,5)&&approx(out[2].y,5);
check("offsetPolyline square outward by 1 (d<0)", okOut, out);
}
// 6) Offset offene L-Polylinie (Gehrung am Innenknick)
{
const L = [{x:0,y:0},{x:4,y:0},{x:4,y:4}];
const off = offsetPolyline(L, 1, false); // links
// Endkanten verschoben; mittlerer Punkt = Gehrung bei (3,1) für links-offset
check("offsetPolyline open L mitre", off.length===3 && approx(off[1].x,3) && approx(off[1].y,1), off);
}
// 7) Trim: Strecke 0..4 von Cutter bei x=1 und x=3, Pick im Mittelstück → 2 Reststücke
{
const cutters = [
{pts:[{x:1,y:-1},{x:1,y:1}], closed:false},
{pts:[{x:3,y:-1},{x:3,y:1}], closed:false},
];
const rem = trimSegment({x:0,y:0},{x:4,y:0},cutters,{x:2,y:0});
const ok = rem.length===2 &&
approx(rem[0][0].x,0)&&approx(rem[0][1].x,1)&&
approx(rem[1][0].x,3)&&approx(rem[1][1].x,4);
check("trimSegment removes middle piece", ok, rem);
}
// 8) Extend: Strecke 0..1 auf x-Achse, Ende bis Cutter bei x=3
{
const cutters=[{pts:[{x:3,y:-1},{x:3,y:1}],closed:false}];
const ex = extendSegment({x:0,y:0},{x:1,y:0},"end",cutters);
check("extendSegment to x=3", !!ex && approx(ex![1].x,3) && approx(ex![1].y,0), ex);
}
// 9) Extend start-Ende rückwärts bis x=-2
{
const cutters=[{pts:[{x:-2,y:-1},{x:-2,y:1}],closed:false}];
const ex = extendSegment({x:0,y:0},{x:1,y:0},"start",cutters);
check("extendSegment start to x=-2", !!ex && approx(ex![0].x,-2), ex);
}
// 10) Fillet 90°-Ecke, r=1 → setback 1, center (1,1)
{
const f = filletCorner({x:0,y:0},{x:5,y:0},{x:0,y:5},1);
const ok = !!f && approx(f!.tangentA.x,1)&&approx(f!.tangentA.y,0)&&
approx(f!.tangentB.x,0)&&approx(f!.tangentB.y,1)&&
approx(f!.center.x,1)&&approx(f!.center.y,1)&&approx(f!.radius,1);
check("filletCorner 90deg r1", ok, f);
}
// 11) Fillet kollinear → null
{
const f = filletCorner({x:0,y:0},{x:1,y:0},{x:-1,y:0},1);
check("filletCorner collinear -> null", f === null);
}
// 12) pointSegmentDistance
{
check("pointSegmentDistance perpendicular", approx(pointSegmentDistance({x:1,y:2},{x:0,y:0},{x:4,y:0}),2));
}
// 13) Linie durch Kreis (Mittelpunkt-Sekante) → 2 Punkte ±r
{
const ps = lineCircleIntersect({x:-5,y:0},{x:5,y:0},{x:0,y:0},2);
const xs = ps.map(p=>p.x).sort((a,b)=>a-b);
check("lineCircleIntersect secant", ps.length===2 && approx(xs[0],-2) && approx(xs[1],2), ps);
}
// 14) Zwei Kreise (Abstand 2, r=√2 je) schneiden sich bei (1,±1)
{
const ps = circleCircleIntersect({x:0,y:0},Math.SQRT2,{x:2,y:0},Math.SQRT2);
const ok = ps.length===2 && ps.every(p=>approx(Math.abs(p.x),1)&&approx(Math.abs(p.y),1)) && approx(ps[0].x,1);
check("circleCircleIntersect at (1,±1)", ok, ps);
}
// 15) Wicklung
{
const ccw = [{x:0,y:0},{x:4,y:0},{x:4,y:4},{x:0,y:4}];
check("signedArea/isCCW", approx(signedArea(ccw),16) && isCCW(ccw) && !isCCW([...ccw].reverse()));
}
// ── Split / Join / Segment-Löschen ───────────────────────────────────────────
const V = (x: number, y: number) => ({ x, y });
// 16) splitPolylineAtParam: offene Linie → 2 Stücke an (5,0)
{
const parts = splitPolylineAtParam([V(0,0),V(10,0)], false, 0, 0.5);
check("splitPolyline open -> 2 pieces", parts.length===2 && approx(parts[0][1].x,5) && approx(parts[1][0].x,5), parts);
}
// 17) splitPolylineAtParam: geschlossenes Quadrat → eine offene Kette von/bis Schnitt
{
const sq=[V(0,0),V(4,0),V(4,4),V(0,4)];
const parts = splitPolylineAtParam(sq, true, 0, 0.5);
const c = parts[0];
check("splitPolyline closed -> 1 open chain", parts.length===1 && vecEqual(c[0],V(2,0)) && vecEqual(c[c.length-1],V(2,0)), c);
}
// 18) splitClosedByChord: Quadrat unten/oben → 2 geschlossene Hälften
{
const sq=[V(0,0),V(4,0),V(4,4),V(0,4)];
const res = splitClosedByChord(sq, 0, 0.5, 2, 0.5);
const has=(p:{x:number;y:number}[],q:{x:number;y:number})=>p.some(r=>vecEqual(r,q));
const ok = !!res && res[0].length>=3 && res[1].length>=3 &&
has(res![0],V(2,0)) && has(res![0],V(2,4)) && has(res![1],V(2,0)) && has(res![1],V(2,4));
check("splitClosedByChord -> 2 closed halves", ok, res);
check("splitClosedByChord same edge -> null", splitClosedByChord(sq,1,0.5,1,0.7)===null);
}
// 19) removeSegment: geschlossenes Quadrat → offen, Start nach Kante
{
const sq=[V(0,0),V(4,0),V(4,4),V(0,4)];
const r = removeSegment(sq, true, 0);
check("removeSegment closed -> open chain", r.closed===false && r.pts.length===4 && vecEqual(r.pts[0],V(4,0)), r);
}
// 20) splitAtIntersections: Rechteck × querende Linie → 2 geschlossene Hälften
{
const rect=[V(0,0),V(4,0),V(4,4),V(0,4)];
const cutter=[V(2,-1),V(2,5)];
const pieces = splitAtIntersections(rect, true, [cutter]);
check("splitAtIntersections rect×line -> 2 closed", pieces.length===2 && pieces[0].length>=3 && pieces[1].length>=3, pieces);
}
// 21) splitAtIntersections: offene Linie × Cutter → 2 Stücke an (5,0)
{
const pieces = splitAtIntersections([V(0,0),V(10,0)], false, [[V(5,-2),V(5,2)]]);
check("splitAtIntersections open line -> 2 pieces", pieces.length===2 && approx(pieces[0][pieces[0].length-1].x,5), pieces);
}
// 22) joinChains: zwei Linien mit gemeinsamem Endpunkt → eine Polylinie
{
const out = joinChains([{pts:[V(0,0),V(5,0)],closed:false},{pts:[V(5,0),V(5,5)],closed:false}]);
check("joinChains 2 chains -> 1 polyline (3 pts)", out.length===1 && out[0].pts.length===3 && out[0].closed===false, out);
}
// 23) joinChains: vier Kanten schließen sich → eine geschlossene Schleife
{
const out = joinChains([
{pts:[V(0,0),V(4,0)],closed:false},
{pts:[V(4,0),V(4,4)],closed:false},
{pts:[V(4,4),V(0,4)],closed:false},
{pts:[V(0,4),V(0,0)],closed:false},
]);
check("joinChains loop -> closed ring (4 pts)", out.length===1 && out[0].closed===true && out[0].pts.length===4, out);
}
console.log(`\nKERNEL2D: ${pass} ok, ${fail} fail`);
if (fail > 0) process.exit(1);
+2783
View File
File diff suppressed because it is too large Load Diff
+141
View File
@@ -0,0 +1,141 @@
// Circle — Mittelpunkt + Radius. Schritte:
// 1) „Mittelpunkt:" → Punkt
// 2) „Radius:" → Punkt (Abstand) ODER getippte Zahl (Radius) → commit
//
// Radius akzeptiert sowohl einen Maus-Punkt (Abstand zum Mittelpunkt) als auch
// eine getippte nackte Zahl. Der Kreis wird in generatePlan als geschlossene
// Polygon-Approximation gerendert.
import type { Drawing2D } from "../../model/types";
import { uniqueId } from "../../tools/types";
import type {
Command,
CommandContext,
CommandField,
CommandResult,
CommandState,
DraftShape,
Project,
ToolDraft,
Vec2,
} from "../types";
const EPS = 1e-6;
interface CircleIdle extends CommandState {
phase: "center";
}
interface CircleDrawing extends CommandState {
phase: "radius";
center: Vec2;
cursor: Vec2 | null;
}
type CircleState = CircleIdle | CircleDrawing;
const dist = (a: Vec2, b: Vec2): number => Math.hypot(b.x - a.x, b.y - a.y);
/** Tab-Feld des Radius-Schritts: der Radius selbst. */
const CIRCLE_FIELDS: CommandField[] = [{ id: "radius", labelKey: "cmd.field.radius" }];
/**
* Punkt auf dem Kreis aus gelocktem Radius (oder Cursor-Abstand), so dass
* dist(center, point) = Radius. Die Richtung folgt dem Cursor (sonst +X).
*/
function circlePointFromFields(center: Vec2, locks: Record<string, number>, cursor: Vec2 | null): Vec2 {
const r = "radius" in locks ? Math.abs(locks.radius) : cursor ? dist(center, cursor) : 0;
if (cursor) {
const d = dist(center, cursor);
if (d > EPS) {
return { x: center.x + ((cursor.x - center.x) / d) * r, y: center.y + ((cursor.y - center.y) / d) * r };
}
}
return { x: center.x + r, y: center.y };
}
/** Punkte einer Kreis-Approximation (für die Vorschau). */
function circlePts(center: Vec2, r: number): Vec2[] {
const N = 48;
const pts: Vec2[] = [];
for (let i = 0; i < N; i++) {
const t = (i / N) * Math.PI * 2;
pts.push({ x: center.x + r * Math.cos(t), y: center.y + r * Math.sin(t) });
}
return pts;
}
/** Vorschau (Kreis) + HUD (Radius). */
function circleDraft(center: Vec2, r: number, at: Vec2 | null): ToolDraft {
if (r < EPS) return { preview: [], vertices: [center] };
const preview: DraftShape[] = [{ kind: "poly", pts: circlePts(center, r), closed: true }];
const draft: ToolDraft = { preview, vertices: [center] };
if (at) draft.hud = { at, text: `r ${r.toFixed(2)} m` };
return draft;
}
function appendCircle(p: Project, center: Vec2, r: number, ctx: CommandContext): Project {
if (r < EPS) return p;
const d: Drawing2D = {
id: uniqueId("dr2d"),
type: "drawing2d",
levelId: ctx.level.id,
categoryCode: ctx.defaultCategoryCode,
geom: { shape: "circle", center, r },
};
return { ...p, drawings2d: [...p.drawings2d, d] };
}
const idle = (): [CommandState, CommandResult] => [
{ phase: "center", lastPoint: null },
{ draft: null, done: true },
];
export const circleCommand: Command = {
name: "circle",
labelKey: "cmd.circle.label",
prompt: (s) => ((s as CircleState).phase === "radius" ? "cmd.circle.radius" : "cmd.circle.center"),
accepts: (s) => ((s as CircleState).phase === "radius" ? ["point", "number"] : ["point"]),
options: () => [],
init: (): CircleIdle => ({ phase: "center", lastPoint: null }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as CircleState;
if (s.phase !== "radius") {
if (input.kind !== "point") return [s, { draft: null }];
const ns: CircleDrawing = { phase: "radius", center: input.point, cursor: input.point, lastPoint: input.point };
return [ns, { draft: circleDraft(input.point, 0, null) }];
}
// Radius-Schritt: Zahl = Radius, Punkt = Abstand zum Mittelpunkt.
let r: number;
if (input.kind === "number") r = input.value;
else if (input.kind === "point") r = dist(s.center, input.point);
else {
const rr = s.cursor ? dist(s.center, s.cursor) : 0;
return [s, { draft: circleDraft(s.center, rr, s.cursor) }];
}
const center = s.center;
return [
{ phase: "center", lastPoint: null },
{ draft: null, done: true, commit: (p) => appendCircle(p, center, r, ctx) },
];
},
onMove: (state, point): [CommandState, CommandResult] => {
const s = state as CircleState;
if (s.phase !== "radius") return [s, { draft: null }];
const r = dist(s.center, point);
const ns: CircleDrawing = { ...s, cursor: point };
return [ns, { draft: circleDraft(s.center, r, point) }];
},
onConfirm: (): [CommandState, CommandResult] => idle(),
onCancel: (): [CommandState, CommandResult] => idle(),
// 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 : []),
pointFromFields: (state, locks, cursor) => {
const s = state as CircleState;
if (s.phase !== "radius") return null;
return circlePointFromFields(s.center, locks, cursor);
},
};
+165
View File
@@ -0,0 +1,165 @@
// Copy — wie Move, aber jeder Zielpunkt fügt eine KOPIE der Auswahl (neue IDs)
// an der Ziellage hinzu; das Original bleibt (Rhino-Copy, docs §2.10). Schritte:
// 1) „Basispunkt:" → Punkt
// 2) „Zielpunkt:" → Punkt (Tab length/angle) → Kopie + BLEIBT AKTIV
// → weitere Zielpunkte = weitere Kopien (Basispunkt bleibt), bis Esc/Enter.
//
// Die Auswahl kommt vorab über ctx.selection. Leere Auswahl → No-op + Hinweis.
// Der Commit nutzt `commitTransform` (op:"move", mode:"copy") — eine Kopie der
// Auswahl an die Endlage, Original unverändert; neue IDs via uniqueId (dort).
import { commitTransform } from "../../tools/transform";
import type { TransformSelection } from "../../tools/transform";
import { transformPreview } from "../../tools/transform";
import type {
Command,
CommandField,
CommandResult,
CommandSelection,
CommandState,
Project,
ToolDraft,
Vec2,
} from "../types";
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;
/** Tab-Felder des Zielschritts: Länge + Winkel des Kopier-Versatzes (§2.7). */
const COPY_FIELDS: CommandField[] = [
{ id: "length", labelKey: "cmd.field.length" },
{ id: "angle", labelKey: "cmd.field.angle" },
];
function targetFromFields(base: Vec2, locks: Record<string, number>, cursor: Vec2 | null): Vec2 {
const ref = cursor ?? base;
const length = "length" in locks ? locks.length : segLen(base, ref);
const angleDeg = "angle" in locks ? locks.angle : segAngleDeg(base, ref);
const ang = angleDeg * DEG;
return { x: base.x + Math.cos(ang) * length, y: base.y + Math.sin(ang) * length };
}
function hasSelection(sel: CommandSelection): boolean {
return sel.wallIds.length > 0 || sel.drawingId !== null;
}
function toTransformSel(sel: CommandSelection): TransformSelection {
return { wallIds: sel.wallIds, drawingId: sel.drawingId };
}
interface CopyBase extends CommandState {
phase: "base";
}
interface CopyTarget extends CommandState {
phase: "target";
sel: CommandSelection;
base: Vec2;
cursor: Vec2 | null;
}
interface CopyDone extends CommandState {
phase: "done";
}
type CopyState = CopyBase | CopyTarget | CopyDone;
/** Vorschau EINER Kopie der Auswahl an der Cursor-Lage. */
function copyDraft(
project: Project,
sel: CommandSelection,
base: Vec2,
target: Vec2,
): ToolDraft {
const preview = transformPreview(
project,
toTransformSel(sel),
"move",
[base, target],
"copy",
1,
);
return {
preview,
vertices: [base],
hud: {
at: target,
text: `${segLen(base, target).toFixed(2)} m · ${Math.abs(
(segAngleDeg(base, target) + 360) % 360,
).toFixed(0)}°`,
},
};
}
const finish = (): [CommandState, CommandResult] => [
{ phase: "done", lastPoint: null } as CopyDone,
{ draft: null, done: true },
];
export const copyCommand: Command = {
name: "copy",
labelKey: "cmd.copy.label",
prompt: (s) => {
const cs = s as CopyState;
if (cs.phase === "target") return "cmd.copy.target";
if (cs.phase === "done") return "cmd.copy.empty";
return "cmd.copy.base";
},
accepts: () => ["point"],
options: () => [],
init: (): CopyBase => ({ phase: "base", lastPoint: null }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as CopyState;
if (s.phase === "done") return finish();
if (!hasSelection(ctx.selection)) {
return [{ phase: "done", lastPoint: null } as CopyDone, { draft: null, done: true }];
}
if (input.kind !== "point") {
if (s.phase === "target") {
return [s, { draft: s.cursor ? copyDraft(ctx.project, s.sel, s.base, s.cursor) : null }];
}
return [s, { draft: null }];
}
const pt = input.point;
if (s.phase !== "target") {
const next: CopyTarget = {
phase: "target",
sel: ctx.selection,
base: pt,
cursor: pt,
lastPoint: pt,
};
return [next, { draft: copyDraft(ctx.project, next.sel, pt, pt) }];
}
// Zielpunkt → eine Kopie committen, ABER aktiv bleiben (wiederholend): der
// Befehl kehrt NICHT in den Ruhezustand zurück (kein done), Basispunkt
// bleibt; jeder weitere Zielpunkt = weitere Kopie, bis Esc/Enter.
const base = s.base;
const sel = s.sel;
const next: CopyTarget = { ...s, cursor: pt, lastPoint: pt };
return [
next,
{
draft: copyDraft(ctx.project, sel, base, pt),
commit: (p) => commitTransform(p, toTransformSel(sel), "move", [base, pt], "copy", 1),
},
];
},
onMove: (state, point, _snap, ctx): [CommandState, CommandResult] => {
const s = state as CopyState;
if (s.phase !== "target") return [s, { draft: null }];
const ns: CopyTarget = { ...s, cursor: point };
return [ns, { draft: copyDraft(ctx.project, s.sel, s.base, point) }];
},
// Enter/Space/Rechtsklick: Copy-Serie beenden (kein Commit hier).
onConfirm: (): [CommandState, CommandResult] => finish(),
onCancel: (): [CommandState, CommandResult] => finish(),
fields: (state) => ((state as CopyState).phase === "target" ? COPY_FIELDS : []),
pointFromFields: (state, locks, cursor) => {
const s = state as CopyState;
if (s.phase !== "target") return null;
return targetFromFields(s.base, locks, cursor);
},
};
+37
View File
@@ -0,0 +1,37 @@
// Import — öffnet den DXF-Datei-Dialog (in App montiert) und beendet sich
// sofort. Der eigentliche Import (Datei lesen → parseDxf → addContextObjects)
// läuft asynchron über das App-onChange des versteckten <input type=file>; ein
// Command kann den Datei-Dialog nicht selbst „committen" (er ist DOM-/async-
// seitig). Daher koppelt dieser Befehl nur den Trigger an (über den Modul-Hook
// dxfImportHook) und gibt keine Mutation zurück.
//
// Bezeichner englisch, sichtbarer Text via t() (Namespace `cmd.import.*`).
import { openDxfImport } from "../../io/dxfImportHook";
import type { Command, CommandResult, CommandState } from "../types";
const IDLE: CommandState = { phase: "idle", lastPoint: null };
/** Öffnet den Datei-Dialog und beendet den Befehl sofort (keine Mutation). */
function open(): [CommandState, CommandResult] {
openDxfImport();
return [{ ...IDLE }, { draft: null, done: true }];
}
export const importCommand: Command = {
name: "import",
labelKey: "cmd.import.label",
prompt: () => "cmd.import.prompt",
accepts: () => [],
options: () => [],
init: () => ({ ...IDLE }),
// Der Befehl hat keine Schritte: Start/Enter/Input öffnet den Dialog + endet.
onInput: () => open(),
onMove: (state): [CommandState, CommandResult] => [state, { draft: null }],
onConfirm: () => open(),
onCancel: (): [CommandState, CommandResult] => [
{ ...IDLE },
{ draft: null, done: true },
],
};
+140
View File
@@ -0,0 +1,140 @@
// Line — erster Befehl der Command-Engine (portiert lineTool aus
// src/tools/tools.ts). Zwei Punkte → Drawing2D{shape:"line"}.
//
// Schritte:
// 1) „Start of line:" → Punkt (Klick ODER getippt: 0,0 / r3,0 / 3<45)
// 2) „End of line:" → Punkt → commit Drawing2D line → done
//
// Akzeptiert in JEDEM Punkt-Schritt sowohl Maus-Picks (gesnappt) als auch
// getippte Koordinaten — das ist die Kern-Vereinheitlichung gegenüber dem alten
// Tool. Live-Vorschau + HUD (Länge/Winkel) wie die bestehenden Tools.
import type { Drawing2D } from "../../model/types";
import { uniqueId } from "../../tools/types";
import type {
Command,
CommandContext,
CommandField,
CommandResult,
CommandState,
DraftShape,
ToolDraft,
Vec2,
} from "../types";
const EPS = 1e-6;
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;
/** Tab-Felder des End-Schritts: erst Länge, dann Winkel (§2.7). */
const LINE_FIELDS: CommandField[] = [
{ id: "length", labelKey: "cmd.field.length" },
{ id: "angle", labelKey: "cmd.field.angle" },
];
/**
* Endpunkt aus gelockten Feldern (length/angle) + Cursor: ungelockte Werte
* folgen dem Cursor relativ zum Startpunkt `a`.
*/
function lineEndFromFields(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 };
}
interface LineIdle extends CommandState {
phase: "start";
}
interface LineDrawing extends CommandState {
phase: "end";
a: Vec2;
cursor: Vec2 | null;
}
type LineState = LineIdle | LineDrawing;
/** Vorschau (Rubber-Band) + HUD (Länge·Winkel) für das lebende Segment. */
function lineDraft(a: Vec2, cursor: Vec2 | null): ToolDraft {
const preview: DraftShape[] = [];
if (cursor && segLen(a, cursor) >= EPS) preview.push({ kind: "line", a, b: cursor });
const draft: ToolDraft = { preview, vertices: [a] };
if (cursor && 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;
}
/** Hängt eine 2D-Linie ans Projekt (immutabel). Farbe/Strich erbt die Kategorie. */
function appendLine(p: import("../types").Project, a: Vec2, b: Vec2, ctx: CommandContext) {
const d: Drawing2D = {
id: uniqueId("dr2d"),
type: "drawing2d",
levelId: ctx.level.id,
categoryCode: ctx.defaultCategoryCode,
geom: { shape: "line", a, b },
};
return { ...p, drawings2d: [...p.drawings2d, d] };
}
export const lineCommand: Command = {
name: "line",
labelKey: "cmd.line.label",
prompt: (s) => ((s as LineState).phase === "end" ? "cmd.line.end" : "cmd.line.start"),
accepts: () => ["point", "number"],
options: () => [],
init: (): LineIdle => ({ phase: "start", lastPoint: null }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as LineState;
// Beide Punkt-Schritte erwarten einen aufgelösten Punkt; die Engine reicht
// getippte Koordinaten bereits als {kind:"point"} herein.
if (input.kind !== "point") return [s, { draft: s.phase === "end" ? lineDraft(s.a, s.cursor) : null }];
const pt = input.point;
if (s.phase !== "end") {
const next: LineDrawing = { phase: "end", a: pt, cursor: pt, lastPoint: pt };
return [next, { draft: lineDraft(pt, pt) }];
}
// Zweiter Punkt: Null-Strecke verwerfen, sonst committen.
if (segLen(s.a, pt) < EPS) {
return [{ phase: "start", lastPoint: null }, { draft: null, done: true }];
}
const a = s.a;
return [
{ phase: "start", lastPoint: null },
{ draft: null, done: true, commit: (p) => appendLine(p, a, pt, ctx) },
];
},
onMove: (state, point): [CommandState, CommandResult] => {
const s = state as LineState;
if (s.phase !== "end") return [s, { draft: null }];
const next: LineDrawing = { ...s, cursor: point };
return [next, { draft: lineDraft(s.a, point) }];
},
// Line braucht genau zwei Punkte; Enter/Rechtsklick im offenen Entwurf bricht ab.
onConfirm: (): [CommandState, CommandResult] => [
{ phase: "start", lastPoint: null },
{ draft: null, done: true },
],
onCancel: (): [CommandState, CommandResult] => [
{ phase: "start", lastPoint: null },
{ draft: null, done: true },
],
// Tab-Feld-Zyklus nur im End-Schritt: Länge + Winkel relativ zum Startpunkt.
fields: (state) => ((state as LineState).phase === "end" ? LINE_FIELDS : []),
pointFromFields: (state, locks, cursor) => {
const s = state as LineState;
if (s.phase !== "end") return null;
return lineEndFromFields(s.a, locks, cursor);
},
};
+181
View File
@@ -0,0 +1,181 @@
// Move — verschiebt die AKTUELLE Auswahl (Wände + ein Drawing2D) um
// delta = Ziel − Basis (Rhino-Move, docs §2.10/§3.4 Tier-1). Schritte:
// 1) „Basispunkt:" → Punkt
// 2) „Zielpunkt:" → Punkt (mit Tab-Feldern length/angle wie Line) → commit
//
// Die Auswahl kommt vorab über ctx.selection (erst selektieren, dann Befehl).
// Ist sie leer, ist nichts zu verschieben: kurzer No-op mit Hinweis-Prompt
// („Nichts gewählt") und Beenden — das ist der einfache, robuste Weg (statt
// einen zweiten, divergierenden Pick-Pfad für die Objektwahl aufzumachen; die
// Selektion läuft schon sauber über die Plan-Ansicht/Store).
//
// Der Commit nutzt den bestehenden, getesteten `commitTransform` (op:"move",
// mode:"move") statt eine zweite Verschiebe-Logik zu duplizieren — er bewegt
// Wände UND alle 2D-Formen immutabel an die Endlage.
import { commitTransform } from "../../tools/transform";
import type { TransformSelection } from "../../tools/transform";
import { transformPreview } from "../../tools/transform";
import type {
Command,
CommandField,
CommandResult,
CommandSelection,
CommandState,
Project,
ToolDraft,
Vec2,
} from "../types";
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;
/** Tab-Felder des Zielschritts: Länge + Winkel des Verschiebevektors (§2.7). */
const MOVE_FIELDS: CommandField[] = [
{ id: "length", labelKey: "cmd.field.length" },
{ id: "angle", labelKey: "cmd.field.angle" },
];
/** Zielpunkt aus gelockten Feldern (length/angle) + Cursor, relativ zum Basispunkt. */
function targetFromFields(base: Vec2, locks: Record<string, number>, cursor: Vec2 | null): Vec2 {
const ref = cursor ?? base;
const length = "length" in locks ? locks.length : segLen(base, ref);
const angleDeg = "angle" in locks ? locks.angle : segAngleDeg(base, ref);
const ang = angleDeg * DEG;
return { x: base.x + Math.cos(ang) * length, y: base.y + Math.sin(ang) * length };
}
/** Hat die Auswahl überhaupt etwas Verschiebbares? */
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 Basispunkt setzen, dann Ziel (mit Auswahl-Snapshot vom Start).
interface MoveBase extends CommandState {
phase: "base";
}
interface MoveTarget extends CommandState {
phase: "target";
sel: CommandSelection;
base: Vec2;
cursor: Vec2 | null;
}
// „done": leere Auswahl → No-op, Befehl beendet.
interface MoveDone extends CommandState {
phase: "done";
}
type MoveState = MoveBase | MoveTarget | MoveDone;
/** Live-Vorschau der Auswahl an der aktuellen Cursor-Lage (Move). */
function moveDraft(
project: Project,
sel: CommandSelection,
base: Vec2,
target: Vec2,
): ToolDraft {
const preview = transformPreview(
project,
toTransformSel(sel),
"move",
[base, target],
"move",
1,
);
return {
preview,
vertices: [base],
hud: {
at: target,
text: `${segLen(base, target).toFixed(2)} m · ${Math.abs(
(segAngleDeg(base, target) + 360) % 360,
).toFixed(0)}°`,
},
};
}
const idle = (): [CommandState, CommandResult] => [
{ phase: "done", lastPoint: null } as MoveDone,
{ draft: null, done: true },
];
export const moveCommand: Command = {
name: "move",
labelKey: "cmd.move.label",
prompt: (s) => {
const ms = s as MoveState;
if (ms.phase === "target") return "cmd.move.target";
if (ms.phase === "done") return "cmd.move.empty";
return "cmd.move.base";
},
accepts: () => ["point"],
options: () => [],
// Beim Start die Auswahl aus dem Kontext einfrieren — dafür braucht init()
// den Kontext nicht, das macht onInput beim ersten Aufruf; bis dahin „base".
// Damit die leere Auswahl SOFORT (vor dem ersten Klick) auffällt, prüfen wir
// sie zusätzlich im ersten onMove/onInput.
init: (): MoveBase => ({ phase: "base", lastPoint: null }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as MoveState;
if (s.phase === "done") return idle();
// Leere Auswahl → nichts zu verschieben (No-op + Hinweis, dann beenden).
if (!hasSelection(ctx.selection)) {
return [{ phase: "done", lastPoint: null } as MoveDone, { draft: null, done: true }];
}
if (input.kind !== "point") {
if (s.phase === "target") {
return [s, { draft: s.cursor ? moveDraft(ctx.project, s.sel, s.base, s.cursor) : null }];
}
return [s, { draft: null }];
}
const pt = input.point;
if (s.phase !== "target") {
// Basispunkt gesetzt → Auswahl-Snapshot mitführen.
const next: MoveTarget = {
phase: "target",
sel: ctx.selection,
base: pt,
cursor: pt,
lastPoint: pt,
};
return [next, { draft: moveDraft(ctx.project, next.sel, pt, pt) }];
}
// Zielpunkt → commit (Auswahl an die Endlage verschieben).
const base = s.base;
const sel = s.sel;
return [
{ phase: "done", lastPoint: null } as MoveDone,
{
draft: null,
done: true,
commit: (p) => commitTransform(p, toTransformSel(sel), "move", [base, pt], "move", 1),
},
];
},
onMove: (state, point, _snap, ctx): [CommandState, CommandResult] => {
const s = state as MoveState;
if (s.phase !== "target") return [s, { draft: null }];
const ns: MoveTarget = { ...s, cursor: point };
return [ns, { draft: moveDraft(ctx.project, s.sel, s.base, point) }];
},
onConfirm: (): [CommandState, CommandResult] => idle(),
onCancel: (): [CommandState, CommandResult] => idle(),
// Tab-Feld-Zyklus nur im Zielschritt: Länge + Winkel relativ zum Basispunkt.
fields: (state) => ((state as MoveState).phase === "target" ? MOVE_FIELDS : []),
pointFromFields: (state, locks, cursor) => {
const s = state as MoveState;
if (s.phase !== "target") return null;
return targetFromFields(s.base, locks, cursor);
},
};
+339
View File
@@ -0,0 +1,339 @@
// Offset — DAS Architektur-Primitiv (docs §2.10/§3.4 Tier-1, §3.6 persistente
// Distanz). Erzeugt eine parallel versetzte Kopie einer 2D-Kurve (line/polyline/
// rect). Schritte:
// 1) Kurve bestimmen: ist genau EIN passendes Drawing2D (line/polyline/rect)
// selektiert, wird es genommen; sonst „Kurve wählen:" → Klick auf eine Kurve.
// 2) „Seite/Distanz:" → Distanz tippen (PERSISTENT als Default über Aufrufe,
// modulweite Variable) ODER Seite per Klick (Vorzeichen aus der Klickseite
// relativ zur Kurve). → commit neue versetzte Drawing2D-Kurve.
//
// Distanz-Vorzeichen: Konvention `+d = links` (kernel2d/leftNormal). Die
// Klickseite bestimmt links/rechts: liegt der Klick links der Kurve (bzw. der
// nächsten Kante), wird +|d| genommen, sonst −|d|. Eine getippte Zahl nutzt das
// Vorzeichen der zuletzt gewählten Seite (Default links/+), bzw. ihr eigenes
// Vorzeichen, wenn negativ getippt.
import type { Drawing2D, Drawing2DGeom } from "../../model/types";
import { uniqueId } from "../../tools/types";
import { leftNormal, normalize, sub } from "../../model/geometry";
import {
offsetPolyline,
offsetSegment,
pointSegmentDistance,
polylineEdges,
} from "../../geometry/kernel2d";
import type {
CmdInput,
Command,
CommandContext,
CommandResult,
CommandSelection,
CommandState,
DraftShape,
Project,
Vec2,
} from "../types";
const EPS = 1e-6;
const HIT_TOL = 0.25; // Modell-Meter: Pick-Toleranz für die Kurvenwahl
/**
* Persistente Offset-Distanz über Aufrufe hinweg (§2.4/§3.6 — Nutzer erwarten,
* dass die letzte Distanz als Default wiederkommt). Modulweite Variable, |d|;
* das Vorzeichen (Seite) wird je Offset frisch aus Klick/Eingabe bestimmt.
*/
let lastDistance = 0.5;
/** Zuletzt gewählte Seite als Vorzeichen (+1 = links, −1 = rechts). */
let lastSign = 1;
// ── Kurven-Abstraktion: line/polyline/rect → Punktliste + closed ──────────────
interface Curve {
pts: Vec2[];
closed: boolean;
}
/** Wandelt eine offset-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") {
// Rechteck als geschlossene 4-Punkt-Polylinie offsetten (Task).
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 offset-fähig
}
/** Ist diese Form offset-fähig (line/polyline/rect)? */
function isOffsetable(d: Drawing2D): boolean {
return d.geom.shape === "line" || d.geom.shape === "polyline" || d.geom.shape === "rect";
}
/** Genau ein passendes Drawing2D vorselektiert? Dann liefere es. */
function singleSelectedCurve(project: Project, sel: CommandSelection): Drawing2D | null {
if (!sel.drawingId || sel.wallIds.length > 0) return null;
const d = project.drawings2d.find((x) => x.id === sel.drawingId);
if (d && isOffsetable(d)) return d;
return null;
}
/** Nächstes offset-fähiges Drawing2D 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;
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;
}
/**
* Vorzeichen (+links / −rechts) für einen Klickpunkt relativ zur Kurve: die
* nächste Kante bestimmen, dann das Vorzeichen der Skalarprojektion von
* (klick − Kantenmitte) auf die LINKS-Normale der Kante. So zeigt das Resultat
* auf die angeklickte Seite (Konvention `+d = links`).
*/
function sideSign(curve: Curve, pt: Vec2): number {
let bestD = Infinity;
let sign = lastSign;
for (const [a, b] of polylineEdges(curve.pts, curve.closed)) {
const mid: Vec2 = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
const dd = pointSegmentDistance(pt, a, b);
if (dd < bestD) {
bestD = dd;
const u = normalize(sub(b, a));
const n = leftNormal(u);
const rel = sub(pt, mid);
const s = rel.x * n.x + rel.y * n.y;
sign = s >= 0 ? 1 : -1;
}
}
return sign;
}
/** Offset einer Kurve um die vorzeichenbehaftete Distanz `d` → neue Punktliste. */
function offsetCurve(curve: Curve, d: number): Vec2[] {
if (!curve.closed && curve.pts.length === 2) {
const [a, b] = offsetSegment(curve.pts[0], curve.pts[1], d);
return [a, b];
}
return offsetPolyline(curve.pts, d, curve.closed);
}
/** Versetzte Form als neues Drawing2D (gleiche Attribute, neue ID/Geometrie). */
function offsetGeom(src: Drawing2D, d: number): Drawing2DGeom | null {
const g = src.geom;
if (g.shape === "line") {
const [a, b] = offsetSegment(g.a, g.b, d);
return { shape: "line", a, b };
}
if (g.shape === "polyline") {
return { shape: "polyline", pts: offsetPolyline(g.pts, d, g.closed), closed: g.closed };
}
if (g.shape === "rect") {
const curve = geomToCurve(g)!;
return { shape: "polyline", pts: offsetPolyline(curve.pts, d, true), closed: true };
}
return null;
}
/** Vorschau-Formen der versetzten Kurve (für den Draft). */
function offsetDraftShapes(curve: Curve, d: number): DraftShape[] {
const pts = offsetCurve(curve, d);
if (pts.length < 2) return [];
if (!curve.closed && pts.length === 2) return [{ kind: "line", a: pts[0], b: pts[1] }];
return [{ kind: "poly", pts, closed: curve.closed }];
}
function appendOffset(p: Project, src: Drawing2D, d: number): Project {
const geom = offsetGeom(src, d);
if (!geom) return p;
const copy: Drawing2D = { ...src, id: uniqueId("dr2d"), geom };
return { ...p, drawings2d: [...p.drawings2d, copy] };
}
// ── Zustand ───────────────────────────────────────────────────────────────────
// „pick": Kurve wählen (keine passende Vorselektion). „side": Kurve steht fest,
// jetzt Seite/Distanz. „done": Befehl beendet.
interface OffPick extends CommandState {
phase: "pick";
}
interface OffSide extends CommandState {
phase: "side";
drawingId: string;
cursor: Vec2 | null;
}
interface OffDone extends CommandState {
phase: "done";
}
type OffState = OffPick | OffSide | OffDone;
const finish = (): [CommandState, CommandResult] => [
{ phase: "done", lastPoint: null } as OffDone,
{ draft: null, done: true },
];
/** Aktuelle Kurve aus dem Side-Zustand (oder null, wenn weg). */
function curveOf(project: Project, drawingId: string): { src: Drawing2D; curve: Curve } | null {
const src = project.drawings2d.find((x) => x.id === drawingId);
if (!src) return null;
const curve = geomToCurve(src.geom);
if (!curve) return null;
return { src, curve };
}
export const offsetCommand: Command = {
name: "offset",
labelKey: "cmd.offset.label",
prompt: (s) => {
const os = s as OffState;
if (os.phase === "side") return "cmd.offset.side";
return "cmd.offset.pick";
},
// Side-Schritt: Punkt (Seite klicken) ODER Zahl (Distanz tippen). Auch der
// „pick"-Schritt nimmt eine Zahl an, damit eine getippte Distanz sofort greift,
// wenn eine Kurve vorselektiert ist (dann wird „pick" live wie „side" behandelt).
accepts: () => ["point", "number"],
options: () => [],
// init() kennt den Kontext nicht; die Vorselektion wird im ersten onMove/
// onInput ausgewertet (geht direkt auf „side", wenn genau eine Kurve gewählt
// ist). Bis dahin „pick".
init: (): OffPick => ({ phase: "pick", lastPoint: null }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as OffState;
if (s.phase === "done") return finish();
// Vorselektion: gibt es genau eine passende Kurve, direkt in den Side-Schritt
// springen (auch wenn die Engine erst onInput aufruft).
if (s.phase === "pick") {
const pre = singleSelectedCurve(ctx.project, ctx.selection);
if (pre) {
// Eingabe im selben Schritt direkt als Side/Distanz behandeln.
return offsetSideInput(
{ phase: "side", drawingId: pre.id, cursor: null, lastPoint: null },
input,
ctx,
);
}
// Keine Vorselektion → Kurve per Klick wählen.
if (input.kind === "point") {
const hit = pickCurveAt(ctx.project, ctx.level.id, input.point);
if (!hit) return [s, { draft: null }];
const ns: OffSide = { phase: "side", drawingId: hit.id, cursor: null, lastPoint: null };
return [ns, { draft: null }];
}
return [s, { draft: null }];
}
// phase === "side"
return offsetSideInput(s as OffSide, input, ctx);
},
onMove: (state, point, _snap, ctx): [CommandState, CommandResult] => {
const s = state as OffState;
// Vorselektion live: in „pick" mit genau einer Kurve → als Side behandeln.
if (s.phase === "pick") {
const pre = singleSelectedCurve(ctx.project, ctx.selection);
if (pre) {
return offsetSideMove({ phase: "side", drawingId: pre.id, cursor: point, lastPoint: null }, point, ctx);
}
return [s, { draft: null }];
}
if (s.phase === "side") return offsetSideMove(s as OffSide, point, ctx);
return [s, { draft: null }];
},
onConfirm: (): [CommandState, CommandResult] => finish(),
onCancel: (): [CommandState, CommandResult] => finish(),
// KEIN Tab-Feld-Zyklus: die Offset-Distanz ist ein reiner Skalar, der als
// `number`-Eingabe in den Schritt läuft (nicht als feld-erzeugter Punkt). So
// committet eine getippte Distanz direkt; die Seite kommt per Klick/Vorzeichen.
};
// ── Side-Schritt-Logik (für Vorselektion UND echten pick wiederverwendet) ─────
/** Eingabe im Side-Schritt: Punkt (Seite klicken) ODER Zahl (Distanz tippen). */
function offsetSideInput(
s: OffSide,
input: CmdInput,
ctx: CommandContext,
): [CommandState, CommandResult] {
const cc = curveOf(ctx.project, s.drawingId);
if (!cc) return finish();
const { src, curve } = cc;
if (input.kind === "number") {
// Getippte Distanz: Betrag merken; Vorzeichen aus der zuletzt gewählten
// Seite (bzw. dem getippten Vorzeichen, falls negativ).
const typed = input.value;
const mag = Math.abs(typed);
if (mag < EPS) return [s, { draft: null }];
lastDistance = mag;
const sign = typed < 0 ? -1 : lastSign;
lastSign = sign;
const d = sign * mag;
return [
{ phase: "done", lastPoint: null } as OffDone,
{ draft: null, done: true, commit: (p) => appendOffset(p, src, d) },
];
}
if (input.kind === "point") {
// Seite per Klick: Vorzeichen aus der Klickseite, Betrag = letzte Distanz.
const sign = sideSign(curve, input.point);
lastSign = sign;
const d = sign * lastDistance;
return [
{ phase: "done", lastPoint: null } as OffDone,
{ draft: null, done: true, commit: (p) => appendOffset(p, src, d) },
];
}
return [s, { draft: null }];
}
/** Hover im Side-Schritt: Vorschau der versetzten Kurve an der Cursor-Seite. */
function offsetSideMove(
s: OffSide,
point: Vec2,
ctx: CommandContext,
): [CommandState, CommandResult] {
const cc = curveOf(ctx.project, s.drawingId);
if (!cc) return [s, { draft: null }];
const { curve } = cc;
const sign = sideSign(curve, point);
const d = sign * lastDistance;
const preview = offsetDraftShapes(curve, d);
const ns: OffSide = { ...s, cursor: point };
return [
ns,
{
draft: {
preview,
vertices: [],
hud: { at: point, text: `${lastDistance.toFixed(2)} m` },
},
},
];
}
+181
View File
@@ -0,0 +1,181 @@
// Polyline — mehrteiliger Zug (portiert polylineTool). Schritte:
// 1) „Startpunkt:" → Punkt
// 2) „Nächster Punkt ( Schliessen Zurück ):" → Punkt … (Enter beendet offen,
// „Schliessen" schließt; Klick auf den Startpunkt schließt ebenfalls)
//
// Akzeptiert je Punkt Maus-Picks UND getippte Koordinaten (0,0 · r3,0 · 5<45).
import type { Drawing2D } from "../../model/types";
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 Zug
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 POLY_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 polyNextFromFields(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 };
}
interface PolyIdle extends CommandState {
phase: "start";
}
interface PolyDrawing extends CommandState {
phase: "next";
points: Vec2[];
cursor: Vec2 | null;
}
type PolyState = PolyIdle | PolyDrawing;
const CLOSE: CmdOption = { id: "close", labelKey: "cmd.polyline.close" };
const UNDO: CmdOption = { id: "undo", labelKey: "cmd.polyline.undo" };
/** Vorschau (Zug + Gummiband zum Cursor) + HUD (Länge·Winkel des letzten Segments). */
function polyDraft(points: Vec2[], cursor: Vec2 | null): ToolDraft {
const pts = cursor ? [...points, cursor] : points;
const preview: DraftShape[] = [{ kind: "poly", pts, closed: false }];
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 2D-Polylinie ans Projekt (immutabel). Farbe/Strich erbt die Kategorie. */
function appendPolyline(
p: Project,
pts: Vec2[],
closed: boolean,
ctx: CommandContext,
): Project {
const d: Drawing2D = {
id: uniqueId("dr2d"),
type: "drawing2d",
levelId: ctx.level.id,
categoryCode: ctx.defaultCategoryCode,
geom: { shape: "polyline", pts, closed },
};
return { ...p, drawings2d: [...p.drawings2d, d] };
}
const idle = (): [CommandState, CommandResult] => [
{ phase: "start", lastPoint: null },
{ draft: null, done: true },
];
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"],
options: (s) => {
const ps = s as PolyState;
if (ps.phase !== "next") return [];
return ps.points.length >= 2 ? [CLOSE, UNDO] : [UNDO];
},
init: (): PolyIdle => ({ phase: "start", lastPoint: null }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as PolyState;
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 }, { 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 (input.id === "close" && s.points.length >= 3) {
const pts = s.points;
return [
{ phase: "start", lastPoint: null },
{ draft: null, done: true, commit: (p) => appendPolyline(p, pts, true, ctx) },
];
}
return [s, { draft: polyDraft(s.points, s.cursor) }];
}
if (input.kind !== "point") {
return [s, { draft: s.phase === "next" ? polyDraft(s.points, s.cursor) : 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) }];
}
// Klick nahe Startpunkt → schließen.
if (s.points.length >= 3 && segLen(s.points[0], pt) < CLOSE_HIT) {
const pts = s.points;
return [
{ phase: "start", lastPoint: null },
{ 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) }];
},
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) }];
},
// Enter/Space/Rechtsklick: offenen Zug beenden (≥2 Punkte) und committen.
onConfirm: (state, ctx): [CommandState, CommandResult] => {
const s = state as PolyState;
if (s.phase === "next" && s.points.length >= 2) {
const pts = s.points;
return [
{ phase: "start", lastPoint: null },
{ draft: null, done: true, commit: (p) => appendPolyline(p, pts, false, ctx) },
];
}
return idle();
},
onCancel: (): [CommandState, CommandResult] => idle(),
// Tab-Feld-Zyklus nur im „next"-Schritt: Länge + Winkel relativ zum letzten
// Punkt. (Erster Punkt = freie Koordinate, kein Feld-Modus.)
fields: (state) => ((state as PolyState).phase === "next" ? POLY_FIELDS : []),
pointFromFields: (state, locks, cursor) => {
const s = state as PolyState;
if (s.phase !== "next" || s.points.length === 0) return null;
return polyNextFromFields(s.points[s.points.length - 1], locks, cursor);
},
};
+143
View File
@@ -0,0 +1,143 @@
// Rectangle — zwei Ecken (portiert rectTool). Schritte:
// 1) „Erste Ecke:" → Punkt
// 2) „Gegenüberliegende Ecke:" → Punkt → commit Drawing2D rect → done
//
// 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).
import type { Drawing2D } from "../../model/types";
import { uniqueId } from "../../tools/types";
import type {
Command,
CommandContext,
CommandField,
CommandResult,
CommandState,
DraftShape,
Project,
ToolDraft,
Vec2,
} from "../types";
const EPS = 1e-6;
/** Tab-Felder des zweiten-Ecke-Schritts: Breite, Höhe. */
const RECT_FIELDS: CommandField[] = [
{ id: "width", labelKey: "cmd.field.width" },
{ id: "height", labelKey: "cmd.field.height" },
];
/**
* Gegenüberliegende Ecke aus gelockten Feldern (width/height) + Cursor: eine
* gelockte Kantenlänge nimmt ihren Betrag, das Vorzeichen (Quadrant) folgt dem
* Cursor relativ zur ersten Ecke `a`; ungelockt folgt die Kante direkt dem Cursor.
*/
function rectCornerFromFields(a: Vec2, locks: Record<string, number>, cursor: Vec2 | null): Vec2 {
const cur = cursor ?? a;
const dx = cur.x - a.x;
const dy = cur.y - a.y;
const sx = dx < 0 ? -1 : 1;
const sy = dy < 0 ? -1 : 1;
const w = "width" in locks ? sx * Math.abs(locks.width) : dx;
const h = "height" in locks ? sy * Math.abs(locks.height) : dy;
return { x: a.x + w, y: a.y + h };
}
interface RectIdle extends CommandState {
phase: "corner1";
}
interface RectDrawing extends CommandState {
phase: "corner2";
a: Vec2;
cursor: Vec2 | null;
}
type RectState = RectIdle | RectDrawing;
function rectGeom(a: Vec2, b: Vec2): { min: Vec2; max: Vec2 } {
return {
min: { x: Math.min(a.x, b.x), y: Math.min(a.y, b.y) },
max: { x: Math.max(a.x, b.x), y: Math.max(a.y, b.y) },
};
}
/** 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 },
];
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` },
};
}
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
const d: Drawing2D = {
id: uniqueId("dr2d"),
type: "drawing2d",
levelId: ctx.level.id,
categoryCode: ctx.defaultCategoryCode,
geom: { shape: "rect", min: g.min, max: g.max },
};
return { ...p, drawings2d: [...p.drawings2d, d] };
}
const idle = (): [CommandState, CommandResult] => [
{ phase: "corner1", lastPoint: null },
{ draft: null, done: true },
];
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 }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as RectState;
if (input.kind !== "point") {
return [s, { draft: s.phase === "corner2" ? rectDraft(s.a, s.cursor) : null }];
}
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) }];
}
const a = s.a;
return [
{ phase: "corner1", lastPoint: null },
{ draft: null, done: true, commit: (p) => appendRect(p, a, 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) }];
},
onConfirm: (): [CommandState, CommandResult] => idle(),
onCancel: (): [CommandState, CommandResult] => idle(),
// Tab-Feld-Zyklus nur im „corner2"-Schritt: Breite + Höhe (signiert nach Cursor).
fields: (state) => ((state as RectState).phase === "corner2" ? RECT_FIELDS : []),
pointFromFields: (state, locks, cursor) => {
const s = state as RectState;
if (s.phase !== "corner2") return null;
return rectCornerFromFields(s.a, locks, cursor);
},
};
+60
View File
@@ -0,0 +1,60 @@
// Terrain — erzeugt aus dem (ersten) ContourSet der Kontext-Schicht ein
// Gelände-TIN und hängt es an `project.context`. Null-Schritt-Befehl: er
// committet sofort beim Start (über onConfirm, das die Engine direkt aufruft,
// wenn keine Punkte erwartet werden) — wir lösen das robust über onInput/
// onConfirm, die beide dieselbe Mutation liefern.
//
// Die Mutation ist rein (kein Store/React): sie sucht den ersten ContourSet in
// p.context, ruft generateTerrainFromContours und fügt das TIN hinzu. Liefert
// das TIN kein sinnvolles Mesh (zu wenige/kollineare Punkte), bleibt p
// unverändert.
//
// Bezeichner englisch, sichtbarer Text via t() (Namespace `cmd.terrain.*`).
import { generateTerrainFromContours } from "../../model/terrain";
import type { ContextObject } from "../../model/types";
import type {
Command,
CommandResult,
CommandState,
Project,
} from "../types";
/** Hängt ein aus dem ersten ContourSet abgeleitetes TIN an (immutabel). */
function appendTerrain(p: Project): Project {
const context: ContextObject[] = p.context ?? [];
const set = context.find((o) => o.type === "contourSet");
if (!set || set.type !== "contourSet") return p;
const terrain = generateTerrainFromContours(set.contours);
if (terrain.indices.length === 0) return p; // kein sinnvolles TIN
return { ...p, context: [...context, terrain] };
}
/** Ergebnis, das den Befehl sofort beendet und das TIN committet. */
const COMMIT_DONE: CommandResult = {
draft: null,
done: true,
commit: appendTerrain,
};
const IDLE: CommandState = { phase: "idle", lastPoint: null };
export const terrainCommand: Command = {
name: "terrain",
labelKey: "cmd.terrain.label",
prompt: () => "cmd.terrain.prompt",
// Erwartet keine echten Eingaben; Enter/Start committet sofort.
accepts: () => [],
options: () => [],
init: () => ({ ...IDLE }),
// Jeglicher Input beendet den Befehl mit dem Commit (es gibt keine Schritte).
onInput: (): [CommandState, CommandResult] => [{ ...IDLE }, COMMIT_DONE],
onMove: (state): [CommandState, CommandResult] => [state, { draft: null }],
// Enter/Space/Rechtsklick: Gelände erzeugen + beenden.
onConfirm: (): [CommandState, CommandResult] => [{ ...IDLE }, COMMIT_DONE],
onCancel: (): [CommandState, CommandResult] => [
{ ...IDLE },
{ draft: null, done: true },
],
};
+439
View File
@@ -0,0 +1,439 @@
// Command-Runner / State-Machine (docs/design/rhino-command-system.md §3.2, §2.3,
// §2.6). Hält den aktiven Befehl + Schritt-Zustand, einen Prompt-Stack-Grundbau
// (für künftige transparente Befehle §2.6) und `lastCommand` (Leer-Enter bzw.
// Rechtsklick wiederholt den letzten Befehl §2.3).
//
// Eingaben kommen aus DREI Quellen, die alle in DENSELBEN Schritt fließen:
// • getippte Command-Line-Eingabe (submitText)
// • Maus-Pick aus PlanView (pick) / Hover (move)
// • Klick auf eine Inline-Option (chooseOption)
//
// Die Engine ist UI-agnostisch: Kontext (Projekt/Level/Kategorie), Snapping und
// das Anwenden von draft/commit liefert ein injizierter `EngineHost`. Bei jeder
// Zustandsänderung ruft sie `emit()` → React-Komponenten (CommandLine, App)
// abonnieren via subscribe() und re-rendern.
import type { Project, Vec2 } from "../model/types";
import type { SnapResult, ToolDraft } from "../tools/types";
import { parseInput, resolvePoint } from "./parseInput";
import { getCommand, resolveCommand, autocomplete } from "./registry";
import type {
Command,
CommandContext,
CommandResult,
CommandState,
CmdInput,
} from "./types";
/**
* 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.
*/
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;
/** Aktuelle Pixel/Meter-Skala (für Snap-Toleranz). */
pxPerMeter(): number;
/** Vorschau setzen (oder löschen). */
setDraft(draft: ToolDraft | null): void;
/** Commit immutabel anwenden. */
commit(mutate: (p: Project) => Project): void;
/** Ist die aktive Ebene ein Geschoss? (für floorOnly-Befehle). */
isFloor(): boolean;
}
/** Öffentlicher Schnappschuss für die Command-Line-UI (rein lesend). */
export interface EngineView {
/** Läuft gerade ein Befehl? */
active: boolean;
/** Aktueller Prompt-Key (i18n) oder null (Ruhezustand). */
promptKey: string | null;
/** Inline-Optionen des aktuellen Schritts. */
options: { id: string; labelKey: string; value?: string }[];
/** Name des aktiven Befehls (für Anzeige), null im Ruhezustand. */
commandName: string | null;
/**
* 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.
*/
fields: { id: string; labelKey: string; value: number | null; active: boolean }[];
}
export class CommandEngine {
private host: EngineHost;
private command: Command | null = null;
private state: CommandState | null = null;
private lastCommandName: string | null = null;
/** Letzter (gesnappter) Cursor-Punkt für Distanz-/Winkel-Lock-Auflösung. */
private lastCursor: Vec2 | null = null;
/** Tab-Feld-Zyklus: aktiver Feld-Index des aktuellen Schritts (§2.7). */
private activeFieldIndex = 0;
/** Tab-Feld-Zyklus: gelockte Feldwerte (id → Zahl); ungelockt folgt der Maus. */
private locks: Record<string, number> = {};
/** Identität des Schritts, für den der Feld-Zustand gilt (Reset bei Wechsel). */
private fieldPhaseKey: string | null = null;
private listeners = new Set<() => void>();
constructor(host: EngineHost) {
this.host = host;
}
// ── Abo / Snapshot ─────────────────────────────────────────────────────────
subscribe(fn: () => void): () => void {
this.listeners.add(fn);
return () => this.listeners.delete(fn);
}
private emit(): void {
for (const l of this.listeners) l();
}
/** Läuft gerade ein Befehl? */
isActive(): boolean {
return this.command !== null;
}
/** Lesender Schnappschuss für die Command-Line. */
view(): EngineView {
if (!this.command || !this.state) {
return { active: false, promptKey: null, options: [], commandName: null, fields: [] };
}
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,
}));
return {
active: true,
promptKey: this.command.prompt(this.state),
options: this.command.options(this.state),
commandName: this.command.name,
fields,
};
}
/** Autocomplete-Kandidaten für die getippte Eingabe (Präfix). */
suggest(prefix: string): string[] {
return autocomplete(prefix);
}
// ── Tab-Feld-Zyklus (§2.7) ────────────────────────────────────────────────
/** Feld-Definitionen des aktuellen Schritts (leer, wenn keine angeboten). */
private currentFields(): { id: string; labelKey: string }[] {
if (!this.command || !this.state || !this.command.fields) return [];
return this.command.fields(this.state);
}
/** Hat der aktuelle Schritt einen Tab-Feld-Modus? */
hasFields(): boolean {
return this.currentFields().length > 0;
}
/**
* Setzt den Feld-Zustand zurück, wenn der Schritt wechselt — Signatur =
* phase + Feld-IDs + lastPoint. Der lastPoint-Teil sorgt dafür, dass ein
* mehrteiliger Befehl (Polyline) je gesetztem Punkt FRISCHE Felder/Locks
* bekommt, obwohl die phase ("next") gleich bleibt.
*/
private syncFields(): void {
const defs = this.currentFields();
const lp = this.state?.lastPoint;
const lpKey = lp ? `${lp.x.toFixed(6)},${lp.y.toFixed(6)}` : "∅";
const key = this.state
? `${this.state.phase}:${defs.map((f) => f.id).join(",")}:${lpKey}`
: null;
if (key !== this.fieldPhaseKey) {
this.fieldPhaseKey = key;
this.activeFieldIndex = 0;
this.locks = {};
}
}
/** Tab: nächstes Feld aktiv (zyklisch). No-op ohne Felder. */
cycleField(): void {
this.syncFields();
const n = this.currentFields().length;
if (n === 0) return;
this.activeFieldIndex = (this.activeFieldIndex + 1) % n;
this.emit();
}
/**
* Lockt einen getippten Zahlenwert ins aktive Feld und rückt aufs nächste vor
* (§2.7, Task-Schritt 2). Sind danach ALLE Felder gelockt, committet der Schritt
* sofort den daraus gebildeten Punkt — so commitet ein Ein-Feld-Befehl (Kreis:
* Radius) direkt und ein Zwei-Feld-Befehl (Linie) nach dem zweiten Wert.
*/
private lockActiveField(value: number): void {
this.syncFields();
const defs = this.currentFields();
if (defs.length === 0) return;
const field = defs[this.activeFieldIndex];
this.locks = { ...this.locks, [field.id]: value };
const allLocked = defs.every((f) => f.id in this.locks);
if (allLocked) {
this.commitFields();
return;
}
this.activeFieldIndex = (this.activeFieldIndex + 1) % defs.length;
this.refreshFieldPreview();
this.emit();
}
/** Committet den aus den Feld-Locks (+ Cursor) gebildeten Punkt als Schritt-Eingabe. */
private commitFields(): void {
const pt = this.fieldResultPoint(this.lastCursor);
if (pt) this.feed({ kind: "point", point: pt });
else this.emit();
}
/** Vorschau aus den gelockten Feldern + dem letzten Cursor neu zeichnen. */
private refreshFieldPreview(): void {
if (!this.command || !this.state || !this.command.pointFromFields) return;
const pt = this.command.pointFromFields(this.state, this.locks, this.lastCursor);
if (!pt) return;
const ctx = this.context();
const [st, res] = this.command.onMove(this.state, pt, null, ctx);
this.state = st;
if (res.draft) this.host.setDraft(res.draft);
}
// ── Befehls-Lebenszyklus ─────────────────────────────────────────────────
/** Startet einen Befehl per Name/Alias/Präfix. Liefert true bei Erfolg. */
start(nameOrPrefix: string): boolean {
const cmd = resolveCommand(nameOrPrefix) ?? getCommand(nameOrPrefix);
if (!cmd) return false;
return this.startCommand(cmd);
}
/** Startet einen konkreten Befehl (z. B. zur Wiederholung). */
startCommand(cmd: Command): boolean {
if (cmd.floorOnly && !this.host.isFloor()) return false;
this.command = cmd;
this.state = cmd.init();
this.lastCommandName = cmd.name;
this.lastCursor = null;
this.resetFields();
this.host.setDraft(null);
this.emit();
return true;
}
/** Tab-Feld-Zustand vollständig zurücksetzen (Befehlsstart/-ende/Abbruch). */
private resetFields(): void {
this.activeFieldIndex = 0;
this.locks = {};
this.fieldPhaseKey = null;
}
/** Bricht den laufenden Befehl ab (Esc). */
cancel(): void {
if (this.command && this.state) {
const [, res] = this.command.onCancel(this.state);
this.applyResult(res);
}
this.command = null;
this.state = null;
this.resetFields();
this.host.setDraft(null);
this.emit();
}
// ── Eingabe-Routing ────────────────────────────────────────────────────────
/**
* Getippte Command-Line-Eingabe (Enter). Im Ruhezustand: Befehl starten / bei
* leerer Zeile letzten wiederholen (§2.3). In einem Befehl: als Koordinate/
* Zahl/Option in den aktuellen Schritt routen.
*/
submitText(raw: string): void {
const text = raw.trim();
if (!this.command || !this.state) {
// Ruhezustand: leere Zeile = letzten Befehl wiederholen.
if (text === "") {
this.repeatLast();
return;
}
this.start(text);
return;
}
// Laufender Befehl: leere Zeile = bestätigen/beenden (Enter §2.3).
if (text === "") {
this.confirm();
return;
}
this.routeTypedInput(text);
}
/** Routet getippten Text in den aktuellen Schritt (Koordinate/Zahl/Option). */
private routeTypedInput(text: string): void {
if (!this.command || !this.state) return;
const accepts = this.command.accepts(this.state);
// Optionsbuchstabe(n)? (nur wenn der Schritt Optionen annimmt) — Präfix-Match.
if (accepts.includes("option")) {
const opts = this.command.options(this.state);
const q = text.toLowerCase();
const hit = opts.find((o) => o.id.toLowerCase().startsWith(q));
if (hit) {
this.feed({ kind: "option", id: hit.id });
return;
}
}
const parsed = parseInput(text);
// Tab-Feld-Modus (§2.7): eine nackte Zahl LOCKT das aktive Feld (statt als
// distance/number durchzureichen) und rückt aufs nächste Feld vor. Absolute/
// relative/polare Koordinaten umgehen die Felder (committen wie gewohnt).
if (parsed.kind === "distance" && this.hasFields()) {
this.lockActiveField(parsed.value);
return;
}
const lastPoint = this.state.lastPoint;
const cursor = this.lastCursor;
if (parsed.kind === "text") {
// Unparsbar im Schritt: ignorieren (kein gültiger Punkt/Option).
return;
}
// Punktbezogene Eingaben → zu Modellpunkt auflösen, wenn der Schritt Punkte
// annimmt; sonst nackte Zahl als number-Input.
if (parsed.kind === "distance" && accepts.includes("number") && !accepts.includes("point")) {
this.feed({ kind: "number", value: parsed.value });
return;
}
const pt = resolvePoint(parsed, lastPoint, cursor);
if (pt && accepts.includes("point")) {
this.feed({ kind: "point", point: pt });
return;
}
if (parsed.kind === "distance" && accepts.includes("number")) {
this.feed({ kind: "number", value: parsed.value });
}
}
/** Maus-Pick aus PlanView (linker Klick) — gesnappter Modellpunkt. */
pick(raw: Vec2): 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 point = snap?.point ?? raw;
this.lastCursor = point;
// Tab-Feld-Modus: gelockte Felder überschreiben den Klick (z. B. Länge fix,
// Richtung per Maus). Der finale Punkt kommt aus pointFromFields.
const fieldPt = this.fieldResultPoint(point);
if (fieldPt) {
this.feed({ kind: "point", point: fieldPt });
return;
}
this.feed({ kind: "point", point, snap });
}
/** Hover/Drag aus PlanView — nur Vorschau (kein Commit). */
move(raw: Vec2): void {
if (!this.command || !this.state) return;
const snap = this.host.snap(raw, this.draftPoints());
const point = snap?.point ?? raw;
this.lastCursor = point;
const ctx = this.context();
// Tab-Feld-Modus: ungelockte Felder folgen der Maus, gelockte überschreiben.
const previewPoint = this.fieldResultPoint(point) ?? point;
const [st, res] = this.command.onMove(this.state, previewPoint, snap, ctx);
this.state = st;
// Snap-Marker mit in den Draft hängen.
if (res.draft) this.host.setDraft({ ...res.draft, snap });
else if (snap) this.host.setDraft({ preview: [], vertices: [], snap });
else this.host.setDraft(null);
}
/**
* Punkt aus dem Tab-Feld-Modus für einen gegebenen Cursor — oder null, wenn der
* Schritt keine Felder hat (dann gilt der rohe Cursor/Pick).
*/
private fieldResultPoint(cursor: Vec2 | null): Vec2 | null {
if (!this.command || !this.state || !this.command.pointFromFields) return null;
if (!this.hasFields()) return null;
return this.command.pointFromFields(this.state, this.locks, cursor);
}
/** Klick auf eine Inline-Option (§2.4). */
chooseOption(id: string): void {
if (!this.command || !this.state) return;
this.feed({ kind: "option", id });
}
/** Enter = Space = Rechtsklick: bestätigen/Default/mehrteilig-beenden (§2.3). */
confirm(): void {
if (!this.command || !this.state) {
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;
}
const ctx = this.context();
const [st, res] = this.command.onConfirm(this.state, ctx);
this.state = st;
this.applyResult(res);
}
/** Leer-Enter/Rechtsklick im Ruhezustand: letzten Befehl wiederholen. */
repeatLast(): void {
if (this.lastCommandName) {
const cmd = getCommand(this.lastCommandName);
if (cmd) this.startCommand(cmd);
}
}
// ── interne Mechanik ───────────────────────────────────────────────────────
private feed(input: CmdInput): void {
if (!this.command || !this.state) return;
if (input.kind === "point") this.lastCursor = input.point;
const ctx = this.context();
const [st, res] = this.command.onInput(this.state, input, ctx);
this.state = st;
this.applyResult(res);
}
private applyResult(res: CommandResult): void {
if (res.commit) this.host.commit(res.commit);
if (res.draft) this.host.setDraft(res.draft);
else this.host.setDraft(null);
if (res.done) {
this.command = null;
this.state = null;
this.resetFields();
}
this.emit();
}
private context(): CommandContext {
return this.host.context(this.state?.lastPoint ?? null);
}
/** Bereits gesetzte Punkte des laufenden Befehls (für Snap-Quellen). */
private draftPoints(): Vec2[] {
const s = this.state as (CommandState & { a?: Vec2; points?: Vec2[] }) | null;
if (!s) return [];
if (s.points) return s.points;
if (s.a) return [s.a];
if (s.lastPoint) return [s.lastPoint];
return [];
}
}
+166
View File
@@ -0,0 +1,166 @@
// Koordinaten- & Numerik-Parser (docs/design/rhino-command-system.md §2.7).
//
// Parst rohen Command-Line-Text TOLERANT (Komma/Space/Semikolon als Trenner) zu
// einer Discriminated Union; die Engine löst die punktbezogenen Varianten mit
// `lastPoint` + Cursor-Richtung zu einem konkreten Modellpunkt auf.
//
// Unterstützte Formen:
// `5,3` → absolut X,Y
// `5,3,2` → absolut X,Y (Z ignoriert, 2D)
// `r5,3` → relativ zum letzten Punkt
// `5<45` → polar: Distanz 5 unter 45° vom letzten Punkt
// `<45` → nur Winkel-Constraint; Distanz folgt Maus/Zahl
// `5` → nackte Zahl = Distanz-Lock entlang Cursor-/Constraint-Richtung
// sonst → Roh-Text (Befehlsname / Optionsbuchstabe) — von der Engine geroutet
//
// Reine Funktionen, kein Zustand. Winkel in Grad, gegen die +X-Achse, CCW positiv
// (passend zur Y-flip-freien Modellkonvention: +Y ist „oben").
import type { Vec2 } from "../model/types";
/** Geparste, noch nicht aufgelöste Eingabe. */
export type ParsedInput =
| { kind: "absolute"; point: Vec2 } // 5,3
| { kind: "relative"; delta: Vec2 } // r5,3
| { kind: "polar"; dist: number; angleDeg: number } // 5<45
| { kind: "angle"; angleDeg: number } // <45
| { kind: "distance"; value: number } // 5 (nackte Zahl)
| { kind: "text"; text: string }; // Befehlsname / Option / unparsbar
const DEG = Math.PI / 180;
/** Tolerant: Zahl aus einem Token (erlaubt führendes +, Dezimalpunkt/-komma). */
function parseNumber(raw: string): number | null {
const s = raw.trim().replace(",", "."); // Hilfsfall: einzelnes Dezimalkomma
if (s === "" || s === "+" || s === "-") return null;
const n = Number(s);
return Number.isFinite(n) ? n : null;
}
/**
* Zerlegt eine Koordinatenliste (X,Y[,Z]) tolerant in Zahlen. Trenner: Komma,
* Semikolon oder Whitespace. Liefert null, wenn nicht ≥2 valide Zahlen vorliegen.
* (Wir nutzen die echten Trenner, daher kollidiert das NICHT mit dem
* Dezimalkomma-Hilfsfall der nackten Zahl.)
*/
function parseCoordList(raw: string): number[] | null {
const parts = raw
.trim()
.split(/[\s,;]+/)
.filter((p) => p.length > 0);
if (parts.length < 2) return null;
const nums: number[] = [];
for (const p of parts) {
const n = Number(p.trim());
if (!Number.isFinite(n)) return null;
nums.push(n);
}
return nums;
}
/**
* Parst rohen Command-Line-Text zu einer {@link ParsedInput}. Befehlsnamen/
* Optionsbuchstaben fallen als `{kind:"text"}` durch (die Engine routet sie).
*/
export function parseInput(raw: string): ParsedInput {
const s = raw.trim();
if (s === "") return { kind: "text", text: "" };
// ── polar / Winkel: enthält '<' ───────────────────────────────────────────
if (s.includes("<")) {
const i = s.indexOf("<");
const left = s.slice(0, i).trim();
const right = s.slice(i + 1).trim();
const angle = parseNumber(right);
if (angle == null) return { kind: "text", text: s };
if (left === "") return { kind: "angle", angleDeg: angle };
const dist = parseNumber(left);
if (dist == null) return { kind: "text", text: s };
return { kind: "polar", dist, angleDeg: angle };
}
// ── relativ: führendes r / @ ──────────────────────────────────────────────
if (/^[r@]/i.test(s)) {
const rest = s.slice(1);
const nums = parseCoordList(rest);
if (nums && nums.length >= 2) {
return { kind: "relative", delta: { x: nums[0], y: nums[1] } };
}
// `r5` → relative Distanz entlang Cursor-Richtung.
const one = parseNumber(rest);
if (one != null) return { kind: "distance", value: one };
return { kind: "text", text: s };
}
// ── absolute Koordinate: ≥2 zahlen mit echtem Trenner ─────────────────────
const coords = parseCoordList(s);
if (coords && coords.length >= 2) {
return { kind: "absolute", point: { x: coords[0], y: coords[1] } };
}
// ── nackte Zahl = Distanz-Lock ────────────────────────────────────────────
const one = parseNumber(s);
if (one != null) return { kind: "distance", value: one };
// ── sonst: Text (Befehl/Option) ───────────────────────────────────────────
return { kind: "text", text: s };
}
/** Punkt aus polarer Form (Distanz/Winkel) relativ zu einem Basispunkt. */
export function polarPoint(base: Vec2, dist: number, angleDeg: number): Vec2 {
const a = angleDeg * DEG;
return { x: base.x + Math.cos(a) * dist, y: base.y + Math.sin(a) * dist };
}
/**
* Löst eine {@link ParsedInput} zu einem konkreten Modellpunkt auf — falls sie
* sich als Punkt auflösen lässt. `lastPoint` ist der letzte gesetzte Punkt im
* Befehl (für relativ/polar/Distanz), `cursorDir` die aktuelle (gesnappte)
* Cursor-Richtung als Roh-Cursorpunkt (für nackte Zahl / reinen Winkel-Lock).
*
* Liefert null, wenn die Eingabe keinen Punkt ergibt (z. B. reiner Text oder
* Distanz ohne Bezug).
*/
export function resolvePoint(
parsed: ParsedInput,
lastPoint: Vec2 | null,
cursor: Vec2 | null,
): Vec2 | null {
switch (parsed.kind) {
case "absolute":
return parsed.point;
case "relative":
if (!lastPoint) return parsed.delta; // ohne Bezug = vom Ursprung
return { x: lastPoint.x + parsed.delta.x, y: lastPoint.y + parsed.delta.y };
case "polar": {
const base = lastPoint ?? { x: 0, y: 0 };
return polarPoint(base, parsed.dist, parsed.angleDeg);
}
case "angle": {
// Reiner Winkel-Lock: ohne Distanz braucht es den Cursor, um die Länge
// entlang der gelockten Richtung zu projizieren.
if (!lastPoint || !cursor) return null;
const a = parsed.angleDeg * DEG;
const dir = { x: Math.cos(a), y: Math.sin(a) };
const dx = cursor.x - lastPoint.x;
const dy = cursor.y - lastPoint.y;
const proj = dx * dir.x + dy * dir.y; // skalare Projektion
const len = Math.max(0, proj);
return { x: lastPoint.x + dir.x * len, y: lastPoint.y + dir.y * len };
}
case "distance": {
// Distanz-Lock: Richtung per Cursor, Länge per Zahl.
if (!lastPoint || !cursor) return null;
const dx = cursor.x - lastPoint.x;
const dy = cursor.y - lastPoint.y;
const l = Math.hypot(dx, dy);
if (l < 1e-9) return { x: lastPoint.x + parsed.value, y: lastPoint.y };
return {
x: lastPoint.x + (dx / l) * parsed.value,
y: lastPoint.y + (dy / l) * parsed.value,
};
}
default:
return null;
}
}
+79
View File
@@ -0,0 +1,79 @@
// Befehls-Registry (docs/design/rhino-command-system.md §3.2) — `COMMANDS` +
// Aliase + Präfix-Autocomplete. Einzelne Befehle leben als eigene Module unter
// `cmds/`, sodass weitere Befehle ADDITIV (ohne Änderung an Engine/App)
// dazukommen: hier importieren + registrieren, fertig.
import type { Command } from "./types";
import { lineCommand } from "./cmds/line";
import { polylineCommand } from "./cmds/polyline";
import { rectCommand } from "./cmds/rect";
import { circleCommand } from "./cmds/circle";
import { moveCommand } from "./cmds/move";
import { copyCommand } from "./cmds/copy";
import { offsetCommand } from "./cmds/offset";
import { importCommand } from "./cmds/import";
import { terrainCommand } from "./cmds/terrain";
/** Alle bekannten Befehle, Schlüssel = Befehlsname (lowercase). */
export const COMMANDS: Record<string, Command> = {
line: lineCommand,
polyline: polylineCommand,
rect: rectCommand,
circle: circleCommand,
move: moveCommand,
copy: copyCommand,
offset: offsetCommand,
import: importCommand,
terrain: terrainCommand,
};
/**
* Aliase / Kürzel → Befehlsname (§2.2). Werden VOR dem Autocomplete gematcht
* (case-insensitiv). Minimal: Einzelbuchstaben. NICHT `r` (kollidiert mit dem
* relativ-Koordinaten-Präfix `r5,3`).
*/
export const ALIASES: Record<string, string> = {
l: "line",
pl: "polyline",
rec: "rect",
c: "circle",
m: "move",
cp: "copy",
o: "offset",
imp: "import",
ter: "terrain",
};
/** Liefert den Befehl zu einem exakten Namen (oder undefined). */
export function getCommand(name: string): Command | undefined {
return COMMANDS[name.toLowerCase()];
}
/**
* Löst eine getippte Eingabe zu einem Befehl auf: erst exakter Name, dann Alias,
* dann eindeutiges Präfix (case-insensitiv). Mehrdeutige/leere Präfixe → null.
*/
export function resolveCommand(raw: string): Command | null {
const q = raw.trim().toLowerCase();
if (q === "") return null;
if (COMMANDS[q]) return COMMANDS[q];
if (ALIASES[q]) return COMMANDS[ALIASES[q]] ?? null;
const matches = autocomplete(q);
if (matches.length === 1) return COMMANDS[matches[0]] ?? null;
// Exaktes Präfix, das auch ein Alias ist, hat Vorrang, wenn eindeutig.
return null;
}
/**
* Präfix-Autocomplete: alle Befehlsnamen, die mit dem (lowercase) Präfix
* beginnen, alphabetisch. Aliase werden NICHT mitgelistet (sie sind Kürzel,
* keine Vorschläge), aber ein exakter Alias-Treffer wird vorangestellt.
*/
export function autocomplete(prefix: string): string[] {
const q = prefix.trim().toLowerCase();
if (q === "") return Object.keys(COMMANDS).sort();
const names = Object.keys(COMMANDS)
.filter((n) => n.startsWith(q))
.sort();
return names;
}
+183
View File
@@ -0,0 +1,183 @@
// Command-Engine — Verallgemeinerung des `Tool`-Interfaces (src/tools/types.ts)
// zu einem Rhino-artigen Befehlsmodell (docs/design/rhino-command-system.md §3.1).
//
// Ein `Command` ist eine kleine State-Machine: jeder Schritt zeigt einen Prompt,
// nimmt bestimmte Eingabe-Arten an (Punkt | Zahl | Option | Auswahl) und liefert
// aus (state, input) den nächsten Zustand + ein `CommandResult` (Vorschau +
// optional commit/done). Damit speisen Maus-Picks UND getippte Koordinaten/
// Optionen DENSELBEN Schritt — es gibt nur einen Eingabepfad.
//
// Bezeichner englisch, sichtbarer Text via t() (i18n-Keys, Namespace `cmd.*`).
import type { Project, Vec2 } from "../model/types";
import type { TranslationKey } from "../i18n";
import type { DrawingLevel } from "../model/types";
import type { SnapResult, ToolDraft } from "../tools/types";
export type { DraftShape, SnapResult, ToolDraft } from "../tools/types";
// ── Eingabe-Arten ────────────────────────────────────────────────────────────
/** Arten von Eingaben, die ein Schritt annehmen kann. */
export type AcceptKind = "point" | "number" | "option" | "selection";
/**
* Geparste, noch nicht aufgelöste Roh-Eingabe aus der Command-Line bzw. der
* Maus. Discriminated Union (§2.7). Die Engine löst `point`-bezogene Varianten
* mit `lastPoint` + Cursor-Richtung zu einem konkreten Modellpunkt auf, bevor
* sie den Schritt aufruft.
*/
export type CmdInput =
| { kind: "point"; point: Vec2; snap?: SnapResult | null }
| { kind: "number"; value: number }
| { kind: "option"; id: string }
| { kind: "text"; text: string }
| { kind: "selection"; wallIds: string[]; drawingIds: string[] };
// ── Optionen (klickbare Inline-Klammern, §2.4) ───────────────────────────────
/**
* Eine Inline-Option eines Schritts. `value` (gesetzt) → wird als „Name=Value"
* angezeigt und ist togglebar/abfragbar; ohne `value` ist es eine Action-Option
* (verzweigt sofort). `id` ist der englische Schlüssel (auch zum Tippen, Präfix).
*/
export interface CmdOption {
id: string;
/** Sichtbares Label (i18n). */
labelKey: TranslationKey;
/** Aktueller Wert (Toggle/Value-Option); fehlt → Action-Option. */
value?: string;
}
// ── Kontext + Ergebnis ───────────────────────────────────────────────────────
/**
* Die aktuelle Auswahl (Rhino: erst selektieren, dann Befehl). Editierbefehle
* (Move/Copy/Offset) lesen sie hieraus, statt sie selbst zu picken. `wallIds`
* sind die gewählten Wände, `drawingId` ist das gewählte 2D-Element (sich
* gegenseitig ausschließend, wie in selectionSlice).
*/
export interface CommandSelection {
wallIds: string[];
drawingId: string | null;
}
/** Live-Kontext, den ein Befehl bei jedem Schritt erhält (analog ToolContext). */
export interface CommandContext {
project: Project;
/** Aktive Zeichnungsebene (Ziel neuer Elemente). */
level: DrawingLevel;
/** Default-Kategorie-Code für neue Elemente. */
defaultCategoryCode: string;
/** Aktiver Linienstil-Code für 2D-Primitive. */
activeLineStyleId: string;
/**
* Letzter gesetzter Punkt im laufenden Befehl (für `r`/polar-Auflösung); null
* am ersten Punkt.
*/
lastPoint: Vec2 | null;
/**
* Vorab gesetzte Auswahl (Wände + ein 2D-Element). Editierbefehle operieren
* darauf (Rhino: erst selektieren, dann Befehl). Beim Befehlsstart aus dem
* Store gefüllt.
*/
selection: CommandSelection;
}
/** Was ein Befehls-Schritt nach außen meldet (analog ToolResult + commit/done). */
export interface CommandResult {
/** Neuer Vorschau-Zustand; null = nichts zu zeigen. */
draft: ToolDraft | null;
/** Bei Abschluss: immutable Mutation, die die Engine über setProject anwendet. */
commit?: (p: Project) => Project;
/** true → Befehl ist fertig und kehrt in den Ruhezustand zurück. */
done?: boolean;
}
// ── Befehls-Zustand ──────────────────────────────────────────────────────────
/**
* Interner Zustand eines laufenden Befehls. Basis-Form (`phase` + `lastPoint`);
* jeder Befehl verfeinert sie zu seiner eigenen Discriminated Union und castet
* beim Eintritt. `lastPoint` lebt im Zustand, damit der Parser `r`/polar relativ
* dazu auflösen kann.
*/
export interface CommandState {
phase: string;
/** Letzter gesetzter Punkt (für relative/polare Eingabe), null am Anfang. */
lastPoint: Vec2 | null;
}
// ── Tab-Feld-Zyklus (Präzisionseingabe §2.7, erweitert) ──────────────────────
/**
* Ein geordnetes Zahlenfeld eines Schritts (z. B. Länge, Winkel, Breite, Höhe,
* Radius). Mit Tab zyklt der Nutzer durch die Felder; eine getippte Zahl LOCKT
* das aktive Feld, ungelockte Felder folgen weiter der Maus.
*/
export interface CommandField {
/** Englischer Schlüssel (Identität des Feldes, z. B. „length"). */
id: string;
/** Sichtbares Label (i18n, Namespace `cmd.field.*`). */
labelKey: TranslationKey;
}
// ── Befehls-Schnittstelle ────────────────────────────────────────────────────
/** Die Command-Schnittstelle (reine Funktionen über einen internen State). */
export interface Command {
/** Befehlsname (englisch, in der Command-Line getippt), z. B. „line". */
name: string;
/** UI-Label-Key (i18n). */
labelKey: TranslationKey;
/** Prompt-Text-Key des aktuellen Schritts. */
prompt(state: CommandState): TranslationKey;
/** Eingabe-Arten, die der aktuelle Schritt annimmt. */
accepts(state: CommandState): AcceptKind[];
/** Inline-Optionen des aktuellen Schritts (klickbar/tippbar). */
options(state: CommandState): CmdOption[];
/** Nur auf Geschossen aktiv? (analog floorOnly bei Tools.) */
floorOnly?: boolean;
/** Initialer Schritt-Zustand. */
init(): CommandState;
/** Eine Eingabe (Punkt/Zahl/Option/Auswahl) verarbeiten. */
onInput(
state: CommandState,
input: CmdInput,
ctx: CommandContext,
): [CommandState, CommandResult];
/** Hover/Bewegung (roher bzw. gesnappter Cursor): nur Vorschau. */
onMove(
state: CommandState,
point: Vec2,
snap: SnapResult | null,
ctx: CommandContext,
): [CommandState, CommandResult];
/** Enter/Space/Rechtsklick: Schritt bestätigen / mehrteilig beenden. */
onConfirm(state: CommandState, ctx: CommandContext): [CommandState, CommandResult];
/** Esc: Entwurf verwerfen, Befehl beenden. */
onCancel(state: CommandState): [CommandState, CommandResult];
// ── Optionaler Tab-Feld-Zyklus (additiv; Befehle ohne diese Methoden
// verhalten sich exakt wie bisher) ──────────────────────────────────────
/**
* Geordnete Zahlenfelder des AKTUELLEN Schritts (leer/undefined = kein
* Feld-Modus). Beispiel Linie-Endschritt: [length, angle].
*/
fields?(state: CommandState): CommandField[];
/**
* Berechnet den resultierenden Modellpunkt aus den gelockten Feldern + dem
* Cursor. Ungelockte Felder folgen dem Cursor (Maus); gelockte Felder
* überschreiben. Liefert null, wenn (noch) kein Punkt bestimmbar ist.
*/
pointFromFields?(
state: CommandState,
locks: Record<string, number>,
cursor: Vec2 | null,
): Vec2 | null;
}
/** Convenience-Re-Export für Befehls-Module. */
export type { Project, Vec2 } from "../model/types";
+277
View File
@@ -0,0 +1,277 @@
// Editier-Operationen Split / Join / Segment-Löschen für 2D-Zeichenelemente
// (Drawing2D). Wandeln Drawing2D ↔ {pts, closed}, rufen den reinen Geometrie-
// Kernel (`../geometry/kernel2d`) und liefern neue Drawing2D-Listen. Stil-,
// Kategorie- und Level-Attribute der Quelle werden geerbt.
//
// Bezeichner englisch, Kommentare deutsch (CLAUDE.md).
import type { Drawing2D, Drawing2DGeom, Vec2 } from "../model/types";
import {
joinChains,
removeSegment,
splitAtIntersections,
splitClosedByChord,
splitPolylineAtParam,
vecEqual,
} from "../geometry/kernel2d";
/** Eine Polylinien-Darstellung eines Drawing2D (für die Kernel-Funktionen). */
export interface PolyForm {
pts: Vec2[];
closed: boolean;
}
/**
* Wandelt die Geometrie eines Drawing2D in eine Polylinien-Form `{pts, closed}`.
* Nur line/polyline/rect werden unterstützt (circle/arc/text → `null`).
*/
export function toPolyForm(geom: Drawing2DGeom): PolyForm | null {
switch (geom.shape) {
case "line":
return { pts: [geom.a, geom.b], closed: false };
case "polyline":
return { pts: geom.pts.slice(), closed: geom.closed };
case "rect":
return {
pts: [
geom.min,
{ x: geom.max.x, y: geom.min.y },
geom.max,
{ x: geom.min.x, y: geom.max.y },
],
closed: true,
};
default:
return null;
}
}
/**
* Baut aus einer Polylinien-Form die passende Drawing2D-Geometrie. Zwei Punkte +
* offen → `line`; sonst `polyline` (geschlossen oder offen). Rechtecke werden
* NICHT rekonstruiert (nach dem Schneiden sind die Teile beliebige Polygone).
*/
export function fromPolyForm(form: PolyForm): Drawing2DGeom {
const pts = form.pts;
if (pts.length === 2 && !form.closed) {
return { shape: "line", a: pts[0], b: pts[1] };
}
return { shape: "polyline", pts, closed: form.closed };
}
let counter = 0;
/** Eindeutige Drawing2D-ID (zeitbasiert + Zähler, damit ein Split nicht kollidiert). */
function newId(prefix: string): string {
counter += 1;
return `${prefix}-${Date.now()}-${counter}`;
}
/** Übernimmt Stil-/Kategorie-/Level-Attribute der Quelle auf ein neues Drawing2D. */
function inherit(src: Drawing2D, geom: Drawing2DGeom): Drawing2D {
const out: Drawing2D = {
id: newId("d2d"),
type: "drawing2d",
levelId: src.levelId,
categoryCode: src.categoryCode,
geom,
};
if (src.lineStyleId !== undefined) out.lineStyleId = src.lineStyleId;
if (src.color !== undefined) out.color = src.color;
if (src.weightMm !== undefined) out.weightMm = src.weightMm;
// Füllung/Schraffur nur an geschlossene Ergebnisse weitergeben.
const closed =
geom.shape === "polyline" ? geom.closed : geom.shape === "rect";
if (closed) {
if (src.hatchId !== undefined) out.hatchId = src.hatchId;
if (src.fillColor !== undefined) out.fillColor = src.fillColor;
}
return out;
}
/**
* Split eines Drawing2D gegen alle ANDEREN Elemente (`others`, als PolyForm).
* • Trifft das Ziel andere Elemente, wird an den Schnittpunkten geteilt
* (geschlossen + 2 Schnitte → zwei GESCHLOSSENE Polygone via Sehne).
* • Ohne Schnitt: Fallback — an der Kanten-MITTE der „längsten" Kante teilen
* (offen → zwei Stücke; geschlossen → an einer Kantenmitte + der gegenüber-
* liegenden Kantenmitte zu zwei geschlossenen Hälften).
* Liefert ≥2 neue Drawing2D, oder `[src]` (unverändert), wenn keine Teilung
* möglich war (z. B. nicht-polyline Form). Stil/Kategorie/Level werden geerbt.
*/
export function splitDrawing(
src: Drawing2D,
others: PolyForm[],
): Drawing2D[] {
const form = toPolyForm(src.geom);
if (!form) return [src];
const otherPts = others.map((o) =>
// Geschlossene als Ring (erster==letzter) übergeben, damit der Kernel die
// Schlusskante als Kante erkennt.
o.closed && o.pts.length > 2 ? [...o.pts, o.pts[0]] : o.pts,
);
let pieces = splitAtIntersections(form.pts, form.closed, otherPts);
// Kein Schnitt-Ergebnis (eine Liste, identisch zur Quelle) → Fallback.
const noCut = pieces.length < 2;
if (noCut) {
pieces = fallbackSplit(form);
}
if (pieces.length < 2) return [src];
// Jedes Stück: geschlossen bleibt geschlossen NUR, wenn das Original
// geschlossen war UND der Schnitt zwei geschlossene Hälften erzeugte
// (Sehnen-Split / Fallback-geschlossen). Bei offenem Aufschneiden sind die
// Stücke offen.
const piecesClosed = form.closed && isClosedSplit(form, pieces);
return pieces.map((pts) =>
inherit(src, fromPolyForm({ pts, closed: piecesClosed })),
);
}
/**
* Heuristik: erzeugte der Schnitt zwei GESCHLOSSENE Hälften (Sehnen-Split)?
* Wahr, wenn das Original geschlossen war und genau zwei Stücke entstanden
* (Chord-Split) — diese sollen wieder geschlossen sein. Bei mehr Stücken
* (geschlossen an >2 Schnitten aufgetrennt) bleiben sie offen.
*/
function isClosedSplit(form: PolyForm, pieces: Vec2[][]): boolean {
return form.closed && pieces.length === 2;
}
/**
* Fallback ohne Schnitt mit anderen Elementen: an der Mitte teilen.
* • Offen: längste Kante an t=0.5 in zwei offene Stücke.
* • Geschlossen: an den Mitten zweier „gegenüberliegender" Kanten in zwei
* geschlossene Polygone (Sehne zwischen den beiden Kantenmitten).
*/
function fallbackSplit(form: PolyForm): Vec2[][] {
const n = form.pts.length;
if (n < 2) return [form.pts];
if (!form.closed) {
// Längste Kante suchen.
let best = 0;
let bestLen = -1;
for (let i = 0; i < n - 1; i++) {
const d = dist(form.pts[i], form.pts[i + 1]);
if (d > bestLen) {
bestLen = d;
best = i;
}
}
return splitPolylineAtParam(form.pts, false, best, 0.5);
}
// Geschlossen: Kante 0 und die „gegenüberliegende" Kante.
const i = 0;
const j = Math.max(1, Math.floor(n / 2)) % n;
if (i === j) return [form.pts];
// Direkt den Kernel-Chord-Split nutzen.
const res = splitClosedHalf(form.pts, i, j);
return res ?? [form.pts];
}
/** Geschlossenes Polygon an den Mitten der Kanten i und j halbieren. */
function splitClosedHalf(pts: Vec2[], i: number, j: number): Vec2[][] | null {
// Reuse: splitClosedByChord ist im Kernel; hier über splitAtIntersections-Form
// nachzubauen wäre umständlich — direkt importieren.
const res = chordSplit(pts, i, 0.5, j, 0.5);
return res;
}
function chordSplit(
pts: Vec2[],
i: number,
ti: number,
j: number,
tj: number,
): Vec2[][] | null {
const r = splitClosedByChord(pts, i, ti, j, tj);
return r ? [r[0], r[1]] : null;
}
const dist = (a: Vec2, b: Vec2): number =>
Math.hypot(a.x - b.x, a.y - b.y);
/**
* Join: verbindet mehrere Drawing2D an koinzidenten Endpunkten zu Polylinien
* (geschlossen, wenn sich eine Kette schließt). Nicht-polyline Elemente
* (circle/arc/text) werden unverändert durchgereicht. Liefert die NEUE Menge
* der Drawing2D, die die Eingabe-Elemente ersetzt. Attribute werden vom ersten
* verbundenen Quell-Element geerbt.
*/
export function joinDrawings(srcs: Drawing2D[]): Drawing2D[] {
const joinable: { form: PolyForm; src: Drawing2D }[] = [];
const passthrough: Drawing2D[] = [];
for (const d of srcs) {
const form = toPolyForm(d.geom);
if (form) joinable.push({ form, src: d });
else passthrough.push(d);
}
if (joinable.length === 0) return srcs;
const chains = joinChains(joinable.map((j) => j.form));
const template = joinable[0].src;
const joined = chains.map((c) =>
inherit(template, fromPolyForm({ pts: c.pts, closed: c.closed })),
);
return [...joined, ...passthrough];
}
/**
* Segment-Löschen (Schere): entfernt das Segment `edgeIndex` aus einem
* Drawing2D. Geschlossene Form → offene Polylinie. Liefert das geänderte
* Drawing2D, oder `null`, wenn weniger als 2 Punkte übrig blieben (Element
* komplett löschen) bzw. die Form nicht unterstützt wird.
*/
export function cutSegment(src: Drawing2D, edgeIndex: number): Drawing2D | null {
const form = toPolyForm(src.geom);
if (!form) return src; // nicht unterstützte Form unverändert lassen
const res = removeSegment(form.pts, form.closed, edgeIndex);
if (res.pts.length < 2) return null;
return inherit(src, fromPolyForm(res));
}
/**
* Liefert den Index der Kante eines Drawing2D, die dem Modellpunkt `p` am
* nächsten liegt (für Alt+Klick = Segment-Löschen). `null`, wenn die Form keine
* Kanten hat (circle/arc/text) oder zu wenige Punkte. Reuse: Polyline-Form +
* Punkt-Strecke-Distanz.
*/
export function nearestEdgeIndex(geom: Drawing2DGeom, p: Vec2): number | null {
const form = toPolyForm(geom);
if (!form || form.pts.length < 2) return null;
const edges: [number, Vec2, Vec2][] = [];
for (let i = 0; i < form.pts.length - 1; i++) {
edges.push([i, form.pts[i], form.pts[i + 1]]);
}
if (form.closed && form.pts.length > 2) {
edges.push([form.pts.length - 1, form.pts[form.pts.length - 1], form.pts[0]]);
}
let best: number | null = null;
let bestD = Infinity;
for (const [i, a, b] of edges) {
const d = segDist(p, a, b);
if (d < bestD) {
bestD = d;
best = i;
}
}
return best;
}
/** Abstand Punkt → Strecke (lokal, klein gehalten). */
function segDist(p: Vec2, a: Vec2, b: Vec2): number {
const abx = b.x - a.x;
const aby = b.y - a.y;
const l2 = abx * abx + aby * aby;
let t = l2 > 0 ? ((p.x - a.x) * abx + (p.y - a.y) * aby) / l2 : 0;
t = Math.max(0, Math.min(1, t));
const cx = a.x + abx * t;
const cy = a.y + aby * t;
return Math.hypot(p.x - cx, p.y - cy);
}
// `vecEqual` re-export für etwaige Aufrufer (Auswahl-Vergleich).
export { vecEqual };
+735
View File
@@ -0,0 +1,735 @@
// 2D-Geometrie-Kernel — die „echte Arbeit" für Offset / Trim / Extend / Fillet /
// Split (Rhino-artige Editierbefehle, Tier 1). Reine Funktionen über `Vec2`,
// kein externer Kernel. Baut auf den Helfern in `../model/geometry` auf.
//
// Konvention: Polylinien sind `Vec2[]`; `closed` heißt, die letzte Kante geht
// von pts[n-1] zu pts[0] (der Schlusspunkt wird NICHT dupliziert). Offsets sind
// vorzeichenbehaftet: positiv = nach LINKS bezogen auf die Laufrichtung
// (`leftNormal`-Konvention des Projekts), negativ = nach rechts.
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
import type { Vec2 } from "../model/types";
import {
add,
cross,
leftNormal,
len,
lineIntersect,
normalize,
scale,
sub,
} from "../model/geometry";
/** Numerische Toleranz (Meter) für Schnitt-/Gleichheitstests. */
export const EPS = 1e-7;
const dot = (a: Vec2, b: Vec2): number => a.x * b.x + a.y * b.y;
const dist = (a: Vec2, b: Vec2): number => len(sub(a, b));
/** Punkt-Gleichheit innerhalb Toleranz. */
export function vecEqual(a: Vec2, b: Vec2, eps = EPS): boolean {
return Math.abs(a.x - b.x) <= eps && Math.abs(a.y - b.y) <= eps;
}
// ── Punkt/Strecke ────────────────────────────────────────────────────────────
/** Projektionsparameter t von p auf die Gerade a→b (nicht geklemmt). */
export function projectParam(p: Vec2, a: Vec2, b: Vec2): number {
const ab = sub(b, a);
const l2 = dot(ab, ab);
if (l2 < EPS) return 0;
return dot(sub(p, a), ab) / l2;
}
/** Nächster Punkt auf der STRECKE a→b zu p (t auf [0,1] geklemmt). */
export function closestPointOnSegment(p: Vec2, a: Vec2, b: Vec2): Vec2 {
const t = Math.max(0, Math.min(1, projectParam(p, a, b)));
return add(a, scale(sub(b, a), t));
}
/** Abstand von p zur Strecke a→b. */
export function pointSegmentDistance(p: Vec2, a: Vec2, b: Vec2): number {
return dist(p, closestPointOnSegment(p, a, b));
}
// ── Schnitt ──────────────────────────────────────────────────────────────────
/** Ergebnis eines Strecken-/Linienschnitts. */
export interface Hit {
point: Vec2;
/** Parameter auf der ersten Strecke (0 = a1, 1 = a2). */
t: number;
/** Parameter auf der zweiten Strecke (0 = b1, 1 = b2). */
s: number;
}
/**
* Schnitt zweier STRECKEN a1→a2 und b1→b2. Liefert den Schnittpunkt nur, wenn er
* auf BEIDEN Strecken liegt (t,s ∈ [-eps, 1+eps]); sonst null. Parallel → null.
*/
export function segmentIntersect(
a1: Vec2,
a2: Vec2,
b1: Vec2,
b2: Vec2,
eps = EPS,
): Hit | null {
const da = sub(a2, a1);
const db = sub(b2, b1);
const denom = cross(da, db);
if (Math.abs(denom) < EPS) return null; // parallel/kollinear
const t = cross(sub(b1, a1), db) / denom;
const s = cross(sub(b1, a1), da) / denom;
if (t < -eps || t > 1 + eps || s < -eps || s > 1 + eps) return null;
return { point: add(a1, scale(da, t)), t, s };
}
/**
* Schnitt der unendlichen GERADE durch a1→a2 mit der STRECKE b1→b2 (für Extend/
* Trim gegen einen Schneider): Schnittpunkt muss auf der Strecke b liegen
* (s ∈ [0,1]), t (auf a) ist frei. Null bei parallel / kein Treffer auf b.
*/
export function lineSegmentIntersect(
a1: Vec2,
a2: Vec2,
b1: Vec2,
b2: Vec2,
eps = EPS,
): Hit | null {
const da = sub(a2, a1);
const db = sub(b2, b1);
const denom = cross(da, db);
if (Math.abs(denom) < EPS) return null;
const t = cross(sub(b1, a1), db) / denom;
const s = cross(sub(b1, a1), da) / denom;
if (s < -eps || s > 1 + eps) return null;
return { point: add(a1, scale(da, t)), t, s };
}
/** Kanten einer Polylinie als [from,to]-Paare (Schlusskante bei `closed`). */
export function polylineEdges(pts: Vec2[], closed: boolean): [Vec2, Vec2][] {
const out: [Vec2, Vec2][] = [];
for (let i = 0; i < pts.length - 1; i++) out.push([pts[i], pts[i + 1]]);
if (closed && pts.length > 2) out.push([pts[pts.length - 1], pts[0]]);
return out;
}
/**
* Alle Schnittpunkte einer STRECKE a1→a2 mit den Kanten einer Polylinie, nach
* Parameter t auf der Strecke sortiert (für Trim/Split). Endpunkt-Duplikate
* (gleicher Punkt aufeinanderfolgender Kanten) werden zusammengefasst.
*/
export function segmentPolylineHits(
a1: Vec2,
a2: Vec2,
pts: Vec2[],
closed: boolean,
): Hit[] {
const hits: Hit[] = [];
for (const [b1, b2] of polylineEdges(pts, closed)) {
const h = segmentIntersect(a1, a2, b1, b2);
if (h) hits.push(h);
}
hits.sort((p, q) => p.t - q.t);
const dedup: Hit[] = [];
for (const h of hits) {
if (dedup.length === 0 || Math.abs(dedup[dedup.length - 1].t - h.t) > 1e-6) {
dedup.push(h);
}
}
return dedup;
}
// ── Offset (DAS Architektur-Primitiv) ───────────────────────────────────────
/** Offset einer einzelnen Strecke um `d` (links positiv). */
export function offsetSegment(a: Vec2, b: Vec2, d: number): [Vec2, Vec2] {
const n = leftNormal(normalize(sub(b, a)));
const off = scale(n, d);
return [add(a, off), add(b, off)];
}
/**
* Offset einer Polylinie um `d` (links positiv) mit GEHRUNG (miter): jede Kante
* wird parallel verschoben, benachbarte verschobene Kanten werden geschnitten.
* Bei (nahezu) geraden/parallelen Nachbarn wird der verschobene Endpunkt direkt
* genommen. Funktioniert für offene und geschlossene Polylinien; degenerierte
* (zu kurze) Kanten werden übersprungen. Selbstüberschneidungen bei starkem
* Offset werden NICHT bereinigt (genügt für den Architektur-Workflow; ein
* vollständiger Polygon-Offset mit Selbstschnitt-Heilung wäre Tier-2-Arbeit).
*/
export function offsetPolyline(pts: Vec2[], d: number, closed: boolean): Vec2[] {
// Auf signifikante Kanten reduzieren (Duplikate verwerfen).
const clean: Vec2[] = [];
for (const p of pts) {
if (clean.length === 0 || dist(clean[clean.length - 1], p) > EPS) clean.push(p);
}
if (closed && clean.length > 1 && dist(clean[0], clean[clean.length - 1]) <= EPS) {
clean.pop();
}
const n = clean.length;
if (n < 2) return [...pts];
const edges = polylineEdges(clean, closed).map(([a, b]) => offsetSegment(a, b, d));
if (edges.length === 0) return [...pts];
const result: Vec2[] = [];
// Hilfsfunktion: Schnitt zweier (verschobener) Kanten als unendliche Geraden.
const join = (e1: [Vec2, Vec2], e2: [Vec2, Vec2], fallback: Vec2): Vec2 => {
const d1 = sub(e1[1], e1[0]);
const d2 = sub(e2[1], e2[0]);
const p = lineIntersect(e1[0], d1, e2[0], d2);
return p ?? fallback;
};
if (!closed) {
// Offener Zug: erster Punkt = Start der ersten Kante; innere Punkte =
// Schnitt aufeinanderfolgender Kanten; letzter Punkt = Ende der letzten Kante.
result.push(edges[0][0]);
for (let i = 0; i < edges.length - 1; i++) {
result.push(join(edges[i], edges[i + 1], edges[i][1]));
}
result.push(edges[edges.length - 1][1]);
return result;
}
// Geschlossen: jeder Ausgabepunkt = Schnitt von Kante i mit Kante i+1 (zyklisch).
for (let i = 0; i < edges.length; i++) {
const prev = edges[(i - 1 + edges.length) % edges.length];
const curr = edges[i];
result.push(join(prev, curr, curr[0]));
}
return result;
}
// ── Trim / Split ─────────────────────────────────────────────────────────────
/**
* Schneidet eine STRECKE seg=[a1,a2] an allen Schnittpunkten mit den Schneider-
* Kurven (Polylinien) und liefert die resultierenden Teilstrecken. `cutters`
* sind Polylinien (jede mit `closed`-Flag). Reine Geometrie; welche Teile
* behalten/verworfen werden, entscheidet der Aufrufer (z. B. Trim verwirft das
* Stück, das den Pick-Punkt enthält — siehe {@link trimSegment}).
*/
export function splitSegmentByCutters(
a1: Vec2,
a2: Vec2,
cutters: { pts: Vec2[]; closed: boolean }[],
): [Vec2, Vec2][] {
const ts: number[] = [0, 1];
for (const c of cutters) {
for (const h of segmentPolylineHits(a1, a2, c.pts, c.closed)) {
if (h.t > EPS && h.t < 1 - EPS) ts.push(h.t);
}
}
ts.sort((p, q) => p - q);
const da = sub(a2, a1);
const pieces: [Vec2, Vec2][] = [];
for (let i = 0; i < ts.length - 1; i++) {
if (ts[i + 1] - ts[i] < 1e-6) continue;
pieces.push([add(a1, scale(da, ts[i])), add(a1, scale(da, ts[i + 1]))]);
}
return pieces;
}
/**
* Trim: schneidet seg an den Cuttern und VERWIRFT das Teilstück, das `pick`
* am nächsten liegt (das angeklickte „wegzuschneidende" Stück). Liefert die
* verbleibenden Teilstücke (0..n).
*/
export function trimSegment(
a1: Vec2,
a2: Vec2,
cutters: { pts: Vec2[]; closed: boolean }[],
pick: Vec2,
): [Vec2, Vec2][] {
const pieces = splitSegmentByCutters(a1, a2, cutters);
if (pieces.length <= 1) return pieces;
let best = 0;
let bestD = Infinity;
for (let i = 0; i < pieces.length; i++) {
const d = pointSegmentDistance(pick, pieces[i][0], pieces[i][1]);
if (d < bestD) {
bestD = d;
best = i;
}
}
return pieces.filter((_, i) => i !== best);
}
// ── Extend ───────────────────────────────────────────────────────────────────
/**
* Verlängert das gewählte Ende (`end`: "start" → a1, "end" → a2) der Strecke
* a1→a2 bis zur NÄCHSTGELEGENEN Schneider-Kante in Verlängerungsrichtung.
* Liefert die neue Strecke oder null, wenn kein Treffer in der korrekten
* Richtung existiert.
*/
export function extendSegment(
a1: Vec2,
a2: Vec2,
end: "start" | "end",
cutters: { pts: Vec2[]; closed: boolean }[],
): [Vec2, Vec2] | null {
// Richtung der Verlängerung: bei "end" über a2 hinaus (t>1), bei "start"
// über a1 hinaus (t<0). Wir parametrisieren stets entlang a1→a2.
let bestT: number | null = null;
for (const c of cutters) {
for (const [b1, b2] of polylineEdges(c.pts, c.closed)) {
const h = lineSegmentIntersect(a1, a2, b1, b2);
if (!h) continue;
if (end === "end" && h.t > 1 + EPS) {
if (bestT === null || h.t < bestT) bestT = h.t; // nächstgelegen über a2
} else if (end === "start" && h.t < -EPS) {
if (bestT === null || h.t > bestT) bestT = h.t; // nächstgelegen unter a1
}
}
}
if (bestT === null) return null;
const da = sub(a2, a1);
const hitPoint = add(a1, scale(da, bestT));
return end === "end" ? [a1, hitPoint] : [hitPoint, a2];
}
// ── Kreis-Schnitte (für Circle/Arc, 2P/3P/Tangenten) ────────────────────────
/** Schnittpunkte einer unendlichen GERADE a→b mit einem Kreis (0/1/2 Punkte). */
export function lineCircleIntersect(
a: Vec2,
b: Vec2,
center: Vec2,
r: number,
): Vec2[] {
const d = sub(b, a);
const f = sub(a, center);
const A = dot(d, d);
if (A < EPS) return [];
const B = 2 * dot(f, d);
const C = dot(f, f) - r * r;
let disc = B * B - 4 * A * C;
if (disc < -EPS) return [];
if (disc < 0) disc = 0;
const sq = Math.sqrt(disc);
const t1 = (-B - sq) / (2 * A);
const t2 = (-B + sq) / (2 * A);
const out = [add(a, scale(d, t1))];
if (Math.abs(t1 - t2) > EPS) out.push(add(a, scale(d, t2)));
return out;
}
/** Schnittpunkte einer STRECKE a→b mit einem Kreis (nur t ∈ [0,1]). */
export function segmentCircleIntersect(
a: Vec2,
b: Vec2,
center: Vec2,
r: number,
): Vec2[] {
return lineCircleIntersect(a, b, center, r).filter((p) => {
const t = projectParam(p, a, b);
return t >= -EPS && t <= 1 + EPS;
});
}
/** Schnittpunkte zweier Kreise (0/1/2 Punkte). */
export function circleCircleIntersect(
c1: Vec2,
r1: number,
c2: Vec2,
r2: number,
): Vec2[] {
const d = dist(c1, c2);
if (d < EPS) return []; // konzentrisch
if (d > r1 + r2 + EPS || d < Math.abs(r1 - r2) - EPS) return []; // getrennt/innen
const a = (r1 * r1 - r2 * r2 + d * d) / (2 * d);
const h2 = r1 * r1 - a * a;
const h = h2 > 0 ? Math.sqrt(h2) : 0;
const u = normalize(sub(c2, c1));
const mid = add(c1, scale(u, a));
const n = leftNormal(u);
if (h < EPS) return [mid];
return [add(mid, scale(n, h)), add(mid, scale(n, -h))];
}
// ── Polygon-Fläche / Wicklung ───────────────────────────────────────────────
/** Vorzeichenbehaftete Polygonfläche (Shoelace); >0 = CCW, <0 = CW. */
export function signedArea(pts: Vec2[]): number {
let s = 0;
for (let i = 0; i < pts.length; i++) {
const a = pts[i];
const b = pts[(i + 1) % pts.length];
s += a.x * b.y - b.x * a.y;
}
return s / 2;
}
/** Ob ein Polygonzug gegen den Uhrzeigersinn (CCW) gewickelt ist. */
export function isCCW(pts: Vec2[]): boolean {
return signedArea(pts) > 0;
}
// ── Fillet (Eck-Verrundung) ─────────────────────────────────────────────────
/** Ergebnis einer Eck-Verrundung. */
export interface Fillet {
center: Vec2;
radius: number;
/** Tangentenpunkt auf dem ersten Schenkel (corner→p1). */
tangentA: Vec2;
/** Tangentenpunkt auf dem zweiten Schenkel (corner→p2). */
tangentB: Vec2;
/** Bogen-Endwinkel in Radiant (für ein Arc-Primitiv). */
startAngle: number;
endAngle: number;
}
/**
* Verrundet die Ecke bei `corner`, gebildet von den beiden Schenkeln corner→p1
* und corner→p2, mit Radius `r`. Liefert Bogenmittelpunkt + Tangentenpunkte
* (an denen die Schenkel zurückgetrimmt werden) oder null, wenn die Schenkel
* (nahezu) kollinear sind oder zu kurz für den Radius. Standard-Kanten-Fillet
* (Distanz vom Eck = r / tan(θ/2)).
*/
export function filletCorner(
corner: Vec2,
p1: Vec2,
p2: Vec2,
r: number,
): Fillet | null {
const u1 = normalize(sub(p1, corner));
const u2 = normalize(sub(p2, corner));
const cosTheta = Math.max(-1, Math.min(1, dot(u1, u2)));
const theta = Math.acos(cosTheta);
if (theta < 1e-4 || Math.PI - theta < 1e-4) return null; // kollinear
const tanHalf = Math.tan(theta / 2);
if (tanHalf < EPS) return null;
const setback = r / tanHalf; // Abstand Eck → Tangentenpunkt entlang je Schenkel
// Genügend Schenkellänge?
if (setback > len(sub(p1, corner)) + EPS || setback > len(sub(p2, corner)) + EPS) {
return null;
}
const tangentA = add(corner, scale(u1, setback));
const tangentB = add(corner, scale(u2, setback));
// Mittelpunkt liegt auf der Winkelhalbierenden, Abstand r/sin(θ/2) vom Eck.
const bis = normalize(add(u1, u2));
const centerDist = r / Math.sin(theta / 2);
const center = add(corner, scale(bis, centerDist));
const startAngle = Math.atan2(tangentA.y - center.y, tangentA.x - center.x);
const endAngle = Math.atan2(tangentB.y - center.y, tangentB.x - center.x);
return { center, radius: r, tangentA, tangentB, startAngle, endAngle };
}
// ── Split / Join / Segment-Löschen (Drawing2D-Editieren) ─────────────────────
// Reine Geometrie auf Polylinien (`Vec2[]` + `closed`). Die Operations-Schicht
// (`src/editors/splitJoin.ts`) wandelt Drawing2D ↔ {pts, closed} und ruft diese
// Funktionen. Punkt P einer Kante: `lerp(pts[i], pts[i+1], t)`.
/** Lineare Interpolation zweier Punkte. */
const lerp = (a: Vec2, b: Vec2, t: number): Vec2 => add(a, scale(sub(b, a), t));
/**
* Teilt eine Polylinie an EINEM Punkt P = lerp(pts[edgeIndex], pts[edgeIndex+1],
* t) (t ∈ (0,1) entlang der Kante; bei geschlossenen Zügen ist die Schlusskante
* `edgeIndex === pts.length-1`).
* • OFFEN → zwei offene Polylinien [start…P] und [P…ende].
* • GESCHLOSSEN → EINE offene Polylinie, am Punkt P aufgeschnitten (der Zug
* läuft von P einmal um die Schleife zurück nach P).
* Liegt P (nahezu) auf einem vorhandenen Vertex, wird KEIN Duplikat eingefügt.
*/
export function splitPolylineAtParam(
pts: Vec2[],
closed: boolean,
edgeIndex: number,
t: number,
): Vec2[][] {
const n = pts.length;
if (n < 2) return [pts.slice()];
const a = pts[edgeIndex];
const b = pts[(edgeIndex + 1) % n];
const p = lerp(a, b, t);
const atStart = t <= EPS;
const atEnd = t >= 1 - EPS;
if (!closed) {
// Offen: [0..edgeIndex] + P + [edgeIndex+1..n-1].
const left = pts.slice(0, edgeIndex + 1);
if (!atStart && !atEnd) left.push(p);
else if (atEnd) left.push(b); // P == b → b gehört noch links dazu
const right: Vec2[] = [];
if (!atStart && !atEnd) right.push(p);
else if (atStart) right.push(a);
for (let i = edgeIndex + 1; i < n; i++) right.push(pts[i]);
const pieces = [left, right].filter((s) => s.length >= 2);
return pieces.length ? pieces : [pts.slice()];
}
// Geschlossen: an P aufschneiden → eine offene Kette, die einmal herumläuft.
const out: Vec2[] = [];
if (!atStart) out.push(p);
// Ab dem Punkt NACH der Schnittkante einmal komplett um die Schleife.
for (let k = 1; k <= n; k++) {
out.push(pts[(edgeIndex + k) % n]);
}
// out endet bei pts[edgeIndex] (== a); P schließt den Zug am Ende ab.
if (!atStart) out.push(p);
else out.push(a); // bei P==a: a doppelt (Start==Ende) ergäbe eine Schleife;
// dedupe identische aufeinanderfolgende Punkte
return [dedupeConsecutive(out)];
}
/** Entfernt aufeinanderfolgende (nahezu) gleiche Punkte (kein Ringschluss). */
function dedupeConsecutive(pts: Vec2[]): Vec2[] {
const out: Vec2[] = [];
for (const p of pts) {
if (out.length === 0 || !vecEqual(out[out.length - 1], p)) out.push(p);
}
return out;
}
/**
* Teilt ein GESCHLOSSENES Polygon an zwei Randpunkten (auf den Kanten `i` bzw.
* `j`, Parameter `ti`/`tj`) in ZWEI GESCHLOSSENE Polygone: jedes besteht aus dem
* jeweiligen Rand-Bogen plus der Sehne (Verbindung Pi—Pj). Liefert `null`, wenn
* i==j (dieselbe Kante → keine sinnvolle Sehne).
*/
export function splitClosedByChord(
pts: Vec2[],
i: number,
ti: number,
j: number,
tj: number,
): [Vec2[], Vec2[]] | null {
const n = pts.length;
if (n < 3 || i === j) return null;
// Reihenfolge sicherstellen: a vor b entlang des Ringes.
let ia = i, ta = ti, ib = j, tb = tj;
if (i > j) {
ia = j; ta = tj; ib = i; tb = ti;
}
const Pa = lerp(pts[ia], pts[(ia + 1) % n], ta);
const Pb = lerp(pts[ib], pts[(ib + 1) % n], tb);
// Bogen 1: Pa → (Vertices ia+1 … ib) → Pb.
const arc1: Vec2[] = [Pa];
for (let k = ia + 1; k <= ib; k++) arc1.push(pts[k % n]);
arc1.push(Pb);
// Bogen 2: Pb → (Vertices ib+1 … ia, modular) → Pa.
const arc2: Vec2[] = [Pb];
for (let k = ib + 1; k <= ia + n; k++) arc2.push(pts[k % n]);
arc2.push(Pa);
return [dedupeRing(arc1), dedupeRing(arc2)];
}
/**
* Dedupliziert aufeinanderfolgende gleiche Punkte UND einen schließenden
* Duplikat-Endpunkt (Ring: pts[last]==pts[0] → letzten verwerfen).
*/
function dedupeRing(pts: Vec2[]): Vec2[] {
const out = dedupeConsecutive(pts);
if (out.length > 1 && vecEqual(out[0], out[out.length - 1])) out.pop();
return out;
}
/**
* Entfernt das Segment `edgeIndex` aus einer Polylinie. Liefert die neue
* Punktliste plus neues `closed`-Flag:
* • OFFEN: innere Kante → die Polylinie zerfällt in zwei Stücke; hier wird das
* LÄNGERE Stück (mehr Punkte) zurückgegeben (Schere). Erste/letzte Kante →
* einfach den End-Vertex abschneiden.
* • GESCHLOSSEN: die Schleife wird an dieser Kante zur OFFENEN Polylinie
* aufgetrennt (Start = Punkt nach der Kante, einmal herum bis zum Punkt davor).
*/
export function removeSegment(
pts: Vec2[],
closed: boolean,
edgeIndex: number,
): { pts: Vec2[]; closed: boolean } {
const n = pts.length;
if (closed) {
// Auftrennen: Start = (edgeIndex+1) … einmal herum bis edgeIndex.
const out: Vec2[] = [];
for (let k = 1; k <= n; k++) out.push(pts[(edgeIndex + k) % n]);
return { pts: dedupeConsecutive(out), closed: false };
}
// Offen.
if (edgeIndex <= 0) return { pts: pts.slice(1), closed: false };
if (edgeIndex >= n - 1) return { pts: pts.slice(0, n - 1), closed: false };
const left = pts.slice(0, edgeIndex + 1);
const right = pts.slice(edgeIndex + 1);
// Das punktreichere (geometrisch i. d. R. „größere") Teilstück behalten.
return { pts: left.length >= right.length ? left : right, closed: false };
}
/** Ein Schnitt-Treffer auf einer Ziel-Kante: Kantenindex + Parameter + Punkt. */
interface EdgeHit {
edge: number;
t: number;
point: Vec2;
}
/**
* Teilt das Ziel (`targetPts`, `closed`) an allen Schnittpunkten mit den anderen
* Polylinien (`others`). Schnittpunkte entstehen je Kantenpaar via
* {@link segmentIntersect}.
* • GESCHLOSSEN + genau zwei Schnitte → {@link splitClosedByChord} (zwei
* geschlossene Polygone). Bei !=2 Schnitten: unverändert (eine Liste).
* • OFFEN → an den (nach Bogenlänge geordneten) Schnittpunkten in Stücke
* zerschnitten.
* Schnitte an vorhandenen Vertices/Endpunkten werden zusammengefasst.
*/
export function splitAtIntersections(
targetPts: Vec2[],
closed: boolean,
others: Vec2[][],
): Vec2[][] {
const edges = polylineEdges(targetPts, closed);
const hits: EdgeHit[] = [];
for (let ei = 0; ei < edges.length; ei++) {
const [a1, a2] = edges[ei];
for (const o of others) {
// jede Kante jeder anderen Polylinie (offen UND geschlossen behandeln wir
// gleich: die Aufrufer-Schicht liefert `others` bereits als Polylinien-
// Punktlisten; geschlossene Schließkante über vecEqual-Heuristik unnötig,
// da der Aufrufer Endpunkt nicht dupliziert — wir testen offene Kanten).
const oEdges = polylineEdgesAuto(o);
for (const [b1, b2] of oEdges) {
const h = segmentIntersect(a1, a2, b1, b2);
if (!h) continue;
// Nur „echte" innere Schnitte zählen (nicht exakt am Ziel-Vertex 0/1,
// die ohnehin Trennstellen sind).
hits.push({ edge: ei, t: h.t, point: h.point });
}
}
}
// Pro Ziel-Kante nach t sortieren + dedupen.
const dedup: EdgeHit[] = [];
hits.sort((p, q) => (p.edge - q.edge) || (p.t - q.t));
for (const h of hits) {
const prev = dedup[dedup.length - 1];
if (prev && prev.edge === h.edge && Math.abs(prev.t - h.t) < 1e-6) continue;
// Schnitte exakt auf einem Ziel-Vertex (t≈0/1) ignorieren — keine echte Teilung.
if (h.t <= EPS || h.t >= 1 - EPS) continue;
dedup.push(h);
}
if (dedup.length === 0) return [targetPts.slice()];
if (closed) {
if (dedup.length === 2) {
const [h1, h2] = dedup;
const res = splitClosedByChord(targetPts, h1.edge, h1.t, h2.edge, h2.t);
return res ? res : [targetPts.slice()];
}
// Mehr/weniger als 2 Schnitte: geschlossen an allen Schnitten zur offenen
// Polylinie aufgetrennt (am ersten Schnitt aufgeschnitten, dann in Stücke).
// Pragmatisch: am ersten Schnitt aufschneiden, dann wie offen weiter.
const opened = splitPolylineAtParam(targetPts, true, dedup[0].edge, dedup[0].t)[0];
return splitOpenAtHits(opened, dedup.slice(1).map((h) => h.point));
}
// Offen: an den geordneten Schnittpunkten zerschneiden.
return splitOpenByEdgeHits(targetPts, dedup);
}
/** Kanten einer (offenen ODER implizit geschlossenen) Punktliste. */
function polylineEdgesAuto(pts: Vec2[]): [Vec2, Vec2][] {
// Heuristik: ist der erste == letzter Punkt, gilt sie als geschlossen.
const closed = pts.length > 2 && vecEqual(pts[0], pts[pts.length - 1]);
return polylineEdges(closed ? pts.slice(0, -1) : pts, closed);
}
/** Offene Polylinie an gegebenen EdgeHits (auf ihren Kanten) zerschneiden. */
function splitOpenByEdgeHits(pts: Vec2[], hits: EdgeHit[]): Vec2[][] {
const pieces: Vec2[][] = [];
let cur: Vec2[] = [pts[0]];
let hi = 0;
for (let ei = 0; ei < pts.length - 1; ei++) {
while (hi < hits.length && hits[hi].edge === ei) {
const p = hits[hi].point;
cur.push(p);
pieces.push(cur);
cur = [p];
hi++;
}
cur.push(pts[ei + 1]);
}
pieces.push(cur);
return pieces.map(dedupeConsecutive).filter((s) => s.length >= 2);
}
/** Offene Polylinie an einer Liste von Schnitt-PUNKTEN (auf dem Zug) zerschneiden. */
function splitOpenAtHits(pts: Vec2[], cutPoints: Vec2[]): Vec2[][] {
if (cutPoints.length === 0) return [pts.slice()];
const hits: EdgeHit[] = [];
for (let ei = 0; ei < pts.length - 1; ei++) {
const a = pts[ei];
const b = pts[ei + 1];
for (const cp of cutPoints) {
const t = projectParam(cp, a, b);
if (t > EPS && t < 1 - EPS && pointSegmentDistance(cp, a, b) < 1e-6) {
hits.push({ edge: ei, t, point: cp });
}
}
}
hits.sort((p, q) => (p.edge - q.edge) || (p.t - q.t));
return splitOpenByEdgeHits(pts, hits);
}
/**
* Verschmilzt Polylinien an koinzidenten Endpunkten zu längeren Ketten. Jeder
* Eintrag ist `{pts, closed}`. Bereits geschlossene Ketten bleiben unverändert.
* Offene Ketten werden an gemeinsamen Endpunkten (vecEqual) aneinandergehängt;
* schließt sich eine Kette (erster == letzter Punkt), wird sie als `closed`
* markiert (der Schlusspunkt-Duplikat entfernt). Reihenfolge-/Richtungs-stabil
* genug für den Editier-Workflow (kein Verzweigungs-Handling: Knoten mit >2
* Enden werden greedy verbunden).
*/
export function joinChains(
polylines: { pts: Vec2[]; closed: boolean }[],
): { pts: Vec2[]; closed: boolean }[] {
const closedOut: { pts: Vec2[]; closed: boolean }[] = [];
// Offene Ketten als veränderbare Punktlisten sammeln (geschlossene bleiben).
const open: Vec2[][] = [];
for (const pl of polylines) {
if (pl.closed) closedOut.push({ pts: pl.pts.slice(), closed: true });
else if (pl.pts.length >= 2) open.push(pl.pts.slice());
else if (pl.pts.length === 1) open.push(pl.pts.slice());
}
let merged = true;
while (merged) {
merged = false;
outer: for (let i = 0; i < open.length; i++) {
for (let j = i + 1; j < open.length; j++) {
const A = open[i];
const B = open[j];
const aS = A[0], aE = A[A.length - 1];
const bS = B[0], bE = B[B.length - 1];
let combined: Vec2[] | null = null;
if (vecEqual(aE, bS)) combined = [...A, ...B.slice(1)];
else if (vecEqual(aE, bE)) combined = [...A, ...B.slice(0, -1).reverse()];
else if (vecEqual(aS, bE)) combined = [...B, ...A.slice(1)];
else if (vecEqual(aS, bS)) combined = [...B.slice().reverse(), ...A.slice(1)];
if (combined) {
open.splice(j, 1);
open.splice(i, 1, combined);
merged = true;
break outer;
}
}
}
}
const out: { pts: Vec2[]; closed: boolean }[] = [...closedOut];
for (const chain of open) {
if (chain.length > 2 && vecEqual(chain[0], chain[chain.length - 1])) {
out.push({ pts: chain.slice(0, -1), closed: true });
} else {
out.push({ pts: chain, closed: false });
}
}
return out;
}
+500
View File
@@ -0,0 +1,500 @@
// Deutsches Wörterbuch (Default-Sprache). Die Werte hier sind EXAKT die bisher
// sichtbaren UI-Texte — die i18n-Umstellung darf optisch nichts ändern.
//
// Identifier/Keys sind englisch und namespaced wie bei DOSSIER
// (z. B. `topbar.grundriss`); nur die Werte sind übersetzbarer Text.
// Platzhalter in geschweiften Klammern (`{name}`) werden von t(...) ersetzt.
export const de = {
// ── Marke / Topbar ───────────────────────────────────────────────────────
"brand.tag": "Dokumentmodell",
"topbar.viewGroup": "Ansicht",
"topbar.grundriss": "Grundriss",
"topbar.perspektive": "Perspektive",
"view3d.group": "Ansicht 3D",
"view3d.front": "Front",
"view3d.front.hint": "Frontalansicht von vorne",
"view3d.top": "Oben",
"view3d.top.hint": "Draufsicht (senkrecht von oben)",
"view3d.side": "Seite",
"view3d.side.hint": "Seitenansicht (90° zur Front)",
"view3d.perspective": "Perspektive",
"view3d.perspective.hint": "Freie Perspektive (3/4-Blick)",
"view3d.iso": "Isometrie",
"view3d.iso.hint": "Isometrische Ansicht (gleichwinklig von vorne-oben-rechts)",
"view3d.camera": "Kamera",
"view3d.camera.hint": "Kamera-Einstellungen (Sichtwinkel)",
"view3d.fov": "Sichtwinkel",
"view3d.fov.hint": "Sichtwinkel (FOV) der Perspektivkamera in Grad",
"topbar.detailGroup": "Detailgrad",
"topbar.detail": "Detailgrad",
"topbar.detail.hint": "Detailgrad der Plandarstellung (Symbole, Schraffuren, Linien)",
"topbar.detail.hintDisabled": "Detailgrad wirkt im Grundriss (in der Perspektive noch nicht)",
"topbar.detail.grob": "grob",
"topbar.detail.mittel": "mittel",
"topbar.detail.fein": "fein",
"topbar.scaleGroup": "Massstab und Zoom",
"topbar.scale": "Massstab",
"topbar.scale.apply": "Papier-Massstab 1:N anwenden",
"topbar.scale.custom": "frei …",
"topbar.scale.live": "Aktueller Papier-Massstab (live)",
"topbar.scale.prompt": "Massstab eingeben (z. B. 1:35 oder 35):",
"topbar.zoom100": "100%",
"topbar.zoom100.hint": "Zoom exakt auf den gewählten Massstab 1:{n}",
"topbar.fit": "Einpassen",
"topbar.fit.hint": "Auf Plan einpassen",
"topbar.fitSelection": "Auswahl",
"topbar.fitSelection.hint": "Auf Auswahl einpassen (sonst auf Plan)",
"topbar.zoom.current": "Aktueller Zoom",
"topbar.renderGroup": "Render-Modus",
"topbar.render.shaded": "Schattiert",
"topbar.render.shaded.hint": "Schattiert",
"topbar.render.wireframe": "Draht",
"topbar.render.wireframe.hint": "Drahtgitter",
"topbar.render.hidden": "Kanten",
"topbar.render.hidden.hint": "Verdeckte Kanten (Hidden-Line)",
"topbar.render.hintDisabled": "Render-Modus wirkt in der Perspektive",
// Darstellungsart (kontextabhängiger Dropdown): Grundriss = Farbig/SW,
// Perspektive = Schattiert/Weiss/Draht/Kanten.
"display.style": "Darstellung",
"display.style.group": "Darstellungsart",
"display.style.hint": "Darstellungsart der aktiven Ansicht",
"display.style.color": "Farbig",
"display.style.mono": "Schwarz-Weiss",
"display.style.shaded": "Schattiert",
"display.style.white": "Weiss",
"display.style.wireframe": "Drahtgitter",
"display.style.hidden": "Kanten",
"topbar.referenceLinesGroup": "Referenzlinien",
"topbar.referenceLines": "Referenzlinien",
"topbar.referenceLines.hint": "Referenzlinien (Wandachsen) ein-/ausblenden",
"topbar.referenceLines.hintDisabled": "Referenzlinien wirken im Grundriss",
"topbar.resources": "Ressourcen",
"topbar.resources.hint": "Ressourcen-Fenster ein-/ausblenden",
// ── Layout-Menü (Topbar) ─────────────────────────────────────────────────
"layout.title": "Layout",
"layout.placeholder": "Layout …",
"layout.saveCurrent": "+ Aktuelles speichern …",
"layout.loadGroup": "Laden",
"layout.deleteGroup": "Löschen",
"layout.savePrompt": "Layout speichern als:",
// ── Sichtbarkeits-Kombinationen (Topbar) ─────────────────────────────────
"combo.layers": "Ebenen-Komb.",
"combo.drawings": "Zeichn.-Komb.",
"combo.placeholder": "Kombination …",
"combo.saveCurrent": "+ Aktuelle speichern …",
"combo.loadGroup": "Laden",
"combo.deleteGroup": "Löschen",
"combo.layers.title": "Ebenenkombinationen",
"combo.drawings.title": "Zeichnungskombinationen",
"combo.layers.savePrompt": "Ebenenkombination speichern als:",
"combo.drawings.savePrompt": "Zeichnungskombination speichern als:",
// ── Zeichenwerkzeuge (Topbar-Werkzeugleiste + Statushinweise) ────────────
"tool.group": "Werkzeuge",
"tool.select": "Auswahl",
"tool.wall": "Wand",
"tool.line": "Linie",
"tool.polyline": "Polylinie",
"tool.rect": "Rechteck",
"tool.select.hint": "Auswahl — Elemente klicken/aufziehen",
"tool.wall.firstPoint": "Wand: ersten Achspunkt setzen",
"tool.wall.nextPoint": "Wand: nächsten Punkt — Doppelklick/Rechtsklick beendet",
"tool.line.firstPoint": "Linie: Startpunkt setzen",
"tool.line.secondPoint": "Linie: Endpunkt setzen",
"tool.polyline.firstPoint": "Polylinie: ersten Punkt setzen",
"tool.polyline.nextPoint": "Polylinie: nächster Punkt — Klick auf Start schließt, Doppelklick beendet",
"tool.rect.firstCorner": "Rechteck: erste Ecke setzen",
"tool.rect.secondCorner": "Rechteck: gegenüberliegende Ecke setzen",
"tool.wallType": "Wandtyp",
"tool.layer": "Ebene",
"tool.layer.hint": "Aktive Ebene — alles Gezeichnete kommt auf diese Kategorie",
"tool.floorOnlyDisabled": "Wand nur auf einem Geschoss zeichenbar",
"tool.snap": "Fang",
"tool.snap.hint": "Fang an Endpunkten/Raster; Shift = Ortho, Ctrl = Fang aus",
"snap.enabled": "Fang aktiv",
"snap.endpoint": "Endpunkt",
"snap.midpoint": "Mittelpunkt",
"snap.intersection": "Schnittpunkt",
"snap.onEdge": "Auf Kante (Lot)",
"snap.grid": "Raster",
"snap.ortho": "Ortho (Shift)",
"snap.gridSize": "Rasterweite (m)",
"snap.angleStep": "Winkelraster",
// ── Transformation (Bewegen/Spiegeln/Drehen) + Modusleiste ───────────────
"transform.bar": "Transformation",
"transform.cancel": "Abbrechen (Esc)",
"transform.countPrompt": "Anzahl Kopien:",
"transform.move": "Bewegen (M)",
"transform.mirror": "Spiegeln (S)",
"transform.rotate": "Drehen (D)",
"transform.mode.move": "Bewegen",
"transform.mode.copy": "Kopie",
"transform.mode.array": "N Kopien",
"transform.mode.distribute": "Verteilen",
"transform.hint.move.0": "Basispunkt klicken",
"transform.hint.move.1": "Zielpunkt klicken",
"transform.hint.rotate.0": "Drehzentrum klicken",
"transform.hint.rotate.1": "Bezugspunkt (Startwinkel) klicken",
"transform.hint.rotate.2": "Zielwinkel klicken",
"transform.hint.mirror.0": "Erster Punkt der Spiegelachse",
"transform.hint.mirror.1": "Zweiter Punkt der Spiegelachse",
// ── Linien-Darstellung (Display/Print) ───────────────────────────────────
"topbar.lineModeGroup": "Linien-Darstellung",
"topbar.lineMode.display": "Display",
"topbar.lineMode.print": "Print",
"topbar.lineMode.display.hint": "Display: alle Linien als konstante Haarlinie (ruhiges Arbeiten)",
"topbar.lineMode.print.hint": "Print: echte mm-Strichstärken (Papier-/Druckbild)",
// ── Statusleiste ─────────────────────────────────────────────────────────
"status.selection": "Auswahl",
"status.selectionN": "Auswahl: {n} {noun}",
"status.wall": "Wand",
"status.walls": "Wände",
"status.xy": "X/Y",
"status.unit": "Einheit",
"status.scale": "Massstab",
"status.zoom": "Zoom",
"status.floor": "Geschoss",
"status.layer": "Ebene",
"status.none": "—",
// ── Navigator-Panels ─────────────────────────────────────────────────────
"nav.drawingLevels": "Zeichnungsebenen",
"nav.layers": "Ebenen",
"nav.tools": "Werkzeuge",
"nav.attributes": "Attribute",
"nav.objectInfo": "Objekt-Info",
// Attribute-Palette (AttributesPanel)
"attr.title": "Attribute",
"attr.empty": "Keine Auswahl",
"attr.stroke": "Stift",
"attr.color": "Farbe",
"attr.weight": "Strichstärke (mm)",
"attr.lineStyle": "Linienstil",
"attr.fill": "Füllung",
"attr.fillColor": "Füllfarbe",
"attr.clearFill": "Füllfarbe entfernen",
"attr.hatch": "Schraffur",
"attr.none": "keine",
"attr.inheritedFromLayer": "Wände erben die Strichstärke aus der Ebene",
"attr.fillNeedsClosed": "Füllung nur für geschlossene Formen",
"attr.size": "Maße",
"attr.width": "Breite",
"attr.height": "Höhe",
// Objekt-Info-Palette (ObjectInfoPanel)
"objinfo.title": "Objekt-Info",
"objinfo.empty": "Kein Objekt gewählt",
"objinfo.refpoint": "Bezugspunkt",
"objinfo.setRefpoint": "Bezugspunkt wählen",
"objinfo.x": "X",
"objinfo.y": "Y",
"objinfo.z": "Z",
"objinfo.zHint": "2D-Ebene – keine Höhe am Element",
"objinfo.dimensions": "Maße",
"objinfo.width": "Breite",
"objinfo.height": "Höhe",
"objinfo.type": "Typ",
"objinfo.kind.wall": "Wand",
"objinfo.kind.drawing2d": "2D-Objekt",
"objinfo.kind.door": "Tür",
// ── Wand-Attribute (Object-Info-Panel) ──────────────────────────────────
"objinfo.wall.section": "Wand",
"objinfo.wall.refLine": "Referenzlinie",
"objinfo.wall.refLine.left": "Außen (links)",
"objinfo.wall.refLine.center": "Mitte",
"objinfo.wall.refLine.right": "Innen (rechts)",
"objinfo.wall.buildup": "Aufbau",
"objinfo.wall.single": "Einschichtig",
"objinfo.wall.multi": "Mehrschichtig",
"objinfo.wall.thickness": "Dicke",
"objinfo.wall.preset": "Wandaufbau",
"objinfo.wall.presetLabel": "{name} · {thickness} m",
"objinfo.wall.refFloor": "Referenzgeschoss",
"objinfo.wall.linkAbove": "An oberes Geschoss binden",
"objinfo.wall.bottom": "Unterkante (UK)",
"objinfo.wall.top": "Oberkante (OK)",
"objinfo.wall.anchorFloor": "An Geschoss gebunden",
"objinfo.wall.anchorCustom": "Eigene Höhe",
"objinfo.wall.height": "Höhe",
"objinfo.wall.floorAboveNone": "Kein oberes Geschoss",
"levels.addFloor": "+ Geschoss",
"levels.addDrawing": "+ Zeichnung",
"layers.addCategory": "+ Ebene",
"nav.visibility": "Sichtbarkeit",
"nav.hide": "Ausblenden",
"nav.show": "Einblenden",
"nav.categoryVisible": "{name} sichtbar",
"nav.categorySelect": "{name} als aktive Ebene wählen (Zeichen-Ziel)",
// ── Badges (Art einer Zeichnungsebene) ───────────────────────────────────
"badge.floor": "Geschoss",
"badge.section": "Schnitt",
"badge.elevation": "Ansicht",
"badge.drawing": "Zeichnung",
// ── Darstellungsmodus-Umschalter ─────────────────────────────────────────
"display.title": "Darstellungsmodus",
"display.all_force": "Alle anzeigen",
"display.all": "Ausgewählte",
"display.active": "Nur aktive",
"display.grey": "Andere grau",
"display.grey_locked": "Andere grau & gesperrt",
// ── Dock ─────────────────────────────────────────────────────────────────
"dock.resize": "Breite anpassen",
"dock.dock": "Andocken",
"dock.close": "Schließen",
"dock.float": "Lösen",
"dock.redock": "Andocken",
"dock.resizePanel": "Größe ändern",
"dock.dropLeft": "Links andocken",
"dock.dropRight": "Rechts andocken",
// ── Kontextmenü (Einträge) ───────────────────────────────────────────────
"ctx.layerSettings": "Ebeneneinstellungen…",
"ctx.addSubLayer": "Sub-Ebene hinzufügen…",
"ctx.assignSelection": "Selektion hierher übertragen",
"ctx.assignSelection.disabled": "Keine Wand ausgewählt — zuerst im Grundriss/3D eine Wand anklicken.",
"ctx.duplicate": "Duplizieren",
"ctx.copyProps": "Eigenschaften kopieren",
"ctx.pasteProps": "Eigenschaften einfügen",
"ctx.pasteProps.disabled": "Zwischenablage leer — zuerst „Eigenschaften kopieren“.",
"ctx.delete": "Löschen",
"ctx.delete.lastLayer": "Die letzte Ebene kann nicht gelöscht werden.",
"ctx.delete.lastLevel": "Die letzte Zeichnungsebene kann nicht gelöscht werden.",
"ctx.levelSettings": "Einstellungen…",
"ctx.fitSelection": "Auf Auswahl einpassen",
"ctx.clearSelection": "Auswahl aufheben",
"ctx.fit": "Ansicht einpassen",
"ctx.noWallHit": "Keine Wand getroffen",
"ctx.noWallHit.hint": "Rechtsklick auf eine Wand wählt sie und zeigt Wand-Aktionen.",
// Kontextmenü-Titel je Quelle.
"ctx.title.level": "Zeichnungsebene",
"ctx.title.wall": "Wand",
"ctx.title.view": "Ansicht",
// ── Inline-Editor (Ebene / Geschoss) ─────────────────────────────────────
"editor.close": "Schließen",
"editor.layerTitle": "Ebene {code}",
"editor.name": "Name",
"editor.color": "Farbe",
"editor.lineWeight": "Linienstärke",
"editor.floorHeight": "Geschosshöhe",
"editor.cutHeight": "Schnitthöhe",
"editor.unit.mm": "mm",
"editor.unit.m": "m",
"editor.noHeightDrawing": "Für Zeichnungen gibt es keine Geschoss-/Schnitthöhe.",
"editor.noHeightSection": "Für Schnitte/Ansichten gibt es keine Geschoss-/Schnitthöhe.",
// ── Hauptinhalt (Panes / Eigenschaften) ──────────────────────────────────
"content.perspektiveTitle": "Perspektive · {name}",
"content.grundrissTitle": "Grundriss · {name}",
"content.grundrissLegend": "Grundriss · {name} · Schnitthöhe {cutHeight}",
"content.drawingTitle": "Zeichnung · {name}",
"content.drawingCanvas": "Zeichenfläche",
"content.drawingLegend": "Freie 2D-Zeichnung",
"props.floorHeight": "Geschosshöhe",
"props.cutHeight": "Schnitthöhe",
"props.okff": "OKFF",
"props.okffValue": "+{value} m",
// ── Stub (Schnitt / Ansicht) ─────────────────────────────────────────────
"stub.section": "Schnitt",
"stub.elevation": "Ansicht",
"stub.note": "{kind} — abgeleitete Projektion folgt in einer späteren Phase",
// ── Default-Namen neuer Elemente ─────────────────────────────────────────
"default.floorName": "Geschoss {n}",
"default.drawingName": "Zeichnung {n}",
"default.categoryName": "Neue Ebene",
"default.subCategoryName": "Neue Sub-Ebene",
"default.componentName": "Neues Bauteil",
"default.hatchName": "Neue Schraffur",
"default.lineStyleName": "Neuer Linienstil",
"default.hatchNone": "Ohne",
"default.copySuffixLevel": "{name} Kopie",
"default.copySuffixLayer": "{name} KOPIE",
"default.wallTypeSingle": "Wand {thickness} m",
// ── Warnungen (Lösch-Schutz) ─────────────────────────────────────────────
"alert.layerInUse": "Ebene „{code} {name}\" wird von Bauteilen verwendet und kann nicht gelöscht werden.",
"alert.componentInUse": "Bauteil „{name}\" wird von einem Wandtyp verwendet und kann nicht gelöscht werden.",
"alert.hatchInUse": "Schraffur „{name}\" wird von einem Bauteil verwendet und kann nicht gelöscht werden.",
"alert.lineStyleInUse": "Linienstil „{name}\" wird von einer Schraffur verwendet und kann nicht gelöscht werden.",
// ── Ressourcen-Manager ───────────────────────────────────────────────────
"resources.title": "Ressourcen",
"resources.label": "Ressourcen-Manager",
"resources.close": "Schließen",
"resources.tab.components": "Bauteile",
"resources.tab.hatches": "Schraffuren",
"resources.tab.lines": "Linien",
"resources.add": "Neu",
"resources.import.lin": "⤓ .lin importieren",
"resources.import.pat": "⤓ .pat importieren",
// Bauteile.
"resources.components.hint": "Höhere Priorität läuft am Stoß durch (verschneidet zuerst); niedrigere stößt seitlich an.",
"resources.components.empty": "Noch keine Bauteile.",
"resources.components.placeholder": "Bauteil",
"resources.col.name": "Name",
"resources.col.color": "Farbe",
"resources.col.hatch": "Schraffur",
"resources.col.prio": "Prio",
"resources.col.prio.hint": "Höhere Priorität läuft am Stoß durch",
"resources.col.texture": "Textur",
"resources.delete.component": "Bauteil „{name}\" löschen",
// 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.",
"resources.hatches.placeholder": "Schraffur",
"resources.col.pattern": "Muster",
"resources.col.scale": "Maßstab",
"resources.col.angle": "Winkel °",
"resources.col.lineStyle": "Linienstil",
"resources.lineStyle.none": "— ohne —",
"resources.delete.hatch": "Schraffur „{name}\" löschen",
// Schraffur-Muster.
"pattern.none": "Ohne",
"pattern.solid": "Vollfläche",
"pattern.insulation": "Wärmedämmung",
"pattern.diagonal": "Diagonal",
"pattern.crosshatch": "Kreuzschraffur",
// Linien.
"resources.lines.hint": "Strichstärke in Millimetern (Paper-Space). Strichmuster: durchgezogen, gestrichelt oder gepunktet.",
"resources.lines.empty": "Noch keine Linienstile.",
"resources.lines.placeholder": "Linienstil",
"resources.col.weight": "Stärke (mm)",
"resources.col.stroke": "Strich",
"resources.col.preview": "Vorschau",
"resources.delete.lineStyle": "Linienstil „{name}\" löschen",
"resources.texture.empty": "—",
// Strichmuster.
"dash.solid": "Durchgezogen",
"dash.dashed": "Gestrichelt",
"dash.dotted": "Gepunktet",
// ── Command-Line / Befehle (Rhino-artiges Befehlssystem) ──────────────────
"cmd.aria": "Befehlszeile",
"cmd.idle.prompt": "Befehl:",
"cmd.idle.placeholder": "Befehl eingeben … (Tab)",
"cmd.line.label": "Linie",
"cmd.line.start": "Startpunkt der Linie:",
"cmd.line.end": "Endpunkt der Linie:",
"cmd.polyline.label": "Polylinie",
"cmd.polyline.start": "Startpunkt:",
"cmd.polyline.next": "Nächster Punkt:",
"cmd.polyline.close": "Schliessen",
"cmd.polyline.undo": "Zurück",
"cmd.rect.label": "Rechteck",
"cmd.rect.first": "Erste Ecke:",
"cmd.rect.second": "Gegenüberliegende Ecke:",
"cmd.circle.label": "Kreis",
"cmd.circle.center": "Mittelpunkt:",
"cmd.circle.radius": "Radius:",
// Editierbefehle (Tier 1): Move/Copy/Offset operieren auf der Auswahl.
"cmd.move.label": "Verschieben",
"cmd.move.base": "Basispunkt:",
"cmd.move.target": "Zielpunkt:",
"cmd.move.empty": "Nichts gewählt (erst Objekte wählen).",
"cmd.copy.label": "Kopieren",
"cmd.copy.base": "Basispunkt:",
"cmd.copy.target": "Zielpunkt (Enter beendet):",
"cmd.copy.empty": "Nichts gewählt (erst Objekte wählen).",
"cmd.offset.label": "Versatz",
"cmd.offset.pick": "Kurve wählen:",
"cmd.offset.side": "Seite oder Distanz:",
// Editier-Werkzeuge Split / Join / Segment-Löschen (Tastatur: Ctrl+S / Ctrl+J /
// Alt+Klick). Labels für Befehlsleiste/Menüs.
"edit.split.label": "Teilen",
"edit.join.label": "Verbinden",
"edit.cutSegment.label": "Segment löschen",
// Tab-Feld-Zyklus (Präzisionseingabe): geordnete Zahlenfelder je Schritt.
"cmd.field.length": "Länge",
"cmd.field.angle": "Winkel",
"cmd.field.width": "Breite",
"cmd.field.height": "Höhe",
"cmd.field.radius": "Radius",
"cmd.field.distance": "Distanz",
// ── Befehle: Import / Gelände ────────────────────────────────────────────
"cmd.import.label": "Importieren",
"cmd.import.prompt": "DXF-Datei wählen …",
"cmd.terrain.label": "Gelände",
"cmd.terrain.prompt": "Gelände aus Konturen erzeugen …",
// ── Site-/Kontext-Panel (SitePanel) ──────────────────────────────────────
"nav.site": "Gelände",
"site.title": "Gelände & Kontext",
"site.import": "⤓ DXF importieren",
"site.generateTerrain": "Gelände erzeugen",
"site.contourSet": "Kontur-Satz",
"site.empty": "Kein Kontext geladen. Importiere eine DXF-Datei (Höhenlinien oder Mesh).",
"site.delete": "Entfernen",
"site.type.mesh": "Mesh",
"site.type.contours": "Konturen",
"site.type.terrain": "Gelände",
"site.dwgHint": "DWG? Zuerst mit dem gratis ODA File Converter in DXF umwandeln.",
"site.terrainFailed": "Kein Gelände erzeugbar (zu wenige/kollineare Konturpunkte).",
// ── Import-Dialog (ImportDialog) ─────────────────────────────────────────
"import.title": "DXF importieren",
"import.close": "Schließen",
"import.file": "Datei",
"import.summary.heading": "Inhalt",
"import.summary.contours": "{n} Konturen",
"import.summary.meshes": "{n} Meshes",
"import.summary.layers": "Layer: {layers}",
"import.summary.noLayers": "keine Layer-Namen",
"import.summary.empty": "Keine importierbare 2D-Geometrie gefunden.",
"import.target.heading": "Ziel-Zeichnungsebene",
"import.target.existing": "In bestehende Zeichnungsebene importieren",
"import.target.new": "Neue Zeichnung anlegen",
"import.target.newNamePlaceholder": "Name der neuen Zeichnung",
"import.layers.heading": "Ebenen-Handling (DXF-Layer)",
"import.layers.asCategories": "DXF-Layer als Kategorien übernehmen",
"import.layers.toActive": "Alles auf die aktive Kategorie ({code})",
"import.mesh.heading": "Meshes",
"import.mesh.asContext": "Meshes als Kontext-Geometrie (3D) importieren",
"import.mesh.note": "Meshes sind keine 2D-Zeichnung — sie landen in der Kontext-Schicht.",
"import.dwg.heading": "DWG-Datei",
"import.dwg.loading": "DWG wird gelesen …",
"import.dwg.hint":
"DWG konnte nicht gelesen werden. Wandle die Datei alternativ mit dem gratis ODA File Converter in DXF um und importiere die DXF-Datei.",
"import.confirm": "Importieren",
"import.cancel": "Abbrechen",
"import.badge.floor": "Geschoss",
"import.badge.section": "Schnitt",
"import.badge.elevation": "Ansicht",
"import.badge.drawing": "Zeichnung",
// ── Drag & Drop (App-weiter Datei-Drop) ──────────────────────────────────
"drop.hint": "DXF/DWG-Datei hier ablegen",
// ── Fehler-Boundary ──────────────────────────────────────────────────────
"error.console": "Unerwarteter Fehler:",
"error.title": "Etwas ist schiefgelaufen",
"error.body": "Ein unerwarteter Fehler ist aufgetreten. Details in der Konsole.",
"error.reload": "Neu laden",
} as const;
export type TranslationKey = keyof typeof de;
+490
View File
@@ -0,0 +1,490 @@
// English dictionary — proves translatability. Same keys as de.ts; only the
// values differ. Identifiers/keys stay English (DOSSIER namespacing); values are
// the translated, user-visible text.
import type { TranslationKey } from "./de";
export const en: Record<TranslationKey, string> = {
// Brand / Topbar.
"brand.tag": "Document model",
"topbar.viewGroup": "View",
"topbar.grundriss": "Plan",
"topbar.perspektive": "Perspective",
"view3d.group": "3D view",
"view3d.front": "Front",
"view3d.front.hint": "Front view (frontal)",
"view3d.top": "Top",
"view3d.top.hint": "Top view (straight down)",
"view3d.side": "Side",
"view3d.side.hint": "Side view (90° to front)",
"view3d.perspective": "Perspective",
"view3d.perspective.hint": "Free perspective (3/4 view)",
"view3d.iso": "Isometric",
"view3d.iso.hint": "Isometric view (equiangular from front-top-right)",
"view3d.camera": "Camera",
"view3d.camera.hint": "Camera settings (field of view)",
"view3d.fov": "Field of view",
"view3d.fov.hint": "Field of view (FOV) of the perspective camera in degrees",
"topbar.detailGroup": "Detail level",
"topbar.detail": "Detail level",
"topbar.detail.hint": "Detail level of the plan (symbols, hatches, lines)",
"topbar.detail.hintDisabled": "Detail level applies in the plan (not yet in perspective)",
"topbar.detail.grob": "coarse",
"topbar.detail.mittel": "medium",
"topbar.detail.fein": "fine",
"topbar.scaleGroup": "Scale and zoom",
"topbar.scale": "Scale",
"topbar.scale.apply": "Apply paper scale 1:N",
"topbar.scale.custom": "custom …",
"topbar.scale.live": "Current paper scale (live)",
"topbar.scale.prompt": "Enter scale (e.g. 1:35 or 35):",
"topbar.zoom100": "100%",
"topbar.zoom100.hint": "Zoom exactly to the chosen scale 1:{n}",
"topbar.fit": "Fit",
"topbar.fit.hint": "Fit to plan",
"topbar.fitSelection": "Selection",
"topbar.fitSelection.hint": "Fit to selection (otherwise to plan)",
"topbar.zoom.current": "Current zoom",
"topbar.renderGroup": "Render mode",
"topbar.render.shaded": "Shaded",
"topbar.render.shaded.hint": "Shaded",
"topbar.render.wireframe": "Wire",
"topbar.render.wireframe.hint": "Wireframe",
"topbar.render.hidden": "Edges",
"topbar.render.hidden.hint": "Hidden line",
"topbar.render.hintDisabled": "Render mode applies in perspective",
// Display style (context-dependent dropdown): plan = Color/Mono,
// perspective = Shaded/White/Wireframe/Hidden.
"display.style": "Display",
"display.style.group": "Display style",
"display.style.hint": "Display style of the active view",
"display.style.color": "Color",
"display.style.mono": "Black & white",
"display.style.shaded": "Shaded",
"display.style.white": "White",
"display.style.wireframe": "Wireframe",
"display.style.hidden": "Hidden line",
"topbar.referenceLinesGroup": "Reference lines",
"topbar.referenceLines": "Reference lines",
"topbar.referenceLines.hint": "Show/hide reference lines (wall axes)",
"topbar.referenceLines.hintDisabled": "Reference lines apply in the plan",
"topbar.resources": "Resources",
"topbar.resources.hint": "Show/hide the resources window",
// Layout menu.
"layout.title": "Layout",
"layout.placeholder": "Layout …",
"layout.saveCurrent": "+ Save current …",
"layout.loadGroup": "Load",
"layout.deleteGroup": "Delete",
"layout.savePrompt": "Save layout as:",
// Visibility combinations (topbar).
"combo.layers": "Layer comb.",
"combo.drawings": "Drawing comb.",
"combo.placeholder": "Combination …",
"combo.saveCurrent": "+ Save current …",
"combo.loadGroup": "Load",
"combo.deleteGroup": "Delete",
"combo.layers.title": "Layer combinations",
"combo.drawings.title": "Drawing combinations",
"combo.layers.savePrompt": "Save layer combination as:",
"combo.drawings.savePrompt": "Save drawing combination as:",
// Drawing tools (topbar toolbar + status hints).
"tool.group": "Tools",
"tool.select": "Select",
"tool.wall": "Wall",
"tool.line": "Line",
"tool.polyline": "Polyline",
"tool.rect": "Rectangle",
"tool.select.hint": "Select — click/drag elements",
"tool.wall.firstPoint": "Wall: set first axis point",
"tool.wall.nextPoint": "Wall: next point — double/right-click to finish",
"tool.line.firstPoint": "Line: set start point",
"tool.line.secondPoint": "Line: set end point",
"tool.polyline.firstPoint": "Polyline: set first point",
"tool.polyline.nextPoint": "Polyline: next point — click start to close, double-click to finish",
"tool.rect.firstCorner": "Rectangle: set first corner",
"tool.rect.secondCorner": "Rectangle: set opposite corner",
"tool.wallType": "Wall 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",
"tool.snap": "Snap",
"tool.snap.hint": "Snap to endpoints/grid; Shift = ortho, Ctrl = snap off",
"snap.enabled": "Snap on",
"snap.endpoint": "Endpoint",
"snap.midpoint": "Midpoint",
"snap.intersection": "Intersection",
"snap.onEdge": "On edge (perp.)",
"snap.grid": "Grid",
"snap.ortho": "Ortho (Shift)",
"snap.gridSize": "Grid size (m)",
"snap.angleStep": "Angle step",
// Transform (move/mirror/rotate) + mode bar.
"transform.bar": "Transform",
"transform.cancel": "Cancel (Esc)",
"transform.countPrompt": "Number of copies:",
"transform.move": "Move (M)",
"transform.mirror": "Mirror (S)",
"transform.rotate": "Rotate (D)",
"transform.mode.move": "Move",
"transform.mode.copy": "Copy",
"transform.mode.array": "N copies",
"transform.mode.distribute": "Distribute",
"transform.hint.move.0": "Click base point",
"transform.hint.move.1": "Click destination point",
"transform.hint.rotate.0": "Click rotation centre",
"transform.hint.rotate.1": "Click reference point (start angle)",
"transform.hint.rotate.2": "Click target angle",
"transform.hint.mirror.0": "First point of mirror axis",
"transform.hint.mirror.1": "Second point of mirror axis",
// Line display (Display/Print).
"topbar.lineModeGroup": "Line display",
"topbar.lineMode.display": "Display",
"topbar.lineMode.print": "Print",
"topbar.lineMode.display.hint": "Display: all lines as constant hairlines (calm editing)",
"topbar.lineMode.print.hint": "Print: real mm line weights (paper/print look)",
// Status bar.
"status.selection": "Selection",
"status.selectionN": "Selection: {n} {noun}",
"status.wall": "wall",
"status.walls": "walls",
"status.xy": "X/Y",
"status.unit": "Unit",
"status.scale": "Scale",
"status.zoom": "Zoom",
"status.floor": "Floor",
"status.layer": "Layer",
"status.none": "—",
// Navigator panels.
"nav.drawingLevels": "Drawing levels",
"nav.layers": "Layers",
"nav.tools": "Tools",
"nav.attributes": "Attributes",
"nav.objectInfo": "Object Info",
// Attribute-Palette (AttributesPanel)
"attr.title": "Attributes",
"attr.empty": "No selection",
"attr.stroke": "Pen",
"attr.color": "Color",
"attr.weight": "Line weight (mm)",
"attr.lineStyle": "Line style",
"attr.fill": "Fill",
"attr.fillColor": "Fill color",
"attr.clearFill": "Remove fill color",
"attr.hatch": "Hatch",
"attr.none": "none",
"attr.inheritedFromLayer": "Walls inherit line weight from the layer",
"attr.fillNeedsClosed": "Fill only for closed shapes",
"attr.size": "Size",
"attr.width": "Width",
"attr.height": "Height",
// Objekt-Info-Palette (ObjectInfoPanel)
"objinfo.title": "Object Info",
"objinfo.empty": "No object selected",
"objinfo.refpoint": "Reference point",
"objinfo.setRefpoint": "Set reference point",
"objinfo.x": "X",
"objinfo.y": "Y",
"objinfo.z": "Z",
"objinfo.zHint": "2D layer – elements have no height",
"objinfo.dimensions": "Dimensions",
"objinfo.width": "Width",
"objinfo.height": "Height",
"objinfo.type": "Type",
"objinfo.kind.wall": "Wall",
"objinfo.kind.drawing2d": "2D object",
"objinfo.kind.door": "Door",
// ── Wall attributes (object info panel) ──────────────────────────────────
"objinfo.wall.section": "Wall",
"objinfo.wall.refLine": "Reference line",
"objinfo.wall.refLine.left": "Exterior (left)",
"objinfo.wall.refLine.center": "Center",
"objinfo.wall.refLine.right": "Interior (right)",
"objinfo.wall.buildup": "Build-up",
"objinfo.wall.single": "Single layer",
"objinfo.wall.multi": "Multi layer",
"objinfo.wall.thickness": "Thickness",
"objinfo.wall.preset": "Wall style",
"objinfo.wall.presetLabel": "{name} · {thickness} m",
"objinfo.wall.refFloor": "Reference floor",
"objinfo.wall.linkAbove": "Bind to floor above",
"objinfo.wall.bottom": "Bottom (UK)",
"objinfo.wall.top": "Top (OK)",
"objinfo.wall.anchorFloor": "Bound to floor",
"objinfo.wall.anchorCustom": "Custom height",
"objinfo.wall.height": "Height",
"objinfo.wall.floorAboveNone": "No floor above",
"levels.addFloor": "+ Floor",
"levels.addDrawing": "+ Drawing",
"layers.addCategory": "+ Layer",
"nav.visibility": "Visibility",
"nav.hide": "Hide",
"nav.show": "Show",
"nav.categoryVisible": "{name} visible",
"nav.categorySelect": "Make {name} the active layer (drawing target)",
// Badges.
"badge.floor": "Floor",
"badge.section": "Section",
"badge.elevation": "Elevation",
"badge.drawing": "Drawing",
// Display mode select.
"display.title": "Display mode",
"display.all_force": "Show all",
"display.all": "Selected",
"display.active": "Active only",
"display.grey": "Others grey",
"display.grey_locked": "Others grey & locked",
// Dock.
"dock.resize": "Adjust width",
"dock.dock": "Dock",
"dock.close": "Close",
"dock.float": "Float",
"dock.redock": "Dock",
"dock.resizePanel": "Resize",
"dock.dropLeft": "Dock left",
"dock.dropRight": "Dock right",
// Context menu.
"ctx.layerSettings": "Layer settings…",
"ctx.addSubLayer": "Add sub-layer…",
"ctx.assignSelection": "Assign selection here",
"ctx.assignSelection.disabled": "No wall selected — first click a wall in the plan/3D.",
"ctx.duplicate": "Duplicate",
"ctx.copyProps": "Copy properties",
"ctx.pasteProps": "Paste properties",
"ctx.pasteProps.disabled": "Clipboard empty — first use “Copy properties”.",
"ctx.delete": "Delete",
"ctx.delete.lastLayer": "The last layer cannot be deleted.",
"ctx.delete.lastLevel": "The last drawing level cannot be deleted.",
"ctx.levelSettings": "Settings…",
"ctx.fitSelection": "Fit to selection",
"ctx.clearSelection": "Clear selection",
"ctx.fit": "Fit view",
"ctx.noWallHit": "No wall hit",
"ctx.noWallHit.hint": "Right-clicking a wall selects it and shows wall actions.",
"ctx.title.level": "Drawing level",
"ctx.title.wall": "Wall",
"ctx.title.view": "View",
// Inline editor.
"editor.close": "Close",
"editor.layerTitle": "Layer {code}",
"editor.name": "Name",
"editor.color": "Color",
"editor.lineWeight": "Line weight",
"editor.floorHeight": "Floor height",
"editor.cutHeight": "Cut height",
"editor.unit.mm": "mm",
"editor.unit.m": "m",
"editor.noHeightDrawing": "Drawings have no floor/cut height.",
"editor.noHeightSection": "Sections/elevations have no floor/cut height.",
// Main content.
"content.perspektiveTitle": "Perspective · {name}",
"content.grundrissTitle": "Plan · {name}",
"content.grundrissLegend": "Plan · {name} · Cut height {cutHeight}",
"content.drawingTitle": "Drawing · {name}",
"content.drawingCanvas": "Drawing canvas",
"content.drawingLegend": "Free 2D drawing",
"props.floorHeight": "Floor height",
"props.cutHeight": "Cut height",
"props.okff": "FFL",
"props.okffValue": "+{value} m",
// Stub.
"stub.section": "Section",
"stub.elevation": "Elevation",
"stub.note": "{kind} — derived projection follows in a later phase",
// Default names.
"default.floorName": "Floor {n}",
"default.drawingName": "Drawing {n}",
"default.categoryName": "New layer",
"default.subCategoryName": "New sub-layer",
"default.componentName": "New component",
"default.hatchName": "New hatch",
"default.lineStyleName": "New line style",
"default.hatchNone": "None",
"default.copySuffixLevel": "{name} copy",
"default.copySuffixLayer": "{name} COPY",
"default.wallTypeSingle": "Wall {thickness} m",
// Alerts.
"alert.layerInUse": "Layer “{code} {name}” is used by components and cannot be deleted.",
"alert.componentInUse": "Component “{name}” is used by a wall type and cannot be deleted.",
"alert.hatchInUse": "Hatch “{name}” is used by a component and cannot be deleted.",
"alert.lineStyleInUse": "Line style “{name}” is used by a hatch and cannot be deleted.",
// Resource manager.
"resources.title": "Resources",
"resources.label": "Resource manager",
"resources.close": "Close",
"resources.tab.components": "Components",
"resources.tab.hatches": "Hatches",
"resources.tab.lines": "Lines",
"resources.add": "New",
"resources.import.lin": "⤓ Import .lin",
"resources.import.pat": "⤓ Import .pat",
"resources.components.hint": "Higher priority runs through at the join (intersects first); lower butts against the side.",
"resources.components.empty": "No components yet.",
"resources.components.placeholder": "Component",
"resources.col.name": "Name",
"resources.col.color": "Color",
"resources.col.hatch": "Hatch",
"resources.col.prio": "Prio",
"resources.col.prio.hint": "Higher priority runs through at the join",
"resources.col.texture": "Texture",
"resources.delete.component": "Delete component “{name}”",
"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",
"resources.col.pattern": "Pattern",
"resources.col.scale": "Scale",
"resources.col.angle": "Angle °",
"resources.col.lineStyle": "Line style",
"resources.lineStyle.none": "— none —",
"resources.delete.hatch": "Delete hatch “{name}”",
"pattern.none": "None",
"pattern.solid": "Solid",
"pattern.insulation": "Insulation",
"pattern.diagonal": "Diagonal",
"pattern.crosshatch": "Crosshatch",
"resources.lines.hint": "Line weight in millimetres (paper space). Stroke: solid, dashed or dotted.",
"resources.lines.empty": "No line styles yet.",
"resources.lines.placeholder": "Line style",
"resources.col.weight": "Weight (mm)",
"resources.col.stroke": "Stroke",
"resources.col.preview": "Preview",
"resources.delete.lineStyle": "Delete line style “{name}”",
"resources.texture.empty": "—",
"dash.solid": "Solid",
"dash.dashed": "Dashed",
"dash.dotted": "Dotted",
// Command line / commands (Rhino-style command system).
"cmd.aria": "Command line",
"cmd.idle.prompt": "Command:",
"cmd.idle.placeholder": "Type a command … (Tab)",
"cmd.line.label": "Line",
"cmd.line.start": "Start of line:",
"cmd.line.end": "End of line:",
"cmd.polyline.label": "Polyline",
"cmd.polyline.start": "Start point:",
"cmd.polyline.next": "Next point:",
"cmd.polyline.close": "Close",
"cmd.polyline.undo": "Undo",
"cmd.rect.label": "Rectangle",
"cmd.rect.first": "First corner:",
"cmd.rect.second": "Opposite corner:",
"cmd.circle.label": "Circle",
"cmd.circle.center": "Center:",
"cmd.circle.radius": "Radius:",
"cmd.move.label": "Move",
"cmd.move.base": "Base point:",
"cmd.move.target": "Target point:",
"cmd.move.empty": "Nothing selected (select objects first).",
"cmd.copy.label": "Copy",
"cmd.copy.base": "Base point:",
"cmd.copy.target": "Target point (Enter to finish):",
"cmd.copy.empty": "Nothing selected (select objects first).",
"cmd.offset.label": "Offset",
"cmd.offset.pick": "Select curve:",
"cmd.offset.side": "Side or distance:",
// Edit tools Split / Join / Delete segment (keyboard: Ctrl+S / Ctrl+J /
// Alt+Click). Labels for the command line / menus.
"edit.split.label": "Split",
"edit.join.label": "Join",
"edit.cutSegment.label": "Delete segment",
// Tab field cycle (precision input): ordered numeric fields per step.
"cmd.field.length": "Length",
"cmd.field.angle": "Angle",
"cmd.field.width": "Width",
"cmd.field.height": "Height",
"cmd.field.radius": "Radius",
"cmd.field.distance": "Distance",
// Commands: import / terrain.
"cmd.import.label": "Import",
"cmd.import.prompt": "Choose DXF file …",
"cmd.terrain.label": "Terrain",
"cmd.terrain.prompt": "Generate terrain from contours …",
// Site / context panel (SitePanel).
"nav.site": "Terrain",
"site.title": "Terrain & Context",
"site.import": "⤓ Import DXF",
"site.generateTerrain": "Generate terrain",
"site.contourSet": "Contour set",
"site.empty": "No context loaded. Import a DXF file (contour lines or mesh).",
"site.delete": "Remove",
"site.type.mesh": "Mesh",
"site.type.contours": "Contours",
"site.type.terrain": "Terrain",
"site.dwgHint": "DWG? Convert it to DXF first with the free ODA File Converter.",
"site.terrainFailed": "No terrain could be generated (too few/collinear contour points).",
// Import dialog.
"import.title": "Import DXF",
"import.close": "Close",
"import.file": "File",
"import.summary.heading": "Contents",
"import.summary.contours": "{n} contours",
"import.summary.meshes": "{n} meshes",
"import.summary.layers": "Layers: {layers}",
"import.summary.noLayers": "no layer names",
"import.summary.empty": "No importable 2D geometry found.",
"import.target.heading": "Target drawing level",
"import.target.existing": "Import into existing drawing level",
"import.target.new": "Create new drawing",
"import.target.newNamePlaceholder": "New drawing name",
"import.layers.heading": "Layer handling (DXF layers)",
"import.layers.asCategories": "Keep DXF layers as categories",
"import.layers.toActive": "Everything onto the active category ({code})",
"import.mesh.heading": "Meshes",
"import.mesh.asContext": "Import meshes as context geometry (3D)",
"import.mesh.note": "Meshes are not 2D drawings — they go into the context layer.",
"import.dwg.heading": "DWG file",
"import.dwg.loading": "Reading DWG …",
"import.dwg.hint":
"DWG could not be read. Alternatively, convert the file to DXF with the free ODA File Converter, then import the DXF.",
"import.confirm": "Import",
"import.cancel": "Cancel",
"import.badge.floor": "Floor",
"import.badge.section": "Section",
"import.badge.elevation": "Elevation",
"import.badge.drawing": "Drawing",
// Drag & drop (app-wide file drop).
"drop.hint": "Drop a DXF/DWG file here",
// Error boundary.
"error.console": "Unexpected error:",
"error.title": "Something went wrong",
"error.body": "An unexpected error occurred. Details in the console.",
"error.reload": "Reload",
};
+94
View File
@@ -0,0 +1,94 @@
// i18n-Schicht — eine einzige Übersetzungsfunktion `t(key, params?)` plus ein
// optionaler React-Hook `useT`. Default-Sprache ist Deutsch; das deutsche
// Wörterbuch (de.ts) hält die EXAKTEN bisherigen UI-Texte, sodass die Umstellung
// optisch nichts ändert. en.ts beweist die Übersetzbarkeit.
//
// Bewusst minimal gehalten: ein modulweiter „current language" + Wörterbuch-
// Lookup mit {param}-Interpolation. Kein Provider-Zwang — t(...) ist überall
// direkt aufrufbar; useT() liefert dieselbe Funktion und re-rendert Komponenten
// beim Sprachwechsel (subscribe), falls je ein Umschalter gebaut wird.
//
// Identifier/Keys bleiben englisch (DOSSIER-Namespacing); nur die Wörterbuch-
// Werte sind übersetzbarer Text.
import { useSyncExternalStore } from "react";
import { de } from "./de";
import { en } from "./en";
import type { TranslationKey } from "./de";
export type { TranslationKey } from "./de";
/** Unterstützte Sprachen. Default ist „de". */
export type Language = "de" | "en";
/** Wörterbücher je Sprache. de ist die Quelle der Keys (Default-Sprache). */
const DICTS: Record<Language, Record<TranslationKey, string>> = { de, en };
/** Werte für die Platzhalter-Interpolation (`{name}` → params.name). */
export type TParams = Record<string, string | number>;
// ── Modulweiter Sprachzustand + Abonnenten (für useT) ──────────────────────
let currentLanguage: Language = "de";
const listeners = new Set<() => void>();
/** Aktuell gewählte Sprache. */
export function getLanguage(): Language {
return currentLanguage;
}
/**
* Sprache umschalten. Benachrichtigt abonnierte Komponenten (useT), damit sie
* mit den neuen Texten neu rendern. (Noch kein UI-Umschalter — die Naht ist
* aber vorhanden und verdrahtet.)
*/
export function setLanguage(lang: Language): void {
if (lang === currentLanguage) return;
currentLanguage = lang;
for (const l of listeners) l();
}
// ── Kern: Lookup + Interpolation ───────────────────────────────────────────
/** Ersetzt `{name}`-Platzhalter im Wert durch die übergebenen Parameter. */
function interpolate(value: string, params?: TParams): string {
if (!params) return value;
return value.replace(/\{(\w+)\}/g, (match, name: string) =>
name in params ? String(params[name]) : match,
);
}
/**
* Übersetzt einen Key in der aktuellen Sprache mit optionaler {param}-
* Interpolation. Fehlt der Key in der gewählten Sprache, greift Deutsch als
* Fallback; fehlt er auch dort, wird der Key selbst zurückgegeben (sichtbar,
* damit fehlende Texte auffallen statt leer zu bleiben).
*/
export function t(key: TranslationKey | string, params?: TParams): string {
const dict = DICTS[currentLanguage];
const value =
(dict as Record<string, string>)[key] ??
(de as Record<string, string>)[key] ??
key;
return interpolate(value, params);
}
// ── React-Hook ─────────────────────────────────────────────────────────────
/**
* Hook-Variante: liefert dieselbe `t`-Funktion, abonniert aber den
* Sprachzustand, sodass die Komponente beim Sprachwechsel neu rendert. In den
* meisten Komponenten reicht der direkte `t`-Import; useT() ist für den Fall
* gedacht, dass ein Live-Sprachumschalter existiert.
*/
export function useT(): typeof t {
useSyncExternalStore(
(cb) => {
listeners.add(cb);
return () => listeners.delete(cb);
},
getLanguage,
getLanguage,
);
return t;
}
+353
View File
@@ -0,0 +1,353 @@
// DWG-Import → Kontext-Geometrie (three-frei), echtes In-Browser-Parsing.
//
// DWG ist ein geschlossenes Binärformat; wir lesen es hier über LibreDWG-WASM
// (`@mlightcad/libredwg-web`) DIREKT im Browser — KEIN ODA-Zwischenschritt mehr.
// Ergebnis ist dasselbe `{meshes, contours}`-Format wie der DXF-Parser, damit der
// gesamte nachgelagerte Import-Fluss (Dialog, Ziel-Ebene, Layer-Handling,
// Mesh→Kontext) unverändert wiederverwendet wird.
//
// API (aus den .d.ts der Lib abgelesen, NICHT geraten):
// LibreDwg.create() → Promise<LibreDwgEx> (lädt WASM)
// dwg.dwg_read_data(buffer, Dwg_File_Type.DWG) → Dwg_Data-Pointer
// dwg.convert(ptr) → DwgDatabase (stark typisiert)
// dwg.dwg_free(ptr) → Speicher freigeben
// 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):
// • 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).
//
// 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).
import type { Contour, ContourSet, ImportedMesh, Vec2 } from "../model/types";
import type { DxfImportResult } 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}).
interface DwgPoint {
x: number;
y: number;
z?: number;
}
// Polyface-/Polyline-Vertex: Punktkoordinaten + optionale Polyface-Indizes.
interface DwgVertexLike extends DwgPoint {
polyfaceIndex0?: number;
polyfaceIndex1?: number;
polyfaceIndex2?: number;
polyfaceIndex3?: number;
flag?: number;
}
interface DwgEntityLike {
type?: string;
layer?: string;
flag?: number;
elevation?: number;
startPoint?: DwgPoint;
endPoint?: DwgPoint;
corner1?: DwgPoint;
corner2?: DwgPoint;
corner3?: DwgPoint;
corner4?: DwgPoint;
vertices?: DwgVertexLike[];
[k: string]: unknown;
}
// 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[] };
dwg_free(ptr: number): void;
}
// Singleton der WASM-Instanz (einmal laden, dann wiederverwenden).
let libredwgPromise: Promise<LibreDwgApi> | null = null;
/**
* Lädt + initialisiert LibreDWG-WASM (lazy, einmalig). Wir laden die Emscripten-
* Glue + die `.wasm`-URL explizit über Vite (`?url`), damit die WASM-Datei in
* Dev UND Build mit korrektem MIME-Type aufgelöst wird (sonst liefert der Dev-
* Server `index.html` statt der WASM → „Incorrect response MIME type").
*/
function createLibreDwg(): Promise<LibreDwgApi> {
if (!libredwgPromise) {
libredwgPromise = (async () => {
const [{ LibreDwg }, glue, wasmUrlMod] = await Promise.all([
import("@mlightcad/libredwg-web"),
// Glue + WASM über ein lokales virtuelles Modul (Alias in vite.config),
// da das Paket seine `wasm/`-Dateien nicht über `exports` freigibt.
import("virtual:libredwg-glue"),
// Vite gibt die WASM als gehashtes Asset aus und liefert ihre URL.
import("virtual:libredwg-wasm-url"),
]);
const createModule = glue.default;
const wasmUrl = wasmUrlMod.default;
const wasmInstance = await createModule({
// locateFile bekommt den Original-Dateinamen; wir liefern die von Vite
// aufgelöste (gehashte) URL zurück → korrektes Laden in Dev + Build.
locateFile: () => wasmUrl,
});
// createByWasmInstance erwartet das (intern getippte) MainModule; die
// Glue liefert es als unknown → bewusster Cast, Rückgabe schmal getippt.
return LibreDwg.createByWasmInstance(
wasmInstance as Parameters<typeof LibreDwg.createByWasmInstance>[0],
) as unknown as LibreDwgApi;
})();
}
return libredwgPromise;
}
let importSeq = 0;
const nextId = (prefix: string): string => `${prefix}-${Date.now()}-${importSeq++}`;
// POLYLINE-Flag 64 = Polyface-Mesh (siehe DwgPolylineFlag in der Lib).
const POLYFACE_FLAG = 64;
// POLYLINE-Flag 1 = geschlossen.
const CLOSED_FLAG = 1;
/**
* 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.
*/
export async function parseDwg(data: ArrayBuffer): Promise<DxfImportResult> {
const libredwg = await createLibreDwg();
let dwgPtr: number | undefined;
try {
// Dwg_File_Type.DWG === 0 (siehe enums.d.ts der Lib). Literal, damit wir den
// Enum-Wert nicht zusätzlich in den lazy Chunk ziehen.
const FILE_TYPE_DWG = 0;
dwgPtr = libredwg.dwg_read_data(data, FILE_TYPE_DWG);
if (dwgPtr == null) {
throw new Error("LibreDWG: dwg_read_data lieferte keinen Pointer.");
}
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;
}
}
const meshes: ImportedMesh[] = [];
if (meshIndices.length > 0) {
meshes.push({
id: nextId("imported"),
type: "importedMesh",
name: "DWG-Mesh",
positions: meshTriangles,
indices: meshIndices,
});
}
const contourSets: ContourSet[] = [];
if (contours.length > 0) {
contourSets.push({
id: nextId("contours"),
type: "contourSet",
name: "DWG-Konturen",
contours,
});
}
return { meshes, contours: contourSets };
} finally {
// Speicher der WASM-Instanz freigeben (auch im Fehlerfall).
if (dwgPtr != null) {
try {
libredwg.dwg_free(dwgPtr);
} catch {
// Freigabe-Fehler ignorieren — nicht fatal.
}
}
}
}
// ── Mesh-Entities ────────────────────────────────────────────────────────────
/** Fügt einen Vertex zu positions hinzu und liefert seinen Index. */
function pushVertex(
positions: number[],
x: number,
y: number,
z: number,
): number {
const idx = positions.length / 3;
positions.push(x, y, z);
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;
return (e.vertices ?? []).some(
(v) =>
Number.isFinite(v.polyfaceIndex0) ||
Number.isFinite(v.polyfaceIndex1) ||
Number.isFinite(v.polyfaceIndex2) ||
Number.isFinite(v.polyfaceIndex3),
);
}
/**
* Polyface-Mesh (POLYLINE, Flag 64): Geometrie-Vertices (mit Koordinaten, keine
* Polyface-Indizes) und Face-Records (mit 1-basierten, ggf. negativen = unsicht-
* bare Kante Polyface-Indizes). Wir trennen beide und fan-triangulieren jede Face.
*/
function addPolyfaceMesh(
positions: number[],
indices: number[],
e: DwgEntityLike,
): void {
const all = e.vertices ?? [];
const hasFaceIdx = (v: DwgVertexLike): boolean =>
!!(
v.polyfaceIndex0 ||
v.polyfaceIndex1 ||
v.polyfaceIndex2 ||
v.polyfaceIndex3
);
const geom = all.filter((v) => !hasFaceIdx(v));
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 fr of faceRecs) {
// 1-basierte Indizes; Vorzeichen markiert (un)sichtbare Kanten → abs().
const ring = [
fr.polyfaceIndex0,
fr.polyfaceIndex1,
fr.polyfaceIndex2,
fr.polyfaceIndex3,
]
.map((i) => Math.abs(i ?? 0))
.filter((i) => i >= 1)
.map((i) => base + (i - 1));
if (ring.length < 3) continue;
for (let i = 1; i + 1 < ring.length; i++) {
pushTri(indices, positions, ring[0], ring[i], ring[i + 1]);
}
}
}
/**
* Fügt ein Dreieck hinzu, sofern es nicht entartet ist (gleiche/ungültige
* Indizes). Hält das importierte Mesh sauber.
*/
function pushTri(
indices: number[],
positions: number[],
a: number,
b: number,
c: number,
): void {
if (a === b || b === c || a === c) return;
const n = positions.length / 3;
if (a < 0 || b < 0 || c < 0 || a >= n || b >= n || c >= n) return;
indices.push(a, b, c);
}
// ── Kontur-Entities ──────────────────────────────────────────────────────────
/**
* LWPOLYLINE / POLYLINE2D / POLYLINE3D → Kontur. Z-Höhe: erster gültiger
* Vertex-Z, sonst `elevation`, sonst 0. `closed` aus Flag 1. < 2 Punkte → null.
*/
function polylineContour(e: DwgEntityLike): 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 });
if (zFromVertex === undefined && Number.isFinite(v.z)) zFromVertex = v.z;
}
if (pts.length < 2) return null;
const z =
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 };
}
/** LINE → zweipunktige (offene) Kontur. Z aus dem Startpunkt (sonst 0). */
function lineContour(e: DwgEntityLike): Contour | null {
const a = e.startPoint;
const b = e.endPoint;
if (
!a ||
!b ||
!Number.isFinite(a.x) ||
!Number.isFinite(a.y) ||
!Number.isFinite(b.x) ||
!Number.isFinite(b.y)
) {
return null;
}
const z = Number.isFinite(a.z) ? (a.z as number) : 0;
return {
z,
pts: [
{ x: a.x, y: a.y },
{ x: b.x, y: b.y },
],
closed: false,
layer: e.layer,
};
}
+22
View File
@@ -0,0 +1,22 @@
// Kleiner Modul-Hook, der das (in App montierte) versteckte DXF-Datei-Input
// mit dem `import`-Befehl entkoppelt verbindet. App registriert beim Mount eine
// Trigger-Funktion (öffnet den Datei-Dialog); der Befehl ruft sie auf, ohne die
// Command-Engine an React/DOM zu koppeln. So bleibt der Import-Befehl additiv.
//
// Bewusst KEIN window-Global: ein Modul-Singleton genügt (eine App-Instanz).
//
// Bezeichner englisch, Kommentare deutsch (CONVENTIONS.md).
type DxfImportTrigger = () => void;
let trigger: DxfImportTrigger | null = null;
/** App registriert hier den Datei-Dialog-Öffner (oder hebt ihn beim Unmount auf). */
export function setDxfImportTrigger(fn: DxfImportTrigger | null): void {
trigger = fn;
}
/** Öffnet den DXF-Datei-Dialog, falls App ihn registriert hat. */
export function openDxfImport(): void {
trigger?.();
}
+295
View File
@@ -0,0 +1,295 @@
// DXF-Import → Kontext-Geometrie (three-frei).
//
// Importierte Geometrie ist „dumme" KONTEXT-Geometrie (Anzeige + späteres
// Snap-Ziel), NICHT semantisch. Dieser Parser liefert nur rohe Buffer-Daten
// (positions/indices) bzw. Konturen (Vec2 + Z), die in `Project.context` landen.
//
// ── DWG-Hinweis (bewusst KEIN DWG-Parser) ──────────────────────────────────
// DWG ist ein geschlossenes Binärformat ohne robusten Pure-JS-Parser; wir bauen
// hier KEINEN DWG-Parser. Der Import-Weg führt über DXF:
// DWG → DXF konvertieren (gratis „ODA File Converter" von der Open Design
// Alliance), dann die DXF-Datei hier importieren.
// LibreDWG-WASM ist nicht zuverlässig genug und wird bewusst nicht verfolgt.
//
// Unterstützte DXF-Entities:
// • 3DFACE → Dreiecks-Mesh (3 bzw. 4 Ecken → 1–2 Tri).
// • POLYLINE (Polyface/PolygonMesh)→ Dreiecks-Mesh (Faces bzw. Gitter).
// • MESH → Dreiecks-Mesh (Vertices + Face-Liste).
// • LWPOLYLINE / POLYLINE (2D/3D) → Kontur (z aus elevation/Vertex-Z).
// • LINE → Kontur (zwei-Punkt-Linienzug).
import DxfParser from "dxf-parser";
import type { Contour, ContourSet, ImportedMesh, Vec2 } from "../model/types";
/** Ergebnis eines DXF-Imports: rohe Meshes + Konturen-Sätze. */
export interface DxfImportResult {
meshes: ImportedMesh[];
contours: ContourSet[];
}
// Lose getippte Sicht auf die dxf-parser-Ausgabe (das Paket liefert keine engen
// Typen); wir greifen tolerant auf die Felder zu, die wir brauchen.
interface DxfVertex {
x?: number;
y?: number;
z?: number;
// Polyface-Mesh: 1-basierte, ggf. negative Vertex-Indizes (negativ = unsichtbare Kante).
faces?: number[];
}
interface DxfEntity {
type?: string;
layer?: string;
elevation?: number;
vertices?: DxfVertex[];
// MESH-Entity-Felder (variieren je nach Parser-Version):
position?: DxfVertex[];
faces?: number[][];
isPolyfaceMesh?: boolean;
is3dPolygonMesh?: boolean;
[k: string]: unknown;
}
interface DxfDocument {
entities?: DxfEntity[];
}
let importSeq = 0;
const nextId = (prefix: string) =>
`${prefix}-${Date.now()}-${importSeq++}`;
/**
* Parst DXF-Text in Kontext-Geometrie. Tolerant gegen fehlende Felder; nicht
* unterstützte/leere Entities werden übersprungen. Ein fataler Parse-Fehler wird
* geworfen (der Aufrufer kann ihn anzeigen) — pro-Entity-Fehler nicht.
*/
export function parseDxf(text: string): DxfImportResult {
const parser = new DxfParser();
// parseSync wirft bei strukturell kaputtem DXF; das soll nach oben.
const doc = parser.parseSync(text) as unknown as DxfDocument;
const entities = doc?.entities ?? [];
const meshTriangles: number[] = []; // gesammelte Mesh-Positions (x,y,z…)
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 "MESH":
addMesh(meshTriangles, meshIndices, e);
break;
case "POLYLINE":
// POLYLINE ist mehrdeutig: Polyface/PolygonMesh → Mesh; sonst → Kontur.
if (isMeshPolyline(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;
}
}
const meshes: ImportedMesh[] = [];
if (meshIndices.length > 0) {
meshes.push({
id: nextId("imported"),
type: "importedMesh",
name: "DXF-Mesh",
positions: meshTriangles,
indices: meshIndices,
});
}
const contourSets: ContourSet[] = [];
if (contours.length > 0) {
contourSets.push({
id: nextId("contours"),
type: "contourSet",
name: "DXF-Konturen",
contours,
});
}
return { meshes, contours: contourSets };
}
// ── Mesh-Entities ────────────────────────────────────────────────────────────
/** Fügt einen Vertex zu positions hinzu und liefert seinen Index. */
function pushVertex(
positions: number[],
x: number,
y: number,
z: number,
): number {
const idx = positions.length / 3;
positions.push(x, y, z);
return idx;
}
/**
* 3DFACE: 3 oder 4 Eckpunkte. Bei 4 Punkten zwei Dreiecke (Fan). Entartete
* (degenerierte) Punkte (4. == 3.) ergeben nur ein Dreieck.
*/
function addFace(positions: number[], indices: number[], e: DxfEntity): void {
const vs = (e.vertices ?? []).filter(
(v) => Number.isFinite(v.x) && Number.isFinite(v.y),
);
if (vs.length < 3) return;
const idx = vs.map((v) =>
pushVertex(positions, v.x ?? 0, v.y ?? 0, v.z ?? 0),
);
// Dreieck (0,1,2).
pushTri(indices, positions, idx[0], idx[1], idx[2]);
// Viereck → zweites Dreieck (0,2,3), falls 4. Punkt vorhanden und ≠ 3.
if (idx.length >= 4) pushTri(indices, positions, idx[0], idx[2], idx[3]);
}
/**
* MESH-Entity: explizite Vertex-Liste + Face-Liste. Die Face-Liste kann Drei-
* oder Vierecke enthalten (jeweils 0-basierte Vertex-Indizes); Vierecke werden
* fan-trianguliert.
*/
function addMesh(positions: number[], indices: number[], e: DxfEntity): void {
const verts = e.position ?? e.vertices ?? [];
if (verts.length === 0) return;
const base = positions.length / 3;
for (const v of verts) {
positions.push(v.x ?? 0, v.y ?? 0, v.z ?? 0);
}
const faces = Array.isArray(e.faces) ? e.faces : [];
for (const face of faces) {
if (!Array.isArray(face) || face.length < 3) continue;
for (let i = 1; i + 1 < face.length; i++) {
pushTri(
indices,
positions,
base + face[0],
base + face[i],
base + face[i + 1],
);
}
}
}
/** Ob eine POLYLINE eine Mesh-Variante ist (Polyface- oder Polygon-Gitter). */
function isMeshPolyline(e: DxfEntity): boolean {
if (e.isPolyfaceMesh === true || e.is3dPolygonMesh === true) return true;
// Heuristik: enthält irgendein Vertex eine `faces`-Liste → Polyface-Mesh.
return (e.vertices ?? []).some(
(v) => Array.isArray(v.faces) && v.faces.length > 0,
);
}
/**
* Polyface-Mesh (POLYLINE, Flag 64): erst Geometrie-Vertices, dann Face-Records
* mit 1-basierten (ggf. negativen = unsichtbare Kante) Vertex-Indizes. Wir
* trennen Geometrie- von Face-Vertices und fan-triangulieren jede Face.
*/
function addPolyfaceMesh(
positions: number[],
indices: number[],
e: DxfEntity,
): void {
const all = e.vertices ?? [];
const geom = all.filter((v) => !(Array.isArray(v.faces) && v.faces.length));
const faceRecs = all.filter((v) => Array.isArray(v.faces) && v.faces.length);
if (geom.length < 3) return;
const base = positions.length / 3;
for (const v of geom) {
positions.push(v.x ?? 0, v.y ?? 0, v.z ?? 0);
}
for (const fr of faceRecs) {
// 1-basierte Indizes; Vorzeichen markiert (un)sichtbare Kanten → abs().
const ring = (fr.faces ?? [])
.map((i) => Math.abs(i))
.filter((i) => i >= 1)
.map((i) => base + (i - 1));
if (ring.length < 3) continue;
for (let i = 1; i + 1 < ring.length; i++) {
pushTri(indices, positions, ring[0], ring[i], ring[i + 1]);
}
}
}
/**
* Fügt ein Dreieck hinzu, sofern es nicht entartet ist (zwei gleiche Indizes
* oder kollineare/Null-Flächen-Ecken). Hält das importierte Mesh sauber.
*/
function pushTri(
indices: number[],
positions: number[],
a: number,
b: number,
c: number,
): void {
if (a === b || b === c || a === c) return;
const n = positions.length / 3;
if (a < 0 || b < 0 || c < 0 || a >= n || b >= n || c >= n) return;
indices.push(a, b, c);
}
// ── Kontur-Entities ──────────────────────────────────────────────────────────
/**
* LWPOLYLINE / 2D-/3D-POLYLINE → Kontur. Z-Höhe: bei 3D-Polylinie aus den
* Vertex-Z (erster gültiger), sonst aus `elevation`, sonst 0. Die 2D-Punkte sind
* (x,y) jedes Vertex. Bei < 2 Punkten wird nichts erzeugt.
*/
function polylineContour(e: DxfEntity): 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 ?? 0, y: v.y ?? 0 });
if (zFromVertex === undefined && Number.isFinite(v.z)) zFromVertex = v.z;
}
if (pts.length < 2) return null;
const z = zFromVertex ?? (Number.isFinite(e.elevation) ? (e.elevation as number) : 0);
// closed-Flag aus dem Parser ableiten (LWPOLYLINE: `shape`, POLYLINE: `shape`).
const closed = e.shape === true || e.closed === true;
return { z, pts, closed, layer: e.layer };
}
/** LINE → zweipunktige (offene) Kontur. Z aus dem Start-Vertex (sonst 0). */
function lineContour(e: DxfEntity): Contour | null {
const vs = e.vertices ?? [];
if (vs.length < 2) return null;
const a = vs[0];
const b = vs[1];
if (
!Number.isFinite(a.x) ||
!Number.isFinite(a.y) ||
!Number.isFinite(b.x) ||
!Number.isFinite(b.y)
) {
return null;
}
const z = Number.isFinite(a.z) ? (a.z as number) : 0;
return {
z,
pts: [
{ x: a.x ?? 0, y: a.y ?? 0 },
{ x: b.x ?? 0, y: b.y ?? 0 },
],
closed: false,
layer: e.layer,
};
}
+89
View File
@@ -0,0 +1,89 @@
// DXF-Konturen → semantische 2D-Zeichengeometrie (Drawing2D).
//
// Der DXF-Parser liefert „dumme" Konturen (Vec2 + Z + optionaler Layer-Name).
// Beim Import in eine ZEICHNUNGSEBENE werden daraus echte Drawing2D-Elemente
// (line/polyline). Die Z-Höhe der Kontur wird in 2D bewusst IGNORIERT (eine
// Zeichnungsebene ist flach). Das `closed`-Flag steuert line vs. polyline:
// • genau zwei Punkte, nicht geschlossen → `line`.
// • sonst → `polyline` (mit `closed`-Flag).
//
// Kategorie-Zuordnung (categoryMode):
// • "active" — alles bekommt die aktive Kategorie (activeCategoryCode).
// • "byLayer" — der DXF-Layer-Name wird auf einen Kategorie-Code gemappt.
// `layerToCode` liefert (deterministisch) einen Code je
// Layer-Name; unbekannte/leere Layer fallen auf `fallbackCode`.
//
// Bezeichner englisch, Kommentare deutsch (CLAUDE.md).
import type { Contour, ContourSet, Drawing2D, Drawing2DGeom } from "../model/types";
/** Wie DXF-Layer auf Kategorien abgebildet werden. */
export type CategoryMode = "active" | "byLayer";
let drawingSeq = 0;
const nextId = (): string => `dxf2d-${Date.now()}-${drawingSeq++}`;
/**
* Wandelt einen Satz Konturen-Sätze in Drawing2D-Elemente für die Ziel-Ebene
* `levelId` um. Bei `categoryMode === "byLayer"` liefert `layerToCode` den
* Kategorie-Code je DXF-Layer-Name; sonst bekommt alles `fallbackCode`.
*/
export function contoursToDrawings(
sets: ContourSet[],
levelId: string,
categoryMode: CategoryMode,
fallbackCode: string,
layerToCode: (layerName: string) => string,
): Drawing2D[] {
const out: Drawing2D[] = [];
for (const set of sets) {
for (const contour of set.contours) {
const geom = contourGeom(contour);
if (!geom) continue;
const layerName = contour.layer ?? set.layer ?? "";
const categoryCode =
categoryMode === "byLayer" && layerName
? layerToCode(layerName)
: fallbackCode;
out.push({
id: nextId(),
type: "drawing2d",
levelId,
categoryCode,
geom,
});
}
}
return out;
}
/** Eine Kontur → 2D-Geometrie (line bei 2 offenen Punkten, sonst polyline). */
function contourGeom(contour: Contour): Drawing2DGeom | null {
const pts = contour.pts;
if (pts.length < 2) return null;
if (pts.length === 2 && !contour.closed) {
return { shape: "line", a: { ...pts[0] }, b: { ...pts[1] } };
}
return {
shape: "polyline",
pts: pts.map((p) => ({ ...p })),
closed: contour.closed,
};
}
/** Liste der unterschiedlichen DXF-Layer-Namen über alle Konturen-Sätze. */
export function distinctLayers(sets: ContourSet[]): string[] {
const seen = new Set<string>();
for (const set of sets) {
for (const contour of set.contours) {
const name = contour.layer ?? set.layer ?? "";
if (name) seen.add(name);
}
}
return [...seen];
}
/** Gesamtzahl der Konturen über alle Sätze (für die Datei-Zusammenfassung). */
export function countContours(sets: ContourSet[]): number {
return sets.reduce((sum, s) => sum + s.contours.length, 0);
}
+19
View File
@@ -0,0 +1,19 @@
// Ambient-Deklarationen für die WASM-Glue-/Asset-Importe von
// @mlightcad/libredwg-web. Das Paket gibt seine `wasm/`-Dateien NICHT über
// `exports` frei, daher laden wir sie über zwei virtuelle Module (Aliase in
// vite.config.ts → echte Dateien im Paket). Hier nur schmale Modul-Stubs.
// (Identifier englisch.)
declare module "virtual:libredwg-glue" {
/** Emscripten-Glue: erzeugt die WASM-Instanz; `locateFile` löst die .wasm-URL. */
const createModule: (opts?: {
locateFile?: (filename: string, scriptDir: string) => string;
}) => Promise<unknown>;
export default createModule;
}
declare module "virtual:libredwg-wasm-url" {
/** Von Vite aufgelöste URL der WASM-Datei (gehashtes Asset im Build). */
const url: string;
export default url;
}
+88
View File
@@ -0,0 +1,88 @@
// Parser für AutoCAD .lin Linientyp-Definitionsdateien.
// Liefert benannte Strichmuster (dash patterns) zur späteren Übernahme in die LineStyle-Bibliothek.
export interface LinPattern {
name: string;
description: string;
dash: number[] | null;
}
// Punkt (0) wird als sehr kurzer Strich dargestellt, da SVG dasharray keine echten Punkte kennt.
const DOT_LENGTH = 0.01;
// Zerlegt eine Musterzeile in Tokens und entfernt eingeklammerte Shape-/Text-Segmente.
function extractNumericSegments(patternLine: string): number[] {
// Klammer-Segmente (z. B. ["TEXT",STYLE,...]) entfernen.
const withoutBrackets = patternLine.replace(/\[[^\]]*\]/g, '');
const tokens = withoutBrackets.split(',');
const numbers: number[] = [];
for (const raw of tokens) {
const token = raw.trim();
if (token === '') continue;
// Alignment-Token überspringen (üblicherweise 'A').
if (/^[A-Za-z]/.test(token)) continue;
const value = Number(token);
if (Number.isFinite(value)) {
numbers.push(value);
}
// Nicht-numerische Tokens werden ignoriert.
}
return numbers;
}
// Wandelt die Roh-Segmente in ein positives alternierendes dash/gap-Array um.
function toDashArray(segments: number[]): number[] | null {
if (segments.length === 0) return null;
const dash: number[] = [];
for (const seg of segments) {
if (seg === 0) {
dash.push(DOT_LENGTH);
} else {
dash.push(Math.abs(seg));
}
}
return dash.length > 0 ? dash : null;
}
export function parseLin(text: string): LinPattern[] {
const result: LinPattern[] = [];
// CRLF/CR vereinheitlichen.
const lines = text.replace(/\r\n?/g, '\n').split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (line === '') continue;
if (line.startsWith(';')) continue;
if (!line.startsWith('*')) continue;
// Definitionszeile: *NAME,Description
const body = line.slice(1);
const commaIndex = body.indexOf(',');
const name = (commaIndex >= 0 ? body.slice(0, commaIndex) : body).trim();
const description = commaIndex >= 0 ? body.slice(commaIndex + 1).trim() : '';
if (name === '') continue;
// Folgende Musterzeile suchen (Kommentare/Leerzeilen überspringen).
let j = i + 1;
while (j < lines.length) {
const candidate = lines[j].trim();
if (candidate === '' || candidate.startsWith(';')) {
j++;
continue;
}
break;
}
// Keine folgende Zeile oder direkt nächste Definition -> Eintrag überspringen.
if (j >= lines.length || lines[j].trim().startsWith('*')) {
continue;
}
const patternLine = lines[j].trim();
const segments = extractNumericSegments(patternLine);
const dash = toDashArray(segments);
result.push({ name, description, dash });
i = j;
}
return result;
}
+75
View File
@@ -0,0 +1,75 @@
// Parser für AutoCAD .pat Schraffur-Musterdateien.
// Liefert strukturierte Linienfamilien zur späteren Übernahme in die Hatch-Bibliothek.
export interface PatLineFamily {
angle: number; // Grad
x: number;
y: number; // Ursprung
dx: number;
dy: number; // Verschiebung entlang / senkrecht
dashes: number[]; // ggf. leer (durchgezogene Familie)
}
export interface PatPattern {
name: string;
description: string;
families: PatLineFamily[];
}
// Zerlegt eine Familienzeile in numerische Tokens.
function parseNumbers(line: string): number[] {
const tokens = line.split(',');
const numbers: number[] = [];
for (const raw of tokens) {
const token = raw.trim();
if (token === '') continue;
const value = Number(token);
numbers.push(value);
}
return numbers;
}
export function parsePat(text: string): PatPattern[] {
const result: PatPattern[] = [];
// CRLF/CR vereinheitlichen.
const lines = text.replace(/\r\n?/g, '\n').split('\n');
let current: PatPattern | null = null;
for (const rawLine of lines) {
const line = rawLine.trim();
if (line === '') continue;
if (line.startsWith(';')) continue;
if (line.startsWith('*')) {
// Header: *PATTERNNAME, optional description
const body = line.slice(1);
const commaIndex = body.indexOf(',');
const name = (commaIndex >= 0 ? body.slice(0, commaIndex) : body).trim();
const description = commaIndex >= 0 ? body.slice(commaIndex + 1).trim() : '';
current = { name, description, families: [] };
result.push(current);
continue;
}
// Familienzeile nur gültig innerhalb eines Pattern-Blocks.
if (!current) continue;
const numbers = parseNumbers(line);
// Mindestens angle, x, y, dx, dy erforderlich.
if (numbers.length < 5) continue;
if (!numbers.slice(0, 5).every((n) => Number.isFinite(n))) continue;
const [angle, x, y, dx, dy] = numbers;
const dashes: number[] = [];
for (let k = 5; k < numbers.length; k++) {
if (Number.isFinite(numbers[k])) {
dashes.push(numbers[k]);
}
}
current.families.push({ angle, x, y, dx, dy, dashes });
}
return result;
}
+60
View File
@@ -0,0 +1,60 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import { ErrorBoundary } from "./ui/ErrorBoundary";
import { getLanguage, setLanguage } from "./i18n";
import type { Language } from "./i18n";
import "./styles.css";
// Sprach-Naht für Werkzeuge/Tests (und einen späteren Umschalter): die i18n-
// Steuerung am window verfügbar machen. Identifier englisch; Werte übersetzbar.
declare global {
interface Window {
__i18n: { getLanguage: () => Language; setLanguage: (lang: Language) => void };
}
}
window.__i18n = { getLanguage, setLanguage };
// ── Native-App-Verhalten (kein Browser-Standard) ─────────────────────────────
// Die App soll sich wie ein natives Programm anfühlen. Diese globalen Hooks
// laufen einmal beim Start (außerhalb von React, am document) — sie greifen
// app-weit und stören die eigenen React-Handler (z. B. unser ContextMenu) nicht.
/** Prüft, ob ein Element eine echte Texteingabe ist (Input/Textarea/CE). */
const isTextEntry = (el: EventTarget | null): boolean => {
if (!(el instanceof HTMLElement)) return false;
const tag = el.tagName;
return (
tag === "INPUT" ||
tag === "TEXTAREA" ||
el.isContentEditable
);
};
// 1) Browser-Kontextmenü global unterdrücken. Unser eigenes ContextMenu wird
// über React-onContextMenu-Handler geöffnet (die preventDefault separat
// aufrufen); auf Flächen ohne eigenes Menü passiert hier nur das Verhindern
// des Browser-Menüs — sonst nichts.
document.addEventListener("contextmenu", (e) => e.preventDefault());
// 2) Ctrl/Cmd+A („Alles markieren") außerhalb echter Eingaben unterbinden.
// In Eingaben bleibt es das normale Markieren des Feldinhalts.
document.addEventListener("keydown", (e) => {
if ((e.ctrlKey || e.metaKey) && (e.key === "a" || e.key === "A")) {
if (!isTextEntry(document.activeElement)) e.preventDefault();
}
});
// 3) Browser-Drag-Gesten unterbinden (Bild-/Element-Drag, Text-Drag). Echte
// Eingaben bleiben unberührt, damit Auswahl-Drag im Feld weiter funktioniert.
document.addEventListener("dragstart", (e) => {
if (!isTextEntry(e.target)) e.preventDefault();
});
createRoot(document.getElementById("root")!).render(
<StrictMode>
<ErrorBoundary>
<App />
</ErrorBoundary>
</StrictMode>,
);
+110
View File
@@ -0,0 +1,110 @@
// Geometrie-Helfer für das Gebäudemodell. Reine 2D/3D-Mathematik,
// kein externer Kernel nötig (Phase 0).
import type { Vec2 } from "./types";
export const sub = (a: Vec2, b: Vec2): Vec2 => ({ x: a.x - b.x, y: a.y - b.y });
export const add = (a: Vec2, b: Vec2): Vec2 => ({ x: a.x + b.x, y: a.y + b.y });
export const scale = (a: Vec2, s: number): Vec2 => ({ x: a.x * s, y: a.y * s });
export const len = (a: Vec2): number => Math.hypot(a.x, a.y);
export const normalize = (a: Vec2): Vec2 => {
const l = len(a) || 1;
return { x: a.x / l, y: a.y / l };
};
/** Linke Normale (90° gegen den Uhrzeigersinn gedreht). */
export const leftNormal = (a: Vec2): Vec2 => ({ x: -a.y, y: a.x });
/** Kreuzprodukt (Z-Komponente) zweier 2D-Vektoren. */
export const cross = (p: Vec2, q: Vec2): number => p.x * q.y - p.y * q.x;
/** Eine unendliche Gerade als Stützpunkt + Richtung. */
export interface Line {
point: Vec2;
dir: Vec2;
}
/**
* Schnittpunkt der Geraden (a + t·da) mit (b + s·db).
* Liefert null bei (nahezu) parallelen Richtungen.
*/
export function lineIntersect(
a: Vec2,
da: Vec2,
b: Vec2,
db: Vec2,
): Vec2 | null {
const denom = cross(da, db);
if (Math.abs(denom) < 1e-9) return null; // parallel → kein Schnitt
const t = cross(sub(b, a), db) / denom;
return add(a, scale(da, t));
}
/** Punkt entlang einer Strecke p1→p2 im Abstand d vom Start. */
export const along = (p1: Vec2, p2: Vec2, d: number): Vec2 =>
add(p1, scale(normalize(sub(p2, p1)), d));
/**
* Die vier Eckpunkte eines Bandes, das quer zur Normalen zwischen den
* Offsets offA und offB liegt (gemessen entlang der linken Normalen n).
* Reihenfolge: start+n*offA, end+n*offA, end+n*offB, start+n*offB.
*/
export function wallBand(
start: Vec2,
end: Vec2,
offA: number,
offB: number,
): [Vec2, Vec2, Vec2, Vec2] {
const u = normalize(sub(end, start));
const n = leftNormal(u);
return [
add(start, scale(n, offA)),
add(end, scale(n, offA)),
add(end, scale(n, offB)),
add(start, scale(n, offB)),
];
}
/** Die vier Eckpunkte einer Wand als Polygon (gegen den Uhrzeigersinn). */
export function wallCorners(
start: Vec2,
end: Vec2,
thickness: number,
): [Vec2, Vec2, Vec2, Vec2] {
const h = thickness / 2;
return wallBand(start, end, h, -h);
}
/**
* Wie {@link wallBand}, aber die Bandenden werden bei Bedarf an Geh­rungs­linien
* (startCut / endCut) abgeschnitten statt rechtwinklig gekappt. Jede der beiden
* Bandkanten (bei Offset offA bzw. offB, Richtung u) wird mit der jeweiligen
* Schnittlinie verschnitten; fehlt eine Schnittlinie, bleibt das Ende
* rechtwinklig (Punkt auf der Achssenkrechten).
* Reihenfolge bleibt konsistent zu wallBand: [A.start, A.end, B.end, B.start].
*/
export function clippedBand(
start: Vec2,
end: Vec2,
offA: number,
offB: number,
startCut: Line | null,
endCut: Line | null,
): [Vec2, Vec2, Vec2, Vec2] {
const u = normalize(sub(end, start));
const n = leftNormal(u);
const edgeStart = (off: number): Vec2 => {
const base = add(start, scale(n, off));
if (!startCut) return base;
return lineIntersect(base, u, startCut.point, startCut.dir) ?? base;
};
const edgeEnd = (off: number): Vec2 => {
const base = add(end, scale(n, off));
if (!endCut) return base;
return lineIntersect(base, u, endCut.point, endCut.dir) ?? base;
};
return [edgeStart(offA), edgeEnd(offA), edgeEnd(offB), edgeStart(offB)];
}
+141
View File
@@ -0,0 +1,141 @@
// Wand-Verschneidung (Gehrung): an einer Ecke, wo zwei Wände aufeinander­
// treffen, sollen sich die Schicht-Bänder nicht überlappen, sondern an einer
// gemeinsamen Gehrungslinie sauber stoßen. Dieses Modul berechnet pro Wand
// die optionalen Schnittlinien an Start- und Endpunkt.
import type { Project, Vec2, Wall } from "./types";
import { getWallType, wallTypeThickness } from "./types";
import { wallReferenceOffset } from "./wall";
import {
add,
leftNormal,
len,
lineIntersect,
normalize,
scale,
sub,
} from "./geometry";
import type { Line } from "./geometry";
/** Schnittlinien einer Wand an ihren beiden Achsenden. */
export interface WallCuts {
startCut: Line | null;
endCut: Line | null;
}
/** Rundet eine Koordinate auf ein Gitter, um Endpunkte robust zu gruppieren. */
const roundKey = (p: Vec2): string => {
const r = (v: number) => Math.round(v * 1e4) / 1e4;
return `${r(p.x)},${r(p.y)}`;
};
interface WallEnd {
wallId: string;
end: "start" | "end";
}
/**
* Berechnet für jede Wand die Gehrungs-Schnittlinien.
* Nur L-Ecken (genau zwei Wandenden treffen sich) werden behandelt; freie
* Enden und T-/X-Stöße bleiben rechtwinklig (siehe Kommentare unten).
*
* `walls` ist die bereits gefilterte Wandmenge (z. B. ein Geschoss); die
* Gehrung wird nur innerhalb dieser Menge gebildet.
*/
export function computeJoins(
project: Project,
walls: Wall[],
): Map<string, WallCuts> {
const byId = new Map(walls.map((w) => [w.id, w]));
const result = new Map<string, WallCuts>();
for (const w of walls) result.set(w.id, { startCut: null, endCut: null });
// Knotenkarte: gerundeter Endpunkt → Liste der dort endenden Wandenden.
const junctions = new Map<string, WallEnd[]>();
const push = (p: Vec2, we: WallEnd) => {
const key = roundKey(p);
const list = junctions.get(key);
if (list) list.push(we);
else junctions.set(key, [we]);
};
for (const w of walls) {
push(w.start, { wallId: w.id, end: "start" });
push(w.end, { wallId: w.id, end: "end" });
}
for (const [, ends] of junctions) {
// Freies Ende → kein Schnitt.
if (ends.length === 1) continue;
// T-/X-Stöße (>2 Enden): vorerst rechtwinklig lassen (Folge-Arbeit).
if (ends.length !== 2) continue;
const a = byId.get(ends[0].wallId)!;
const b = byId.get(ends[1].wallId)!;
const cut = miterLine(project, a, ends[0].end, b);
if (!cut) continue; // kollinear → kein Schnitt
setCut(result, ends[0], cut);
setCut(result, ends[1], cut);
}
return result;
}
/** Trägt eine Schnittlinie am passenden Ende einer Wand ein. */
function setCut(result: Map<string, WallCuts>, we: WallEnd, cut: Line): void {
const cuts = result.get(we.wallId)!;
if (we.end === "start") cuts.startCut = cut;
else cuts.endCut = cut;
}
/** Achsrichtung start→end, normalisiert. */
const dirOf = (w: Wall): Vec2 => normalize(sub(w.end, w.start));
/**
* Gemeinsame Gehrungslinie zweier Wände A, B, die sich im Knoten J treffen.
* Robust gegen beliebige Wicklung und ungleiche Dicken: A's Außenfläche wird
* mit der NÄCHSTGELEGENEN Fläche von B verschnitten, A's Innenfläche mit der
* jeweils anderen. Die Gerade durch beide Eckpunkte ist die Gehrung.
*/
function miterLine(
project: Project,
a: Wall,
aEnd: "start" | "end",
b: Wall,
): Line | null {
const j = aEnd === "start" ? a.start : a.end;
const tA = wallTypeThickness(getWallType(project, a));
const tB = wallTypeThickness(getWallType(project, b));
const uA = dirOf(a);
const uB = dirOf(b);
const nA = leftNormal(uA);
const nB = leftNormal(uB);
// Referenzlinien-Versatz: liegt die Achse nicht mittig, sind die beiden
// Wandflächen um diesen Betrag entlang +n verschoben (außen −T/2+off, innen
// +T/2+off). Für „center" (Default) ist off=0 → unverändert.
const offA = wallReferenceOffset(a, tA);
const offB = wallReferenceOffset(b, tB);
// Flächen-Stützpunkte am Knoten (linke/rechte Wandseite), inkl. Referenzversatz.
const pLA = add(j, scale(nA, offA + tA / 2));
const pRA = add(j, scale(nA, offA - tA / 2));
const pLB = add(j, scale(nB, offB + tB / 2));
const pRB = add(j, scale(nB, offB - tB / 2));
// Für A's linke Fläche die nähere B-Fläche wählen; A's rechte bekommt die andere.
const lbCloser =
len(sub(pLA, pLB)) <= len(sub(pLA, pRB));
const bForLeft = lbCloser ? pLB : pRB;
const bForRight = lbCloser ? pRB : pLB;
const c1 = lineIntersect(pLA, uA, bForLeft, uB);
const c2 = lineIntersect(pRA, uA, bForRight, uB);
if (!c1 || !c2) return null;
const dir = sub(c2, c1);
if (len(dir) < 1e-9) return null;
return { point: c1, dir };
}
+182
View File
@@ -0,0 +1,182 @@
import type { Project } from "./types";
import { recomputeFloorElevations } from "./types";
// Demo-Haus mit zwei Geschossen:
// EG — rechteckiger Raum (5 × 4 m) mit einer Tür in der Südwand.
// OG — rechteckiger Raum (5 × 3 m) ohne Tür.
// Maße in Metern. Die Außenwand ist ein realistischer SIA-naher
// Schichtaufbau (außen → innen).
//
// Zwei UNABHÄNGIGE Achsen (Dokumentmodell nach DOSSIER):
// • drawingLevels — Geschosse (EG, OG) + Platzhalter Schnitt/Ansicht.
// • layers — Grafik-Kategorie-Baum (Codes/Namen/Farben/lw 1:1 aus DOSSIER,
// DEFAULT_LAYER_SCHEMA in launcher/src/App.jsx). 21 Türen/Fenster ist hier
// als Kind von 20 Wände gehängt.
export const sampleProject: Project = {
id: "demo",
name: "Demo-Haus",
// ── Ressourcen-Bibliotheken (zentral verwiesen per id) ──────────────────
// Linienstile: Strichstärken aus DEFAULT_LAYER_SCHEMA (lw in mm). "hatch-line"
// ist der dünne Strich für Schraffur-Muster.
lineStyles: [
{ id: "thin", name: "Dünn 0.13", weight: 0.13, color: "#1a1a1a", dash: null },
{ id: "medium", name: "Mittel 0.25", weight: 0.25, color: "#1a1a1a", dash: null },
{ id: "thick", name: "Stark 0.5", weight: 0.5, color: "#0a0a0a", dash: null },
// Schraffurlinie = schwarze Haarlinie (Grundeinstellung für alle Schraffuren).
{ id: "hatch-line", name: "Schraffurlinie", weight: 0.13, color: "#1a1a1a", dash: null },
],
// Schraffuren: color = Linien-/Vollfüllfarbe des Musters; angle in Grad.
// "none" = keine Schraffur (nur Component-Füllung).
// Grundeinstellung aller Linien-Schraffuren: schwarze Haarlinie (color) auf
// weißem Grund (die zugehörigen Bauteile tragen weiße Füllfarbe).
hatches: [
{ id: "none", name: "Ohne", pattern: "none", scale: 1, angle: 0, color: "#1a1a1a" },
{
id: "insulation",
name: "Wärmedämmung",
pattern: "insulation",
scale: 1,
angle: 0,
color: "#1a1a1a",
lineStyleId: "hatch-line",
},
{
id: "diagonal",
name: "Diagonal",
pattern: "diagonal",
scale: 1,
angle: 45,
color: "#1a1a1a",
lineStyleId: "hatch-line",
},
{
id: "crosshatch",
name: "Kreuzschraffur",
pattern: "crosshatch",
scale: 1,
angle: 0,
color: "#1a1a1a",
lineStyleId: "hatch-line",
},
{
id: "solid-concrete",
name: "Beton (voll)",
pattern: "solid",
scale: 1,
angle: 0,
color: "#9aa0a6",
lineStyleId: "thin",
},
],
// Bauteil-Materialien: color = Poché-Füllung im Grundriss UND 3D-Diffusfarbe;
// joinPriority höher = läuft am Stoß durch (für spätere T-Stöße).
components: [
{ id: "render-ext", name: "Aussenputz", color: "#d8d2c7", hatchId: "none", joinPriority: 10 },
// Weißer Grund unter der schwarzen Dämmungs-Haarlinie (Schraffur-Grundeinstellung).
{ id: "insulation", name: "Wärmedämmung", color: "#ffffff", hatchId: "insulation", joinPriority: 20 },
{ id: "brick", name: "Backstein", color: "#8c5544", hatchId: "none", joinPriority: 50 },
{ id: "render-int", name: "Innenputz", color: "#efece6", hatchId: "none", joinPriority: 10 },
// Für spätere T-Stöße: Beton als durchlaufender Backbone (höchste Priorität).
{ id: "concrete", name: "Beton", color: "#9aa0a6", hatchId: "solid-concrete", joinPriority: 100 },
],
wallTypes: [
{
id: "aw",
name: "Aussenwand 34.5 cm",
// Schichten außen → innen; Priorität lebt jetzt am Component.
layers: [
{ componentId: "render-ext", thickness: 0.02 },
{ componentId: "insulation", thickness: 0.16 },
{ componentId: "brick", thickness: 0.15 },
{ componentId: "render-int", thickness: 0.015 },
],
},
],
// Oberste Schnitte: Geschosse + Schnitt/Ansicht + freie Zeichnung.
// baseElevation wird über recomputeFloorElevations gestapelt: EG=0, OG=2.6.
drawingLevels: recomputeFloorElevations([
{ id: "eg", name: "Erdgeschoss", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0 },
{ id: "og", name: "Obergeschoss", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0 },
{
id: "schnitt-a",
name: "Schnitt A",
kind: "section",
visible: true,
locked: false,
linePoints: [{ x: -1, y: 2 }, { x: 6, y: 2 }],
directionSign: 1,
},
{ id: "ansicht-sued", name: "Ansicht Süd", kind: "elevation", visible: true, locked: false },
{ id: "detail-1", name: "Detail 1", kind: "drawing", visible: true, locked: false },
]),
// Grafik-Kategorie-Baum (DOSSIER-Codes 1:1).
layers: [
{ code: "00", name: "Raster", color: "#484850", lw: 0.13, visible: true, locked: false },
{ code: "01", name: "Vermessung", color: "#707078", lw: 0.18, visible: true, locked: false },
{ code: "10", name: "Situation", color: "#909090", lw: 0.18, visible: true, locked: false },
{ code: "11", name: "Strasse", color: "#a89070", lw: 0.18, visible: true, locked: false },
{ code: "12", name: "Gebäude", color: "#888888", lw: 0.25, visible: true, locked: false },
{ code: "13", name: "Bäume", color: "#50a050", lw: 0.13, visible: true, locked: false },
{ code: "14", name: "Höhenlinien", color: "#909050", lw: 0.18, visible: true, locked: false },
{
code: "20",
name: "Wände",
color: "#0a0a0a",
lw: 0.5,
visible: true,
locked: false,
children: [
{ code: "21", name: "Türen/Fenster", color: "#5080c8", lw: 0.25, visible: true, locked: false },
{ code: "22", name: "Möbel", color: "#909090", lw: 0.13, visible: true, locked: false },
{ code: "25", name: "Stützen", color: "#c87050", lw: 0.5, visible: true, locked: false },
],
},
{ 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: "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: "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 },
],
walls: [
// ── EG: 5 × 4 m Raum (CCW) ──
// Süd (unten)
{ id: "W1", type: "wall", floorId: "eg", categoryCode: "20", start: { x: 0, y: 0 }, end: { x: 5, y: 0 }, wallTypeId: "aw", height: 2.6 },
// Ost (rechts)
{ id: "W2", type: "wall", floorId: "eg", categoryCode: "20", start: { x: 5, y: 0 }, end: { x: 5, y: 4 }, wallTypeId: "aw", height: 2.6 },
// Nord (oben)
{ id: "W3", type: "wall", floorId: "eg", categoryCode: "20", start: { x: 5, y: 4 }, end: { x: 0, y: 4 }, wallTypeId: "aw", height: 2.6 },
// West (links)
{ id: "W4", type: "wall", floorId: "eg", categoryCode: "20", start: { x: 0, y: 4 }, end: { x: 0, y: 0 }, wallTypeId: "aw", height: 2.6 },
// ── OG: 5 × 3 m Raum (CCW), keine Tür ──
// Süd (unten)
{ id: "W5", type: "wall", floorId: "og", categoryCode: "20", start: { x: 0, y: 0 }, end: { x: 5, y: 0 }, wallTypeId: "aw", height: 2.6 },
// Ost (rechts)
{ id: "W6", type: "wall", floorId: "og", categoryCode: "20", start: { x: 5, y: 0 }, end: { x: 5, y: 3 }, wallTypeId: "aw", height: 2.6 },
// Nord (oben)
{ id: "W7", type: "wall", floorId: "og", categoryCode: "20", start: { x: 5, y: 3 }, end: { x: 0, y: 3 }, wallTypeId: "aw", height: 2.6 },
// West (links)
{ id: "W8", type: "wall", floorId: "og", categoryCode: "20", start: { x: 0, y: 3 }, end: { x: 0, y: 0 }, wallTypeId: "aw", height: 2.6 },
],
doors: [
{
id: "D1",
type: "door",
hostWallId: "W1",
categoryCode: "21",
position: 2.0, // 2 m vom Wand-Start (Ecke unten links)
width: 0.9,
height: 2.1,
swing: "left", // schlägt nach innen (in den Raum) auf
hinge: "start",
},
],
// Freie 2D-Zeichengeometrie — anfangs leer; wird mit den Zeichenwerkzeugen
// gefüllt (docs/design/drawing-tools.md).
drawings2d: [],
// Kontext-Schicht (importierte/abgeleitete Geometrie) — anfangs leer; wird
// über den DXF-Import bzw. die Gelände-Generierung gefüllt.
context: [],
};
+100
View File
@@ -0,0 +1,100 @@
// Gelände-Generierung (TIN) aus Konturen (Höhenlinien).
//
// Prinzip: Die Stütz-Vertices der Konturen werden als Punktmenge genommen
// (sie liegen direkt auf den Höhenlinien = bevorzugte „Breaklines"). Über deren
// (x,y)-Projektion wird eine 2D-Delaunay-Triangulation gelegt; die Z-Höhe je
// Vertex stammt aus der jeweiligen Kontur. Daraus entsteht ein TIN (Triangulated
// Irregular Network) als rohes positions/indices-Mesh (three-frei).
//
// Gelände ist NICHT semantisch — es ist Kontext-Geometrie (Project.context).
import Delaunator from "delaunator";
import type { Contour, TerrainMesh } from "./types";
let terrainSeq = 0;
/**
* Erzeugt ein TIN aus Konturen.
*
* Algorithmus:
* 1. Sampling: jeder Stützpunkt jeder Kontur wird ein 3D-Vertex (x, y, z) mit
* z = Kontur-Höhe. Es wird KEIN zusätzliches Resampling betrieben — die
* Kontur-Vertices selbst sind die Breaklines.
* 2. Delaunay: 2D-Triangulation über die (x,y)-Projektion aller Vertices
* (Delaunator). Die Z-Höhe je Vertex bleibt erhalten → echtes Gelände-TIN.
* 3. Degenerate-Filter: Dreiecke mit (nahezu) null Fläche in der (x,y)-Ebene
* bzw. mit nicht-endlichen Koordinaten werden verworfen.
*
* Robustheit:
* • offene UND geschlossene Konturen werden gleich behandelt (nur die Punkte
* zählen; closed/offen beeinflusst die TIN-Fläche nicht).
* • leere/zu kleine Eingabe (< 3 Punkte) → leeres Mesh (kein Crash).
* • NaN/Infinity-Punkte werden vor der Triangulation aussortiert.
*/
export function generateTerrainFromContours(contours: Contour[]): TerrainMesh {
const id = `terrain-${Date.now()}-${terrainSeq++}`;
const name = "Gelände";
// 1. Sampling: alle Kontur-Stützpunkte als (x,y,z)-Vertices sammeln, ungültige
// (nicht-endliche) Punkte überspringen.
const xs: number[] = [];
const ys: number[] = [];
const zs: number[] = [];
for (const c of contours) {
const z = Number.isFinite(c.z) ? c.z : 0;
for (const p of c.pts) {
if (!Number.isFinite(p.x) || !Number.isFinite(p.y)) continue;
xs.push(p.x);
ys.push(p.y);
zs.push(z);
}
}
// Zu wenige Punkte für ein Dreieck → triviales leeres Mesh.
if (xs.length < 3) {
return { id, type: "terrainMesh", name, positions: [], indices: [] };
}
// 2. Delaunay über die (x,y)-Projektion. Delaunator erwartet ein flaches
// [x0,y0, x1,y1, …]-Koordinatenarray.
const coords2d = new Float64Array(xs.length * 2);
for (let i = 0; i < xs.length; i++) {
coords2d[i * 2] = xs[i];
coords2d[i * 2 + 1] = ys[i];
}
// Bei kollinearen Punkten liefert Delaunator eine leere Triangulation — das ist
// korrekt (kein Crash). Trotzdem schützen wir gegen einen möglichen Wurf.
let triangles: Uint32Array;
try {
triangles = new Delaunator(coords2d).triangles;
} catch {
return { id, type: "terrainMesh", name, positions: [], indices: [] };
}
// 3. positions (x,y,z) aufbauen; Z aus zs je Vertex.
const positions: number[] = new Array(xs.length * 3);
for (let i = 0; i < xs.length; i++) {
positions[i * 3] = xs[i];
positions[i * 3 + 1] = ys[i];
positions[i * 3 + 2] = zs[i];
}
// Degenerierte Dreiecke (Null-Fläche in (x,y)) herausfiltern, damit das TIN
// keine entarteten Faces enthält.
const indices: number[] = [];
const EPS_AREA = 1e-9;
for (let t = 0; t < triangles.length; t += 3) {
const a = triangles[t];
const b = triangles[t + 1];
const c = triangles[t + 2];
const ax = xs[a], ay = ys[a];
const bx = xs[b], by = ys[b];
const cx = xs[c], cy = ys[c];
// doppelte Dreiecksfläche (Kreuzprodukt) in der (x,y)-Ebene.
const area2 = Math.abs((bx - ax) * (cy - ay) - (cx - ax) * (by - ay));
if (area2 <= EPS_AREA) continue;
indices.push(a, b, c);
}
return { id, type: "terrainMesh", name, positions, indices };
}
+469
View File
@@ -0,0 +1,469 @@
// Das semantische Gebäudemodell — der Kern von BIM.
// Bauteile haben Bedeutung, nicht nur Form. Eine Tür "kennt" ihre Wand,
// eine Wand "kennt" ihren mehrschichtigen Aufbau (WallType).
//
// Dokumentmodell (nach DOSSIER): zwei UNABHÄNGIGE Achsen.
// 1. Zeichnungsebenen (DrawingLevel) = oberste Schnitte: Geschosse +
// Schnitte/Ansichten.
// 2. Ebenen (LayerCategory) = Grafik-Kategorie-Schema, das für jedes
// Geschoss gilt. Ein Element lebt auf einer Kategorie (code) UND einem
// Geschoss.
export type Vec2 = { x: number; y: number };
// ── Ressourcen-Bibliotheken (Vectorworks-/DOSSIER-Stil) ────────────────────
// Verwaltete, per id verwiesene Stil-Ressourcen. Verweis-Kette:
// Component → Hatch → LineStyle.
// Darstellung wird beim Rendern aus diesen Ressourcen aufgelöst, nie in die
// Geometrie eingebacken (siehe docs/design/resources-graphics.md).
/** Ein wiederverwendbarer Linienstil (Line Manager). */
export interface LineStyle {
id: string;
name: string;
/** Strichstärke in Millimetern (≙ Rhino PlotWeight). */
weight: number;
/** Farbe (hex). */
color: string;
/** Strichmuster in Millimetern; `null` = durchgezogen. */
dash: number[] | null;
}
/** Mögliche Schraffur-Muster im Plan/Schnitt. */
export type HatchPattern =
| "none"
| "solid"
| "insulation"
| "diagonal"
| "crosshatch";
/** Eine wiederverwendbare Schraffur (Hatch Manager). */
export interface HatchStyle {
id: string;
name: string;
/** Muster-Typ. */
pattern: HatchPattern;
/** Grundmaßstab des Musters (1 = Standardteilung). */
scale: number;
/** Drehung des Musters in Grad. */
angle: number;
/**
* Farbe der Musterlinien bzw. der Vollfüllung (`pattern==="solid"`). Bei
* `pattern==="none"` ungenutzt.
*/
color: string;
/** Optionaler Linienstil für die Musterlinien (Line Manager). */
lineStyleId?: string;
}
/**
* Ein Bauteil-Material (Component Manager) — vereint Plan-Schnittdarstellung
* (Schraffur) und 3D-Erscheinung. Ersetzt das frühere `Material`.
*/
export interface Component {
id: string;
name: string;
/** Füllfarbe im Grundriss (Poché) und 3D-Diffusfarbe. */
color: string;
/** Schnitt-Schraffur → Hatch Manager. */
hatchId: string;
/** Optionale 3D-Textur (vorerst ignoriert). */
texture3d?: string;
/** Verschneidungs-Rang: höher läuft am Stoß durch (Backbone). */
joinPriority: number;
}
/** Eine Schicht eines mehrschichtigen Bauteils. */
export interface Layer {
/** Verweis auf das Bauteil-Material (Component Manager). */
componentId: string;
/** Schichtdicke in Metern. */
thickness: number;
}
/** Ein Wandtyp = geordneter Schichtaufbau (außen → innen). */
export interface WallType {
id: string;
name: string;
layers: Layer[];
}
/**
* Art einer Zeichnungsebene.
* • "floor" — Geschoss, trägt Bauteile.
* • "section" — Schnitt (abgeleitete Projektion entlang einer Linie).
* • "elevation" — Ansicht (abgeleitete Projektion entlang einer Linie).
* • "drawing" — freie 2D-Zeichnung, nicht an ein Geschoss gebunden.
*/
export type DrawingLevelKind = "floor" | "section" | "elevation" | "drawing";
/**
* Eine Zeichnungsebene (DrawingLevel) — eine oberste Schnittebene des
* Dokuments. Ein Geschoss trägt die Bauteile (über `floorId`); ein
* Schnitt/Ansicht ist eine abgeleitete Projektion (vorerst Platzhalter); eine
* reine Zeichnung ist eine freie 2D-Ebene ohne Geschossbezug.
*
* Geschoss nutzt floorHeight/cutHeight/baseElevation; Schnitt/Ansicht nutzen
* linePoints/directionSign; "drawing" nutzt keines dieser Felder.
*/
export interface DrawingLevel {
id: string;
name: string;
kind: DrawingLevelKind;
/** Sichtbarkeit der Zeichnungsebene im Navigator. */
visible: boolean;
/** Gesperrt (keine Bearbeitung). */
locked: boolean;
/** Lichte Geschosshöhe in Metern (nur Geschoss). */
floorHeight?: number;
/** Schnitthöhe über OKFF in Metern (nur Geschoss, für den Grundriss). */
cutHeight?: number;
/** Oberkante Fertigfußboden in Metern (nur Geschoss). */
baseElevation?: number;
/** Schnitt-/Ansichtslinie im Grundriss (nur Schnitt/Ansicht). */
linePoints?: [Vec2, Vec2];
/** Blickrichtung relativ zur Schnittlinie (nur Schnitt/Ansicht). */
directionSign?: 1 | -1;
}
/**
* Eine Ebene (LayerCategory) — ein Knoten im Grafik-Kategorie-Baum. Das
* Schema gilt geschossübergreifend; Elemente verweisen über `code` darauf.
*/
export interface LayerCategory {
/** Eindeutiger Kategorie-Code, z. B. "20" für Wände. */
code: string;
name: string;
/** Darstellungsfarbe (hex). */
color: string;
/** Linienstärke in Millimetern. */
lw: number;
visible: boolean;
locked: boolean;
/** Optionale Standard-Schraffur der Kategorie. */
hatch?: string;
/** Unterkategorien (Baum). */
children?: LayerCategory[];
}
/**
* Lage der Wandachse (Referenzlinie) über die Dicke der Wand — analog
* Vectorworks. Gemessen relativ zur Laufrichtung (start→end) mit der
* leftNormal-Konvention `n = (-u.y, u.x)`:
* • "center" — Achse mittig (Default = heutiges Verhalten).
* • "left" — Achse liegt auf der linken Wandfläche (+n-Seite, „außen").
* • "right" — Achse liegt auf der rechten Wandfläche (−n-Seite, „innen").
*/
export type WallReferenceLine = "left" | "center" | "right";
/**
* Vertikale Bindung einer Wandkante (UK/OK).
* • "floor" — an ein Geschoss gebunden: Z = baseElevation des Geschosses
* (+ optionalem `offset`). Stapelt automatisch mit dem Geschoss.
* • "custom" — fester absoluter Z-Wert (Meter).
*/
export type VerticalAnchor =
| { mode: "floor"; floorId: string; offset?: number }
| { mode: "custom"; z: number };
/** Eine Wand, definiert über ihre Achse (Centerline) und ihren Typ. */
export interface Wall {
id: string;
type: "wall";
/** Zugehörige Zeichnungsebene (Geschoss). */
floorId: string;
/** Grafik-Kategorie (Ebene), z. B. "20" für Wände. */
categoryCode: string;
/** Achs-Startpunkt im Grundriss (Meter). */
start: Vec2;
/** Achs-Endpunkt im Grundriss (Meter). */
end: Vec2;
/** Verweis auf den (mehrschichtigen) Wandtyp. */
wallTypeId: string;
/** Wandhöhe in Metern. */
height: number;
/**
* Optionale Übersteuerung der Strich-/Umrandungsfarbe der Wand; sonst gilt
* die Kategorie-Farbe. Übersteuert NUR die Linienfarbe (Umriss/Schichtfugen),
* nicht die Schicht-Füllfarben/Schraffuren.
*/
color?: string;
/**
* Lage der Wandachse über die Dicke. Fehlt sie, gilt "center" (= heutiges
* Verhalten: Schichten symmetrisch −T/2 … +T/2 um die Achse).
*/
referenceLine?: WallReferenceLine;
/**
* Vertikale Bindung der Unterkante (UK). Fehlt sie, sitzt die UK auf der
* baseElevation des zugehörigen Geschosses (= heutiges Verhalten).
*/
bottom?: VerticalAnchor;
/**
* Vertikale Bindung der Oberkante (OK). Fehlt sie, ergibt sich die OK aus
* UK + `height` (= heutiges Verhalten). „custom" setzt einen absoluten
* Z-Wert; „floor" bindet die OK an ein (z. B. nächsthöheres) Geschoss.
*/
top?: VerticalAnchor;
}
/** Schwenkrichtung der Tür relativ zur Wandachse. */
export type SwingSide = "left" | "right";
/** Eine Tür, gehostet in einer Wand. Ihr Geschoss ergibt sich aus der Wand. */
export interface Door {
id: string;
type: "door";
hostWallId: string;
/** Grafik-Kategorie (Ebene), z. B. "21" für Türen/Fenster. */
categoryCode: string;
/** Abstand des Türanschlags (erster Pfosten) vom Wand-Startpunkt, in Metern. */
position: number;
/** Türbreite (lichte Öffnung) in Metern. */
width: number;
/** Türhöhe in Metern. */
height: number;
/** Auf welche Seite der Wandachse die Tür aufschlägt. */
swing: SwingSide;
/** An welchem Pfosten das Scharnier sitzt. */
hinge: "start" | "end";
}
// ── Freie 2D-Zeichengeometrie (Drawing2D) ──────────────────────────────────
// Ein semantisches 2D-Element (wie Wall/Door), das beim Rendern abgeleitet wird
// (keine vorab erzeugten Plan-Primitive). Siehe docs/design/drawing-tools.md §7.
/** Geometrie-Form eines 2D-Zeichenelements. */
export type Drawing2DGeom =
| { shape: "line"; a: Vec2; b: Vec2 }
| { shape: "polyline"; pts: Vec2[]; closed: boolean }
| { shape: "rect"; min: Vec2; max: Vec2 }
| { shape: "circle"; center: Vec2; r: number }
| { shape: "arc"; center: Vec2; r: number; a0: number; a1: number }
| { shape: "text"; at: Vec2; text: string; height: number; angle: number };
/** Ein freies 2D-Zeichenelement auf einer Zeichnungsebene. */
export interface Drawing2D {
id: string;
type: "drawing2d";
/** Zeichnungsebene (Geschoss ODER freie 2D-Ebene). */
levelId: string;
/** Grafik-Kategorie (Ebene) — liefert Farbe/Strichstärke als Default. */
categoryCode: string;
geom: Drawing2DGeom;
/** Optionaler Linienstil (Line Manager); sonst Kategorie-Default. */
lineStyleId?: string;
/** Optionale Schraffur für geschlossene Formen (Hatch Manager). */
hatchId?: string;
/** Optionale explizite Strichfarbe; sonst Kategorie-Farbe. */
color?: string;
/**
* Optionale Vollton-Füllfarbe für geschlossene Formen (getrennt von der
* Strichfarbe `color`). Fehlt sie, ist die Fläche transparent (nur Schraffur
* bzw. ungefüllt).
*/
fillColor?: string;
/**
* Optionale direkte Strichstärke-Übersteuerung in Millimetern; hat Vorrang
* vor dem LineStyle-Gewicht und der Kategorie-Strichstärke.
*/
weightMm?: number;
}
/**
* Ein Kanten-/Seiten-Griff eines selektierten Elements (zusätzlich zu den
* Eckpunkt-Griffen). Liegt am Mittelpunkt einer Seite (Modell-Meter) und zeigt
* mit `normal` als Einheitsvektor nach AUSSEN (vom Element weg). `aIndex`/
* `bIndex` sind die beiden Vertex-Indizes der Kante — passend zur Indizierung
* von `drawingVertices`/`moveGripOf`. Ziehen verschiebt BEIDE Vertices senkrecht
* zur Kante (Form wächst/schrumpft an dieser Seite).
*/
export interface EdgeGrip {
mid: Vec2;
normal: Vec2;
aIndex: number;
bIndex: number;
}
export type Element = Wall | Door | Drawing2D;
// ── Kontext-Geometrie (importiert / abgeleitet, NICHT semantisch) ───────────
// Importierte Geometrie ist „dumme" KONTEXT-Geometrie (Anzeige + späteres
// Snap-Ziel), KEIN Teil des semantischen BIM-Modells. Sie lebt in einer eigenen
// Schicht `Project.context` und wird beim Rendern wie eine Referenz behandelt.
// Bewusst three-frei: nur rohe Buffer-Daten (positions/indices), three-Objekte
// entstehen erst im Viewport.
/**
* Ein importiertes Dreiecks-Mesh (z. B. aus DXF 3DFACE/POLYFACE/MESH). Rohe
* BufferGeometry-Daten: `positions` = flaches Array (x,y,z, x,y,z, …) in Metern,
* `indices` = Dreiecks-Indizes (je 3 ein Dreieck). Keine three-Objekte.
*/
export interface ImportedMesh {
id: string;
type: "importedMesh";
name: string;
/** Ursprünglicher DXF-Layer-Name (für spätere Kategorisierung). */
layer?: string;
positions: number[];
indices: number[];
}
/**
* Eine einzelne Kontur (Höhenlinie / Polylinie) auf konstanter Höhe `z`. `pts`
* sind 2D-Stützpunkte (x,y) in Metern; `closed` schließt den Linienzug.
*/
export interface Contour {
z: number;
pts: Vec2[];
closed: boolean;
/** Ursprünglicher DXF-Layer-Name (für die Kategorisierung beim 2D-Import). */
layer?: string;
}
/** Ein Satz Konturen (z. B. alle Höhenlinien eines DXF-Imports). */
export interface ContourSet {
id: string;
type: "contourSet";
name: string;
/** Ursprünglicher DXF-Layer-Name (für spätere Kategorisierung). */
layer?: string;
contours: Contour[];
}
/**
* Ein TIN-Geländemodell, abgeleitet aus Konturen (Delaunay über (x,y), Z aus der
* jeweiligen Kontur-Höhe). `positions` = flaches (x,y,z…)-Array in Metern,
* `indices` = Dreiecks-Indizes. Gelände ist NICHT semantisch (Kontext-Schicht).
*/
export interface TerrainMesh {
id: string;
type: "terrainMesh";
name: string;
positions: number[];
indices: number[];
}
/** Ein Kontext-Objekt: importiertes Mesh, Konturen-Satz oder abgeleitetes TIN. */
export type ContextObject = ImportedMesh | ContourSet | TerrainMesh;
/** Das gesamte Projekt. */
export interface Project {
id: string;
name: string;
/** Linienstil-Bibliothek (Line Manager). */
lineStyles: LineStyle[];
/** Schraffur-Bibliothek (Hatch Manager). */
hatches: HatchStyle[];
/** Bauteil-Material-Bibliothek (Component Manager). */
components: Component[];
wallTypes: WallType[];
/** Oberste Schnitte: Geschosse + Schnitte/Ansichten. */
drawingLevels: DrawingLevel[];
/** Grafik-Kategorie-Baum (geschossübergreifend). */
layers: LayerCategory[];
walls: Wall[];
doors: Door[];
/** Freie 2D-Zeichengeometrie (Line/Polyline/Rect/Circle/Arc/Text). */
drawings2d: Drawing2D[];
/**
* Kontext-Schicht: importierte/abgeleitete „dumme" Geometrie (Meshes,
* Konturen, Gelände-TIN) — NICHT semantisch. Optional, damit bestehende
* Projekte/Tests ohne `context` gültig bleiben (Default: leer behandeln).
*/
context?: ContextObject[];
}
// ── Helfer ───────────────────────────────────────────────────────────────
export const getWallType = (project: Project, wall: Wall): WallType => {
const wt = project.wallTypes.find((t) => t.id === wall.wallTypeId);
if (!wt) throw new Error(`Unbekannter Wandtyp: ${wall.wallTypeId}`);
return wt;
};
/** Liefert ein Bauteil-Material (Component) per ID oder wirft. */
export const getComponent = (project: Project, id: string): Component => {
const c = project.components.find((co) => co.id === id);
if (!c) throw new Error(`Unbekanntes Bauteil-Material: ${id}`);
return c;
};
/** Liefert eine Schraffur (HatchStyle) per ID oder wirft. */
export const getHatch = (project: Project, id: string): HatchStyle => {
const h = project.hatches.find((ht) => ht.id === id);
if (!h) throw new Error(`Unbekannte Schraffur: ${id}`);
return h;
};
/** Liefert einen Linienstil (LineStyle) per ID oder wirft. */
export const getLineStyle = (project: Project, id: string): LineStyle => {
const l = project.lineStyles.find((ls) => ls.id === id);
if (!l) throw new Error(`Unbekannter Linienstil: ${id}`);
return l;
};
/** Liefert eine Zeichnungsebene (Geschoss) per ID oder wirft. */
export const getFloor = (project: Project, id: string): DrawingLevel => {
const g = project.drawingLevels.find((z) => z.id === id);
if (!g) throw new Error(`Unbekanntes Geschoss: ${id}`);
return g;
};
/**
* Stapelt baseElevation der Geschosse in Dokumentreihenfolge: Das erste
* Geschoss beginnt bei 0, jedes weitere bei baseElevation + floorHeight des
* vorigen Geschosses. Nicht-Geschoss-Ebenen behalten baseElevation undefined.
* Liefert eine neue Liste (mit neuen Geschoss-Objekten); die Eingabe bleibt
* unverändert.
*/
export const recomputeFloorElevations = (
levels: DrawingLevel[],
): DrawingLevel[] => {
let nextBase = 0;
return levels.map((level) => {
if (level.kind !== "floor") {
return { ...level, baseElevation: undefined };
}
const baseElevation = nextBase;
nextBase = baseElevation + (level.floorHeight ?? 0);
return { ...level, baseElevation };
});
};
/** Flacht den Kategorie-Baum (Tiefensuche) in eine Liste ab. */
export const flattenCategories = (cats: LayerCategory[]): LayerCategory[] => {
const out: LayerCategory[] = [];
const walk = (list: LayerCategory[]) => {
for (const c of list) {
out.push(c);
if (c.children) walk(c.children);
}
};
walk(cats);
return out;
};
/** Menge aller Codes sichtbarer Kategorien (Baum berücksichtigt). */
export const collectVisibleCodes = (layers: LayerCategory[]): Set<string> => {
const codes = new Set<string>();
for (const c of flattenCategories(layers)) {
if (c.visible) codes.add(c.code);
}
return codes;
};
/** Gesamtdicke eines Wandtyps = Summe der Schichtdicken. */
export const wallTypeThickness = (wt: WallType): number =>
wt.layers.reduce((sum, l) => sum + l.thickness, 0);
/** Formatiert Meter mit zwei Nachkommastellen, z. B. "0.35 m". */
export const formatM = (meters: number): string => meters.toFixed(2) + " m";
/**
* Standard-Stiftstärken (mm Papier bei 100 %), Vorgabeliste für Linienstil-/
* Strichstärke-Eingaben. Bedeutung: Breite auf dem Papier — die Linien skalieren
* mit dem Massstab (non-scaling-stroke). Werte sind Vorschläge; man darf abweichen.
*/
export const PEN_WEIGHTS: number[] = [
0.02, 0.1, 0.13, 0.18, 0.25, 0.35, 0.5, 0.7, 1.0, 1.4, 2.0,
];
+87
View File
@@ -0,0 +1,87 @@
// Wand-Resolver: leitet aus den (optionalen) Wand-Attributen die konkreten
// geometrischen Größen ab — Versatz der Achse über die Dicke (Referenzlinie)
// 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).
import type { Project, VerticalAnchor, Wall } from "./types";
import { getFloor } from "./types";
/**
* Versatz der Schichtstapelung entlang der linken Normalen `+n` (leftNormal),
* sodass die Achse auf der gewünschten Wandfläche liegt. Die Schichten werden
* normalerweise symmetrisch von `−T/2` (außen, +n-Seite) bis `+T/2` (innen)
* gestapelt; dieser Versatz wird ZUSÄTZLICH auf jeden Schicht-Offset addiert.
*
* • "center" → 0 (Schichten symmetrisch um die Achse — Default).
* • "left" → +T/2 (Achse auf der +n-/„außen"-Fläche; Wand liegt auf −n).
* • "right" → −T/2 (Achse auf der −n-/„innen"-Fläche; Wand liegt auf +n).
*
* Die Schicht-Offsets laufen von −T/2 (= +n außen) nach +T/2 (= −n innen).
* Für "left" soll die −T/2-Kante (außen) auf der Achse liegen → +T/2 addieren,
* sodass die Schichten von 0…T (alle auf der −n-Seite) liegen.
*/
export function wallReferenceOffset(
wall: Wall,
totalThickness: number,
): number {
const ref = wall.referenceLine ?? "center";
const half = totalThickness / 2;
if (ref === "left") return +half;
if (ref === "right") return -half;
return 0;
}
/** Löst einen VerticalAnchor zu einem absoluten Z (Meter) auf oder `null`. */
function resolveAnchor(
project: Project,
anchor: VerticalAnchor | undefined,
): number | null {
if (!anchor) return null;
if (anchor.mode === "custom") return anchor.z;
// "floor": baseElevation des gebundenen Geschosses (+ optionalem Offset).
const floor = project.drawingLevels.find((z) => z.id === anchor.floorId);
if (!floor) return null;
return (floor.baseElevation ?? 0) + (anchor.offset ?? 0);
}
/**
* Vertikale Ausdehnung der Wand (absolute Z-Werte in Metern).
* • zBottom: aus `wall.bottom`, sonst baseElevation des Wand-Geschosses
* (= heutiges Verhalten).
* • zTop: aus `wall.top`, sonst `zBottom + wall.height` (= heute).
* Sind beide UK/OK ungesetzt, liefert dies exakt `[baseElevation, base+height]`.
*/
export function wallVerticalExtent(
project: Project,
wall: Wall,
): { zBottom: number; zTop: number } {
// Geschoss-baseElevation als Default-UK (heutiges Verhalten). Fehlt das
// Geschoss, 0 als sicherer Rückfall.
let floorBase = 0;
try {
floorBase = getFloor(project, wall.floorId).baseElevation ?? 0;
} catch {
floorBase = 0;
}
const zBottom = resolveAnchor(project, wall.bottom) ?? floorBase;
const zTop = resolveAnchor(project, wall.top) ?? zBottom + wall.height;
return { zBottom, zTop };
}
/**
* Das Geschoss DIREKT ÜBER dem Wand-Geschoss (in Dokumentreihenfolge der
* "floor"-Ebenen) oder `undefined`, wenn die Wand im obersten Geschoss liegt.
* Dient der „Verknüpfung zum oberen Geschoss" (OK an nächstes Geschoss binden).
*/
export function nextFloorAbove(
project: Project,
wall: Wall,
): { id: string; name: string } | undefined {
const floors = project.drawingLevels.filter((z) => z.kind === "floor");
const idx = floors.findIndex((z) => z.id === wall.floorId);
if (idx < 0) return undefined;
const above = floors[idx + 1];
return above ? { id: above.id, name: above.name } : undefined;
}
+177
View File
@@ -0,0 +1,177 @@
// Inhalts-Panel „Attribute" — die Attribut-Palette (Vectorworks-Stil) für die
// aktuelle Auswahl. Zeigt die EFFEKTIV aufgelösten Stift-/Füll-Eigenschaften des
// ersten selektierten Elements und erlaubt das direkte Bearbeiten (Farbe,
// Strichstärke, Füllschraffur).
//
// Daten/Handler kommen ausschließlich über usePanelHost (kein Prop-Drilling).
// Es werden NUR Eigenschaften angeboten, die der Host-Kontrakt tatsächlich
// setzen kann — keine Stubs/Fake-Setter (Konvention wire-dont-stub). Felder ohne
// echten Setter (Linienstil) werden read-only/informativ gezeigt; Felder, die
// das Modell gar nicht trägt (Deckkraft, Caps, Schlagschatten), werden bewusst
// weggelassen, um die Palette ehrlich und schlank zu halten.
//
// DOSSIER-Tabellen-Look: KEINE wiederholten Feld-Labels pro Zeile — ein kompaktes
// Label→Wert-Grid. Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
// Alle sichtbaren Texte über t(...).
import { t } from "../i18n";
import { formatM } from "../model/types";
import { PEN_WEIGHTS } from "../model/types";
import { usePanelHost } from "./host";
export function AttributesPanel() {
const host = usePanelHost();
const sel = host.selection;
const { project } = host;
// Leerzustand: kompakter Hinweis, wenn nichts (Sinnvolles) selektiert ist.
if (sel === null) {
return (
<div className="attr-panel">
<div className="attr-empty">{t("attr.empty")}</div>
</div>
);
}
const isDrawing = sel.kind === "drawing2d";
// Strichstärke wirkt laut Kontrakt nur auf Drawing2D; Wände erben sie aus der
// Ebene und sind daher hier nicht editierbar.
const weightEditable = isDrawing;
// Füllung nur bei geschlossener 2D-Form sinnvoll.
const fillEditable = isDrawing && sel.closed === true;
// Effektiver Linienstil rein informativ (kein Setter im Kontrakt → kein Fake).
// Wir leiten ihn aus dem Modell-Element ab, wenn explizit gesetzt; sonst „—".
const drawing = isDrawing
? project.drawings2d.find((d) => d.id === sel.id)
: undefined;
const lineStyle = drawing?.lineStyleId
? project.lineStyles.find((l) => l.id === drawing.lineStyleId)
: undefined;
const bboxW = sel.bbox.maxX - sel.bbox.minX;
const bboxH = sel.bbox.maxY - sel.bbox.minY;
return (
<div className="attr-panel">
<div className="attr-title">{t("attr.title")}</div>
{/* ── Stift/Strich ──────────────────────────────────────────────── */}
<div className="attr-section-label">{t("attr.stroke")}</div>
<div className="attr-grid">
{/* Farbe — gilt für Wand UND Drawing2D (Kontrakt setzt beide). */}
<span className="attr-key">{t("attr.color")}</span>
<span className="attr-val">
<label className="attr-color">
<span
className="attr-color-swatch"
style={{ background: sel.color }}
/>
<input
type="color"
value={sel.color}
onChange={(e) => host.onSetSelectionColor(e.target.value)}
/>
<span className="attr-color-hex">{sel.color}</span>
</label>
</span>
{/* Strichstärke (mm) — nur Drawing2D editierbar; Wände erben aus Ebene. */}
<span className="attr-key">{t("attr.weight")}</span>
<span className="attr-val">
<input
className="attr-num"
type="number"
step={0.01}
min={0}
list="attr-pen-weights"
value={sel.weightMm}
disabled={!weightEditable}
title={weightEditable ? undefined : t("attr.inheritedFromLayer")}
onChange={(e) => {
const v = Number(e.target.value);
if (Number.isFinite(v)) host.onSetSelectionWeight(v);
}}
/>
<datalist id="attr-pen-weights">
{PEN_WEIGHTS.map((w) => (
<option key={w} value={w} />
))}
</datalist>
</span>
{/* Linienstil — read-only/informativ (kein Setter im Kontrakt). */}
<span className="attr-key">{t("attr.lineStyle")}</span>
<span className="attr-val attr-readonly">
{lineStyle ? lineStyle.name : "—"}
</span>
</div>
{/* ── Füllung (nur geschlossene 2D-Form) ────────────────────────── */}
<div className="attr-section-label">{t("attr.fill")}</div>
<div className="attr-grid">
{/* Vollton-Füllfarbe (getrennt von der Strichfarbe). „keine" = transparent. */}
<span className="attr-key">{t("attr.fillColor")}</span>
<span className="attr-val">
<label className="attr-color">
<span
className="attr-color-swatch"
style={{
background: fillEditable && sel.fillColor ? sel.fillColor : "transparent",
}}
/>
<input
type="color"
value={sel.fillColor ?? "#808080"}
disabled={!fillEditable}
title={fillEditable ? undefined : t("attr.fillNeedsClosed")}
onChange={(e) => host.onSetSelectionFillColor(e.target.value)}
/>
<span className="attr-color-hex">
{fillEditable && sel.fillColor ? sel.fillColor : t("attr.none")}
</span>
{fillEditable && sel.fillColor && (
<button
type="button"
className="attr-color-clear"
title={t("attr.clearFill")}
onClick={() => host.onSetSelectionFillColor(null)}
>
×
</button>
)}
</label>
</span>
<span className="attr-key">{t("attr.hatch")}</span>
<span className="attr-val">
<select
className="attr-select"
value={fillEditable ? sel.fillHatchId ?? "" : ""}
disabled={!fillEditable}
title={fillEditable ? undefined : t("attr.fillNeedsClosed")}
onChange={(e) =>
host.onSetSelectionFill(e.target.value || null)
}
>
<option value="">{t("attr.none")}</option>
{project.hatches.map((h) => (
<option key={h.id} value={h.id}>
{h.name}
</option>
))}
</select>
</span>
</div>
{/* ── Maße (informativ, aus der bbox) ───────────────────────────── */}
<div className="attr-section-label">{t("attr.size")}</div>
<div className="attr-grid">
<span className="attr-key">{t("attr.width")}</span>
<span className="attr-val attr-readonly">{formatM(bboxW)}</span>
<span className="attr-key">{t("attr.height")}</span>
<span className="attr-val attr-readonly">{formatM(bboxH)}</span>
</div>
</div>
);
}
+58
View File
@@ -0,0 +1,58 @@
// Darstellungsmodus-Umschalter — kleines dunkles Dropdown für die Panel-Kopfzeile.
//
// Reines, kontrolliertes Steuerelement: zeigt die fünf DOSSIER-DisplayMode-
// Optionen (Alle anzeigen / Ausgewählte / Nur aktive / Andere grau / Andere
// grau & gesperrt) und meldet die Wahl per Callback. Es kennt weder Host noch
// Registry — der Rahmen (PanelFrame) reicht `value` und `onChange` durch, die
// App verdrahtet sie mit `host.displayMode`.
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import type { DisplayMode } from "./types";
import { t } from "../i18n";
/**
* Beschriftungs-Keys je Modus (Werte englisch, vgl. types.ts) — exakt die
* fünf DOSSIER-Modi aus docs/design/context-menu.md.
*/
const MODE_OPTIONS: { value: DisplayMode; labelKey: string }[] = [
{ value: "all_force", labelKey: "display.all_force" },
{ value: "all", labelKey: "display.all" },
{ value: "active", labelKey: "display.active" },
{ value: "grey", labelKey: "display.grey" },
{ value: "grey_locked", labelKey: "display.grey_locked" },
];
/**
* Kleines Pill-Dropdown für den Darstellungsmodus. Vollständig kontrolliert:
* `value` ist der aktuelle Modus, `onChange` liefert den neuen.
*/
export function DisplayModeSelect({
value,
onChange,
title,
}: {
/** Aktuell gewählter Darstellungsmodus. */
value: DisplayMode;
/** Wird mit dem neuen Modus aufgerufen, wenn der Nutzer wählt. */
onChange: (mode: DisplayMode) => void;
/** Optionaler Tooltip auf dem Dropdown (Default: i18n „Darstellungsmodus"). */
title?: string;
}) {
const label = title ?? t("display.title");
return (
<select
className="display-mode"
value={value}
title={label}
aria-label={label}
onChange={(e) => onChange(e.target.value as DisplayMode)}
>
{MODE_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{t(o.labelKey)}
</option>
))}
</select>
);
}
+265
View File
@@ -0,0 +1,265 @@
// Dock — eine angedockte Spalte (links oder rechts) mit GESTAPELTEN Gruppen.
//
// Ein Dock hält eine Liste vertikal gestapelter Gruppen (DockState.groups). Jede
// Gruppe ist ein eigener Tab-Stapel (TabStrip + aktives Panel im PanelFrame) und
// trägt ein Höhen-Gewicht; zwischen den Gruppen sitzt ein Splitter, der die
// Gewichte der Nachbarn verschiebt. So entsteht das Vectorworks-Bild (z. B.
// links Werkzeuge OBEN, Attribute UNTEN gleichzeitig).
//
// Jede Gruppe rendert ihren PanelFrame in einem EIGENEN Host-Provider, damit der
// Darstellungsmodus-Umschalter dem aktiven Tab DIESER Gruppe folgt. Den Provider
// baut der Host (App) und reicht ihn als `buildHost(activeTab)` herein — das Dock
// bleibt so vom konkreten Host-Modell entkoppelt.
//
// Breite ist per Ziehgriff an der INNEREN Kante veränderbar (rechte Kante beim
// linken Dock, linke Kante beim rechten Dock); `dock.size` ist die Quelle der
// Wahrheit, `onResize(size)` meldet jede Änderung zurück. Die Gruppen-Höhen
// meldet `onResizeGroups(weights)`.
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import { useCallback, useRef, useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import { PanelFrame } from "./PanelFrame";
import { TabStrip } from "./TabStrip";
import { getPanel } from "./registry";
import { PanelHostContext } from "./types";
import type {
DisplayMode,
DockId,
DockState,
PanelContext,
PanelHost,
} from "./types";
import { t } from "../i18n";
/** Untere/obere Grenze der Dock-Breite in CSS-Pixeln (Voreinstellung). */
const DEFAULT_MIN_WIDTH = 200;
const DEFAULT_MAX_WIDTH = 480;
/** Mindest-Pixelhöhe, die eine Gruppe beim Splitter-Ziehen behält. */
const MIN_GROUP_PX = 70;
export interface DockProps {
/** Welches Dock: bestimmt Rand + Seite des Ziehgriffs. */
side: DockId;
/** Zustand dieses Docks (Gruppen + Breite). */
dock: DockState;
/**
* Beginn einer Tab-Geste (pointerdown auf einem Tab) — an die TabStrip
* weitergereicht. Der Drag-Controller klärt Klick (aktivieren) vs. Ziehen
* (Reorder/Andocken/Lösen/Stapeln).
*/
onStartTabDrag: (panelId: string, e: React.PointerEvent) => void;
/** Breite geändert — liefert die neue (bereits geklemmte) Breite in px. */
onResize: (size: number) => void;
/** Gruppen-Höhen geändert — liefert die neue Gewichts-Liste (so lang wie groups). */
onResizeGroups: (weights: number[]) => void;
/**
* Baut den Host-Context-Wert für eine Gruppe (passend zu ihrem aktiven Tab).
* App reicht hier `hostWithMode(...)` herein; das Dock wickelt jede Gruppe in
* einen eigenen <PanelHostContext.Provider>.
*/
buildHost: (activeTab: string | null) => PanelHost;
/** Generischer Kontext, den der PanelFrame an `def.render` weiterreicht. */
ctx?: PanelContext;
/** Aktueller Darstellungsmodus zur aktiven Tab-ID einer Gruppe. */
displayModeFor: (activeTab: string | null) => DisplayMode;
/** Setter des Darstellungsmodus zur aktiven Tab-ID einer Gruppe. */
onDisplayModeChange: (activeTab: string | null, mode: DisplayMode) => void;
/** Minimale Breite in px (Voreinstellung 200). */
minWidth?: number;
/** Maximale Breite in px (Voreinstellung 480). */
maxWidth?: number;
}
/** Begrenzt eine Breite auf [min, max]. */
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
/**
* Eine angedockte Spalte mit gestapelten Gruppen. Rendert je Gruppe TabStrip +
* aktives Panel (im eigenen Host-Provider) und erlaubt das Verändern von Breite
* (innere Kante) und Gruppen-Höhen (Splitter zwischen Gruppen).
*/
export function Dock({
side,
dock,
onStartTabDrag,
onResize,
onResizeGroups,
buildHost,
ctx,
displayModeFor,
onDisplayModeChange,
minWidth = DEFAULT_MIN_WIDTH,
maxWidth = DEFAULT_MAX_WIDTH,
}: DockProps) {
// Wurzelelement, um die Breite während des Ziehens aus clientX zu berechnen.
const rootRef = useRef<HTMLDivElement | null>(null);
// Container der Gruppen (für die Gesamthöhe beim Gruppen-Splitter).
const stackRef = useRef<HTMLDivElement | null>(null);
// Breite während des aktiven Ziehens; null = nicht im Ziehen (dock.size gilt).
const [dragWidth, setDragWidth] = useState<number | null>(null);
const onResizeStart = useCallback(
(e: ReactPointerEvent<HTMLDivElement>) => {
// Nur primäre Taste / Berührung; Griff fängt den Zeiger ein, damit das
// Ziehen auch außerhalb des Elements weiterläuft.
e.preventDefault();
const handle = e.currentTarget;
handle.setPointerCapture(e.pointerId);
const move = (ev: PointerEvent) => {
const root = rootRef.current;
if (!root) return;
const rect = root.getBoundingClientRect();
// Linkes Dock wächst nach rechts (Zeiger − linke Kante);
// rechtes Dock wächst nach links (rechte Kante − Zeiger).
const raw =
side === "left" ? ev.clientX - rect.left : rect.right - ev.clientX;
const next = clamp(Math.round(raw), minWidth, maxWidth);
setDragWidth(next);
onResize(next);
};
const up = (ev: PointerEvent) => {
setDragWidth(null);
handle.removeEventListener("pointermove", move);
handle.removeEventListener("pointerup", up);
handle.removeEventListener("pointercancel", up);
if (handle.hasPointerCapture(ev.pointerId)) {
handle.releasePointerCapture(ev.pointerId);
}
};
handle.addEventListener("pointermove", move);
handle.addEventListener("pointerup", up);
handle.addEventListener("pointercancel", up);
},
[side, minWidth, maxWidth, onResize],
);
// Splitter zwischen Gruppe `index` und `index+1`: verschiebt Gewicht zwischen
// den beiden Nachbarn. Umgerechnet wird der Pixel-Versatz über die Gesamthöhe
// des Stapels in einen Gewichts-Anteil (Summe der Gewichte bleibt erhalten).
const onGroupResizeStart = useCallback(
(index: number, e: ReactPointerEvent<HTMLDivElement>) => {
e.preventDefault();
const handle = e.currentTarget;
handle.setPointerCapture(e.pointerId);
const stack = stackRef.current;
const totalPx = stack ? stack.getBoundingClientRect().height : 0;
const weights = dock.groups.map((g) => g.weight);
const sumW = weights.reduce((s, w) => s + w, 0) || 1;
const startY = e.clientY;
const wA = weights[index];
const wB = weights[index + 1];
// Pixel ↔ Gewicht: ein Pixel entspricht sumW/totalPx an Gewicht.
const perPx = totalPx > 0 ? sumW / totalPx : 0;
const minW = MIN_GROUP_PX * perPx;
const move = (ev: PointerEvent) => {
if (perPx === 0) return;
const dW = (ev.clientY - startY) * perPx;
// A wächst nach unten, B schrumpft; beide bleiben über dem Minimum.
let nextA = wA + dW;
let nextB = wB - dW;
const pair = wA + wB;
nextA = clamp(nextA, minW, pair - minW);
nextB = pair - nextA;
const out = [...weights];
out[index] = nextA;
out[index + 1] = nextB;
onResizeGroups(out);
};
const up = (ev: PointerEvent) => {
handle.removeEventListener("pointermove", move);
handle.removeEventListener("pointerup", up);
handle.removeEventListener("pointercancel", up);
if (handle.hasPointerCapture(ev.pointerId)) {
handle.releasePointerCapture(ev.pointerId);
}
};
handle.addEventListener("pointermove", move);
handle.addEventListener("pointerup", up);
handle.addEventListener("pointercancel", up);
},
[dock.groups, onResizeGroups],
);
const width = dragWidth ?? dock.size;
// Breite als CSS-Variable; styles.css liest --dock-w für flex-basis/width.
const style = { "--dock-w": `${width}px` } as CSSProperties;
return (
<aside
ref={rootRef}
className={`dock dock-${side}${dragWidth !== null ? " resizing" : ""}`}
style={style}
>
<div className="dock-stack" ref={stackRef}>
{dock.groups.map((group, gi) => {
const def = group.activeTab ? getPanel(group.activeTab) : undefined;
const groupStyle = { flexGrow: group.weight } as CSSProperties;
return [
<section
key={`g${gi}`}
className="dock-group"
style={groupStyle}
// Datenattribute für die Drag-Auflösung: über dem Körper einer
// Gruppe entsteht beim Ablegen eine NEUE Gruppe darüber/darunter.
data-group-body=""
data-dock={side}
data-group-index={gi}
>
<TabStrip
side={side}
groupIndex={gi}
group={group}
onStartDrag={onStartTabDrag}
/>
{def ? (
<PanelHostContext.Provider value={buildHost(group.activeTab)}>
<PanelFrame
def={def}
ctx={ctx}
displayMode={displayModeFor(group.activeTab)}
onDisplayModeChange={(m) =>
onDisplayModeChange(group.activeTab, m)
}
/>
</PanelHostContext.Provider>
) : (
<div className="panel-frame panel-frame-empty" />
)}
</section>,
// Splitter zwischen dieser und der nächsten Gruppe.
gi < dock.groups.length - 1 ? (
<div
key={`s${gi}`}
className="dock-group-resize"
role="separator"
aria-orientation="horizontal"
aria-label={t("dock.resize")}
onPointerDown={(e) => onGroupResizeStart(gi, e)}
/>
) : null,
];
})}
</div>
{/* Ziehgriff an der inneren Kante (rechts bei links, links bei rechts). */}
<div
className="dock-resize"
role="separator"
aria-orientation="vertical"
aria-label={t("dock.resize")}
onPointerDown={onResizeStart}
/>
</aside>
);
}
+157
View File
@@ -0,0 +1,157 @@
// Inhalts-Panel „Zeichnungsebenen" — die obere Navigator-Liste als Panel.
//
// Zeigt die Zeichnungsebenen (Geschosse + Schnitte/Ansichten/Zeichnungen)
// exakt wie der bisherige Navigator: EyeIcon schaltet die Sichtbarkeit, ein
// Kind-Badge zeigt die Art, die aktive Ebene ist hervorgehoben, Höhen-Readout
// (+OKFF) bei Geschossen. Kopfzeile mit + Geschoss / + Zeichnung.
//
// Neu gegenüber dem Inline-Navigator: das Panel deklariert hasDisplayMode und
// liest den gewählten DisplayMode aus dem Host-Context. `itemDisplay`
// entscheidet je Zeile, ob sie gezeichnet und ob sie ausgegraut wird (aktives
// Element = Arbeitsfokus, immer sichtbar). Daten/Handler kommen über
// usePanelHost — kein Prop-Drilling.
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import { EyeIcon } from "../ui/EyeIcon";
import { itemDisplay } from "./displayMode";
import { usePanelHost } from "./host";
import type { DrawingLevel } from "./host";
import type { DisplayMode } from "./types";
import type { DrawingLevelKind } from "../model/types";
import { t } from "../i18n";
/** i18n-Key des Kurzbadges für die Art einer Zeichnungsebene. */
const KIND_BADGE_KEY: Record<DrawingLevelKind, string> = {
floor: "badge.floor",
section: "badge.section",
elevation: "badge.elevation",
drawing: "badge.drawing",
};
/** Inhalt des Panels „Zeichnungsebenen". */
export function DrawingLevelsPanel() {
const host = usePanelHost();
const { project, activeLevelId, displayMode } = host;
// Oberstes Geschoss zuoberst (wie DOSSIER): Anzeige umkehren.
const levelsTopDown = [...project.drawingLevels].reverse();
// Klick aufs Auge in „Alle anzeigen"/„Nur aktive" schaltet automatisch auf
// „Ausgewählte" (DOSSIER-Regel), damit das Sichtbarkeits-Flag sofort greift.
const toggle = (id: string) => {
const m = displayMode.mode;
if (m === "all_force" || m === "active") displayMode.setMode("all");
host.onToggleLevel(id);
};
// Rechtsklick auf eine Zeile → Kontextmenü in App öffnen (Browser-Menü aus).
const onRowContext = (id: string, e: React.MouseEvent) => {
e.preventDefault();
host.onLevelContextMenu(id, e.clientX, e.clientY);
};
return (
<section className="nav-group">
<header className="nav-group-head">
<span className="nav-group-title">{t("nav.drawingLevels")}</span>
<span className="nav-group-actions">
<button className="nav-add" onClick={host.onAddFloor}>
{t("levels.addFloor")}
</button>
<button className="nav-add" onClick={host.onAddDrawing}>
{t("levels.addDrawing")}
</button>
</span>
</header>
<div className="nav-list">
{levelsTopDown.map((level) => (
<LevelRow
key={level.id}
level={level}
active={level.id === activeLevelId}
mode={displayMode.mode}
onSelect={() => host.onSelectLevel(level.id)}
onToggle={() => toggle(level.id)}
onContext={(e) => onRowContext(level.id, e)}
/>
))}
</div>
</section>
);
}
/** Eine Zeile im Zeichnungsebenen-Bereich. */
function LevelRow({
level,
active,
mode,
onSelect,
onToggle,
onContext,
}: {
level: DrawingLevel;
active: boolean;
mode: DisplayMode;
onSelect: () => void;
onToggle: () => void;
onContext: (e: React.MouseEvent) => void;
}) {
// Darstellungsmodus auf diese Zeile anwenden: greyed steuert das Dimmen,
// render würde im Modus "active" Nicht-Aktive ausblenden. Im Navigator bleibt
// jede Zeile bedienbar (Liste = Steuerung), daher blenden wir nicht ganz aus,
// sondern dimmen nicht gezeichnete Zeilen wie ausgegraute.
const display = itemDisplay(mode, active, level.visible);
const dimmed = display.greyed || !display.render;
// In „Alle anzeigen" sind die Augen gedimmt (Sichtbarkeit erzwungen, Flag
// ohne Wirkung). „grey_locked" sperrt nicht-aktive Zeilen.
const eyeDimmed = mode === "all_force";
const cls = [
"nav-row",
active ? "active" : "",
display.locked ? "locked" : "",
// Bisheriges Verhalten: nicht-aktive, unsichtbare Zeilen gedimmt.
!active && !level.visible ? "hidden" : "",
]
.filter(Boolean)
.join(" ");
// Zusätzliches Dimmen aus dem DisplayMode (nicht in styles.css → inline).
// Aktiv bleibt immer voll sichtbar.
const style = !active && dimmed ? { opacity: 0.45 } : undefined;
return (
<div
className={cls}
style={style}
onClick={onSelect}
onContextMenu={onContext}
>
<button
className={`eye${level.visible ? " is-on" : ""}`}
aria-label={t("nav.visibility")}
title={level.visible ? t("nav.hide") : t("nav.show")}
style={eyeDimmed ? { opacity: 0.5 } : undefined}
onClick={(e) => {
e.stopPropagation();
onToggle();
}}
>
<EyeIcon open={level.visible} />
</button>
<span className="nav-label">{level.name}</span>
{level.kind === "floor" && level.baseElevation !== undefined && (
<span className="nav-elev">+{level.baseElevation.toFixed(2)}</span>
)}
<span className={`chip badge-${level.kind}`}>
{t(KIND_BADGE_KEY[level.kind])}
</span>
</div>
);
}
+214
View File
@@ -0,0 +1,214 @@
// Schwebendes Panel — ein frei positioniertes, dunkles Fenster über der Mitte.
//
// Rendert denselben Panel-Inhalt wie ein Dock (über Registry + PanelFrame, inkl.
// Darstellungsmodus-Dropdown für Ebenen-Panels), aber in einem eigenen Fenster
// mit Titelleiste (zum Verschieben), Andock-/Schließen-Knopf und einem
// Greifer unten rechts zum Ändern der Größe. Ein Klick irgendwo im Fenster holt
// es nach vorne (z-Index).
//
// Verschieben/Größe ändern laufen über POINTER-Events mit Pointer-Capture, damit
// die Geste auch außerhalb des Fensters weiterläuft. Während des Ziehens der
// Titelleiste meldet `onTitleDrag` jede Cursorposition an den Host (App), der
// daraus eine mögliche Andock-Geste an einer Rand-Zone ableitet; beim Loslassen
// entscheidet `onTitleDrop`, ob das Fenster schwebend bleibt (verschoben) oder
// in ein Dock wandert.
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import { useCallback, useRef } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import { PanelFrame } from "./PanelFrame";
import { getPanel } from "./registry";
import type { DisplayMode, FloatingPanel as FloatingPanelState, PanelContext } from "./types";
import { t } from "../i18n";
/** Mindestgröße eines schwebenden Fensters in CSS-Pixeln. */
const MIN_W = 220;
const MIN_H = 160;
export interface FloatingPanelProps {
/** Zustand (Position/Größe/z) dieses schwebenden Panels. */
panel: FloatingPanelState;
/** Generischer Kontext für den Panel-Inhalt (an def.render). */
ctx?: PanelContext;
/** Aktueller Darstellungsmodus (für Panels mit hasDisplayMode). */
displayMode?: DisplayMode;
/** Setter des Darstellungsmodus (zeigt den Umschalter in der Kopfzeile). */
onDisplayModeChange?: (mode: DisplayMode) => void;
/** Holt das Fenster nach vorne (z-Index) — bei Klick/Geste-Beginn. */
onFocus: (panelId: string) => void;
/** Verschiebt das Fenster (Position-Patch). */
onMove: (panelId: string, x: number, y: number) => void;
/** Ändert die Größe (w/h-Patch). */
onResize: (panelId: string, w: number, h: number) => void;
/** Dockt das Fenster wieder an (Standard-Andockziel des Redock-Knopfs). */
onRedock: (panelId: string) => void;
/** Schließt das Fenster (entfernt es ganz). */
onClose: (panelId: string) => void;
/**
* Während des Titel-Ziehens: meldet die aktuelle Cursorposition (clientX/Y) an
* den Host. Der Host hebt mögliche Rand-Andock-Zonen hervor.
*/
onTitleDrag?: (panelId: string, clientX: number, clientY: number) => void;
/**
* Beim Loslassen der Titelleiste über einer Andock-Zone: meldet die finale
* Cursorposition. Liefert `true`, wenn der Host das Fenster angedockt hat (dann
* unterbleibt das reine Verschieben). Sonst bleibt das Fenster schwebend.
*/
onTitleDrop?: (panelId: string, clientX: number, clientY: number) => boolean;
}
/**
* Ein einzelnes schwebendes Panel-Fenster. Titelleiste = verschieben (+ Andocken
* über Rand-Zonen), Greifer unten rechts = Größe ändern, Andock-/Schließen-Knopf
* in der Titelleiste.
*/
export function FloatingPanel({
panel,
ctx,
displayMode,
onDisplayModeChange,
onFocus,
onMove,
onResize,
onRedock,
onClose,
onTitleDrag,
onTitleDrop,
}: FloatingPanelProps) {
const def = getPanel(panel.panelId);
// Verschiebe-Offset (Cursor − linke/obere Kante) für ein ruckelfreies Greifen.
const dragOffset = useRef<{ dx: number; dy: number }>({ dx: 0, dy: 0 });
const onTitlePointerDown = useCallback(
(e: ReactPointerEvent<HTMLDivElement>) => {
// Knöpfe in der Titelleiste nicht als Verschiebe-Start werten.
if ((e.target as HTMLElement).closest("button")) return;
e.preventDefault();
onFocus(panel.panelId);
const handle = e.currentTarget;
handle.setPointerCapture(e.pointerId);
dragOffset.current = {
dx: e.clientX - panel.x,
dy: e.clientY - panel.y,
};
const move = (ev: PointerEvent) => {
const x = ev.clientX - dragOffset.current.dx;
const y = ev.clientY - dragOffset.current.dy;
onMove(panel.panelId, x, y);
onTitleDrag?.(panel.panelId, ev.clientX, ev.clientY);
};
const up = (ev: PointerEvent) => {
handle.removeEventListener("pointermove", move);
handle.removeEventListener("pointerup", up);
handle.removeEventListener("pointercancel", up);
if (handle.hasPointerCapture(ev.pointerId)) {
handle.releasePointerCapture(ev.pointerId);
}
// Über einer Rand-Zone? Dann dockt der Host an; sonst bleibt es schweben.
onTitleDrop?.(panel.panelId, ev.clientX, ev.clientY);
};
handle.addEventListener("pointermove", move);
handle.addEventListener("pointerup", up);
handle.addEventListener("pointercancel", up);
},
[panel.panelId, panel.x, panel.y, onFocus, onMove, onTitleDrag, onTitleDrop],
);
const onResizePointerDown = useCallback(
(e: ReactPointerEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
onFocus(panel.panelId);
const handle = e.currentTarget;
handle.setPointerCapture(e.pointerId);
const startX = e.clientX;
const startY = e.clientY;
const startW = panel.w;
const startH = panel.h;
const move = (ev: PointerEvent) => {
const w = Math.max(MIN_W, Math.round(startW + (ev.clientX - startX)));
const h = Math.max(MIN_H, Math.round(startH + (ev.clientY - startY)));
onResize(panel.panelId, w, h);
};
const up = (ev: PointerEvent) => {
handle.removeEventListener("pointermove", move);
handle.removeEventListener("pointerup", up);
handle.removeEventListener("pointercancel", up);
if (handle.hasPointerCapture(ev.pointerId)) {
handle.releasePointerCapture(ev.pointerId);
}
};
handle.addEventListener("pointermove", move);
handle.addEventListener("pointerup", up);
handle.addEventListener("pointercancel", up);
},
[panel.panelId, panel.w, panel.h, onFocus, onResize],
);
const style: CSSProperties = {
left: `${panel.x}px`,
top: `${panel.y}px`,
width: `${panel.w}px`,
height: `${panel.h}px`,
zIndex: 500 + panel.z,
};
const title = def ? t(def.title) : panel.panelId;
return (
<section
className="floating-panel"
style={style}
onPointerDownCapture={() => onFocus(panel.panelId)}
role="dialog"
aria-label={title}
>
<div className="floating-titlebar" onPointerDown={onTitlePointerDown}>
<span className="floating-title">{title}</span>
<div className="floating-actions">
<button
type="button"
className="floating-btn"
title={t("dock.redock")}
aria-label={t("dock.redock")}
onClick={() => onRedock(panel.panelId)}
>
⤣
</button>
<button
type="button"
className="floating-btn"
title={t("dock.close")}
aria-label={t("dock.close")}
onClick={() => onClose(panel.panelId)}
>
×
</button>
</div>
</div>
{def ? (
<PanelFrame
def={def}
ctx={ctx}
displayMode={displayMode}
onDisplayModeChange={onDisplayModeChange}
/>
) : (
<div className="panel-frame panel-frame-empty" />
)}
{/* Greifer unten rechts (Größe ändern). */}
<div
className="floating-resize"
role="separator"
aria-label={t("dock.resizePanel")}
title={t("dock.resizePanel")}
onPointerDown={onResizePointerDown}
/>
</section>
);
}
+155
View File
@@ -0,0 +1,155 @@
// Inhalts-Panel „Ebenen" — der Kategorie-Baum als Panel.
//
// Zeigt den Grafik-Kategorie-Baum (LayerCategory) exakt wie der bisherige
// Navigator: Farb-Swatch, Code, Name, EyeIcon zum Schalten, eingerückte
// Kinder, Kopfzeile mit + Ebene. Daten/Handler über usePanelHost.
//
// Das Panel deklariert hasDisplayMode. Kategorien kennen keine „aktive"
// Auswahl (anders als Zeichnungsebenen), daher ist isActive je Zeile false;
// `itemDisplay` ergibt damit (fünf DOSSIER-Modi):
// • "all_force" — alle erzwungen sichtbar; Augen gedimmt.
// • "all" — alle sichtbaren normal (Standard, wie bisher).
// • "active" — keine Kategorie aktiv → alle gedimmt.
// • "grey" — sichtbare Kategorien ausgegraut.
// • "grey_locked" — wie „grey", zusätzlich gesperrt.
// Wie im Zeichnungsebenen-Panel bleibt jede Zeile bedienbar (die Liste ist die
// Steuerung); statt auszublenden dimmen wir gedimmte Zeilen per Inline-Opacity
// (styles.css bleibt unangetastet).
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import { EyeIcon } from "../ui/EyeIcon";
import { itemDisplay } from "./displayMode";
import { usePanelHost } from "./host";
import type { LayerCategory } from "./host";
import type { DisplayMode } from "./types";
import { t } from "../i18n";
/** Inhalt des Panels „Ebenen". */
export function LayersPanel() {
const host = usePanelHost();
const { project, displayMode } = host;
// Klick aufs Auge in „Alle anzeigen"/„Nur aktive" schaltet automatisch auf
// „Ausgewählte" (DOSSIER-Regel), damit das Sichtbarkeits-Flag sofort greift.
const toggle = (code: string) => {
const m = displayMode.mode;
if (m === "all_force" || m === "active") displayMode.setMode("all");
host.onToggleCategory(code);
};
// Rechtsklick auf eine Zeile → Kontextmenü in App öffnen (Browser-Menü aus).
const onRowContext = (code: string, e: React.MouseEvent) => {
e.preventDefault();
host.onLayerContextMenu(code, e.clientX, e.clientY);
};
return (
<section className="nav-group">
<header className="nav-group-head">
<span className="nav-group-title">{t("nav.layers")}</span>
<span className="nav-group-actions">
<button className="nav-add" onClick={host.onAddCategory}>
{t("layers.addCategory")}
</button>
</span>
</header>
<div className="nav-list">
{project.layers.map((cat) => (
<CategoryRow
key={cat.code}
category={cat}
depth={0}
mode={displayMode.mode}
activeCode={host.activeCategoryCode}
onSelect={host.onSelectCategory}
onToggle={toggle}
onContext={onRowContext}
/>
))}
</div>
</section>
);
}
/** Eine Kategorie-Zeile im Ebenen-Baum (rekursiv für Kinder). */
function CategoryRow({
category,
depth,
mode,
activeCode,
onSelect,
onToggle,
onContext,
}: {
category: LayerCategory;
depth: number;
mode: DisplayMode;
activeCode: string;
onSelect: (code: string) => void;
onToggle: (code: string) => void;
onContext: (code: string, e: React.MouseEvent) => void;
}) {
// „aktiv" = diese Kategorie ist das Zeichen-Ziel (per Klick gesetzt).
const isActive = category.code === activeCode;
const display = itemDisplay(mode, isActive, category.visible);
const dimmed = display.greyed || !display.render;
// In „Alle anzeigen" sind die Augen gedimmt (Sichtbarkeit erzwungen).
const eyeDimmed = mode === "all_force";
const cls = [
"cat-row",
isActive ? "active" : "",
display.locked ? "locked" : "",
!category.visible ? "hidden" : "",
]
.filter(Boolean)
.join(" ");
// Einrückung wie bisher; zusätzliches Dimmen aus dem DisplayMode inline.
// In „Alle anzeigen" erzwungen sichtbar → auch unsichtbare Zeilen normal.
const style: React.CSSProperties = { paddingLeft: 6 + depth * 14 };
if (dimmed) style.opacity = 0.45;
return (
<>
<div
className={cls}
style={style}
onClick={() => onSelect(category.code)}
onContextMenu={(e) => onContext(category.code, e)}
title={t("nav.categorySelect", { name: category.name })}
>
<button
className={`eye${category.visible ? " is-on" : ""}`}
aria-label={t("nav.categoryVisible", { name: category.name })}
title={category.visible ? t("nav.hide") : t("nav.show")}
style={eyeDimmed ? { opacity: 0.5 } : undefined}
onClick={(e) => {
e.stopPropagation();
onToggle(category.code);
}}
>
<EyeIcon open={category.visible} />
</button>
<span className="cat-code">{category.code}</span>
<span className="cat-swatch" style={{ background: category.color }} />
<span className="cat-name">{category.name}</span>
</div>
{category.children?.map((child) => (
<CategoryRow
key={child.code}
category={child}
depth={depth + 1}
mode={mode}
activeCode={activeCode}
onSelect={onSelect}
onToggle={onToggle}
onContext={onContext}
/>
))}
</>
);
}
+440
View File
@@ -0,0 +1,440 @@
// Inhalts-Panel „Objekt-Info" (Vectorworks-Stil) — kompakte Sicht auf die
// aktuelle Auswahl: ein 3×3-Bezugspunkt-„Würfel", die X/Y-Koordinaten des
// gewählten Bezugspunkts (read-only) sowie editierbare Maße Breite×Höhe.
//
// Bei Auswahl GENAU EINER Wand zeigt das Panel zusätzlich einen Wand-Abschnitt
// (Referenzlinie, Aufbau-Typ/Dicke/Preset, Referenzgeschoss + UK/OK) — analog
// Vectorworks. Die Referenzlinie sitzt rechts oben im Kopf.
//
// Alle Daten/Handler kommen über usePanelHost (kein Prop-Drilling, kein
// Modellzugriff). Der gewählte Bezugspunkt (fx,fy ∈ {0,0.5,1}) ist lokaler
// State und wird als Anker an onResizeSelection durchgereicht: Beim Ändern
// der Maße bleibt genau dieser Punkt fix.
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md). Alle sichtbaren
// Texte über t(...).
import { useState } from "react";
import { t } from "../i18n";
import { formatM } from "../model/types";
import type { VerticalAnchor, WallReferenceLine } from "../model/types";
import { Dropdown } from "../ui/Dropdown";
import { usePanelHost } from "./host";
import type { WallInfo } from "../state/selectionInfo";
// ── Bezugspunkt-Raster ───────────────────────────────────────────────────────
// Neun Anker als 3×3-Raster. Zeile 0 = oben (fy=0), Spalte 0 = links (fx=0).
// Reihenfolge des Arrays = Lesereihenfolge des Grids (links→rechts, oben→unten).
const FRACTIONS = [0, 0.5, 1] as const;
/** Stabiler Schlüssel je Anker (z. B. "0-0.5"), unabhängig von Float-Anzeige. */
function anchorKey(fx: number, fy: number): string {
return `${fx}-${fy}`;
}
export function ObjectInfoPanel() {
const host = usePanelHost();
const sel = host.selection;
// Gewählter Bezugspunkt — Default Mitte (Vectorworks-Standardanker beim
// Skalieren). Bleibt erhalten, solange das Panel montiert ist.
const [anchor, setAnchor] = useState<{ fx: number; fy: number }>({
fx: 0.5,
fy: 0.5,
});
// ── Leerzustand ───────────────────────────────────────────────────────────
if (sel === null) {
return (
<div className="objinfo-panel">
<div className="objinfo-empty">{t("objinfo.empty")}</div>
</div>
);
}
const { bbox } = sel;
const width = bbox.maxX - bbox.minX;
const height = bbox.maxY - bbox.minY;
// X/Y des gewählten Bezugspunkts (Modell-Meter). Read-only: Der Kontrakt
// bietet keinen Positions-Setter, daher wird hier kein Verschieben gefaket.
const px = bbox.minX + anchor.fx * width;
const py = bbox.minY + anchor.fy * height;
// ── Maß-Commit ──────────────────────────────────────────────────────────────
// Liest beide aktuellen Maße aus den Feldern und skaliert um den aktiven
// Anker. Negative/ungültige Werte werden ignoriert (kein Resize).
function commitSize(nextWidth: number, nextHeight: number) {
if (!isFinite(nextWidth) || !isFinite(nextHeight)) return;
if (nextWidth < 0 || nextHeight < 0) return;
host.onResizeSelection(nextWidth, nextHeight, anchor);
}
const wall = sel.wall;
return (
<div className="objinfo-panel">
{/* Kopfzeile: Typ + Kategorie. Bei einer Wand rechts oben das
Referenzlinie-Dropdown (wo die Achse über die Dicke liegt). */}
<div className="objinfo-head">
<span className="objinfo-kind">{t(`objinfo.kind.${sel.kind}`)}</span>
{wall ? (
<div className="objinfo-head-ref" title={t("objinfo.wall.refLine")}>
<span className="objinfo-head-ref-label">
{t("objinfo.wall.refLine")}
</span>
<Dropdown
value={wall.referenceLine}
onChange={(v) =>
host.onSetWallReferenceLine(v as WallReferenceLine)
}
options={[
{ value: "left", label: t("objinfo.wall.refLine.left") },
{ value: "center", label: t("objinfo.wall.refLine.center") },
{ value: "right", label: t("objinfo.wall.refLine.right") },
]}
title={t("objinfo.wall.refLine")}
width={120}
/>
</div>
) : (
<span className="objinfo-cat">{sel.categoryCode}</span>
)}
</div>
<div className="objinfo-sep" />
{/* Bezugspunkt-Würfel (3×3). */}
<div className="objinfo-section-label">{t("objinfo.refpoint")}</div>
<div className="objinfo-cube" role="group" aria-label={t("objinfo.refpoint")}>
{FRACTIONS.map((fy) =>
FRACTIONS.map((fx) => {
const active = anchor.fx === fx && anchor.fy === fy;
return (
<button
key={anchorKey(fx, fy)}
type="button"
className={"objinfo-anchor" + (active ? " active" : "")}
title={t("objinfo.setRefpoint")}
aria-pressed={active}
onClick={() => setAnchor({ fx, fy })}
>
<span className="objinfo-dot" />
</button>
);
}),
)}
</div>
{/* X / Y des gewählten Bezugspunkts (read-only). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.x")}</span>
<span className="objinfo-fval">{formatM(px)}</span>
</div>
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.y")}</span>
<span className="objinfo-fval">{formatM(py)}</span>
</div>
{/* Z ehrlich: Das Modell ist 2D pro Ebene; es gibt keine Z am Element.
Daher „—" statt einer gefakten 0, mit kurzem Hinweis im Titel. */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.z")}</span>
<span className="objinfo-fval objinfo-muted" title={t("objinfo.zHint")}>
—
</span>
</div>
<div className="objinfo-sep" />
{/* Maße (editierbar) — Commit auf blur/Enter skaliert um den Anker. */}
<div className="objinfo-section-label">{t("objinfo.dimensions")}</div>
<DimensionField
label={t("objinfo.width")}
value={width}
onCommit={(w) => commitSize(w, height)}
/>
<DimensionField
label={t("objinfo.height")}
value={height}
onCommit={(h) => commitSize(width, h)}
/>
{/* ── Wand-Abschnitt (nur bei genau einer Wand) ────────────────────── */}
{wall && <WallSection wall={wall} host={host} />}
</div>
);
}
// ── Wand-Attribut-Abschnitt ──────────────────────────────────────────────────
// Aufbau-Typ (einschichtig/mehrschichtig), Dicke/Preset, Referenzgeschoss +
// Oberverknüpfung, UK/OK je Modus-Umschalter. Alle Werte/Setter über den Host.
function WallSection({
wall,
host,
}: {
wall: WallInfo;
host: ReturnType<typeof usePanelHost>;
}) {
// Aufbau-Typ-Segment: einschichtig vs. mehrschichtig. Aus dem Modell
// abgeleitet (singleLayer); der Umschalter auf „mehrschichtig" wählt einen
// mehrschichtigen Wandtyp-Preset (erster mit >1 Schicht), auf „einschichtig"
// setzt er die Dicke des aktuellen Aufbaus als einschichtige Wand.
const isSingle = wall.singleLayer;
const multiPresets = wall.wallTypes.filter((wt) => wt.layerCount > 1);
function chooseSingle() {
if (isSingle) return;
// Auf einschichtig wechseln: aktuelle Gesamtdicke als Einzelschicht setzen.
host.onSetWallThickness(wall.thickness);
}
function chooseMulti() {
if (!isSingle) return;
const first = multiPresets[0];
if (first) host.onSetWallType(first.id);
}
return (
<>
<div className="objinfo-sep" />
<div className="objinfo-section-label">{t("objinfo.wall.section")}</div>
{/* Aufbau-Typ-Segment. */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.wall.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.wall.thickness")}
value={wall.thickness}
onCommit={(v) => host.onSetWallThickness(v)}
/>
) : (
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.wall.preset")}</span>
<Dropdown
value={wall.wallTypeId}
onChange={(id) => host.onSetWallType(id)}
options={wall.wallTypes.map((wt) => ({
value: wt.id,
label: t("objinfo.wall.presetLabel", {
name: wt.name,
thickness: wt.thickness.toFixed(2),
}),
}))}
title={t("objinfo.wall.preset")}
width={150}
/>
</div>
)}
{/* Referenzgeschoss (read-only Anzeige). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.wall.refFloor")}</span>
<span className="objinfo-fval">{wall.floorName}</span>
</div>
{/* Verknüpfung zum oberen Geschoss: OK an nächstes Geschoss binden. */}
<label className="objinfo-field objinfo-check-field">
<span className="objinfo-flabel">{t("objinfo.wall.linkAbove")}</span>
<input
type="checkbox"
className="objinfo-check"
checked={wall.top?.mode === "floor"}
disabled={!wall.floorAbove}
title={
wall.floorAbove
? wall.floorAbove.name
: t("objinfo.wall.floorAboveNone")
}
onChange={(e) => {
if (e.target.checked && wall.floorAbove) {
host.onSetWallTop({ mode: "floor", floorId: wall.floorAbove.id });
} else {
host.onSetWallTop(null);
}
}}
/>
</label>
<div className="objinfo-sep" />
{/* UK & OK getrennt: je „an Geschoss gebunden" ODER „eigene Höhe". */}
<VerticalAnchorRow
label={t("objinfo.wall.bottom")}
anchor={wall.bottom}
defaultZ={wall.zBottom}
floors={wall.floors}
defaultFloorId={wall.floorId}
onChange={(a) => host.onSetWallBottom(a)}
/>
<VerticalAnchorRow
label={t("objinfo.wall.top")}
anchor={wall.top}
defaultZ={wall.zTop}
floors={wall.floors}
defaultFloorId={wall.floorAbove?.id ?? wall.floorId}
onChange={(a) => host.onSetWallTop(a)}
/>
{/* Höhe = OK − UK (abgeleitet, read-only). */}
<div className="objinfo-field">
<span className="objinfo-flabel">{t("objinfo.wall.height")}</span>
<span className="objinfo-fval">{formatM(wall.zTop - wall.zBottom)}</span>
</div>
</>
);
}
// ── UK/OK-Zeile: Modus-Umschalter (Geschoss/eigene Höhe) + Feld ──────────────
// „Geschoss gebunden" → Geschoss-Dropdown; „eigene Höhe" → absolutes Z-Feld.
// `null`-Anchor (undefined im Modell) = Geschoss-Default; zeigt „eigene Höhe"
// mit dem aufgelösten Default-Z als Ausgangswert NICHT — Default ist
// „an Geschoss gebunden" (Geschoss-Default-Verhalten).
function VerticalAnchorRow({
label,
anchor,
defaultZ,
floors,
defaultFloorId,
onChange,
}: {
label: string;
anchor?: VerticalAnchor;
defaultZ: number;
floors: { id: string; name: string }[];
defaultFloorId: string;
onChange: (a: VerticalAnchor | null) => void;
}) {
const mode: "floor" | "custom" = anchor?.mode === "custom" ? "custom" : "floor";
function setMode(next: "floor" | "custom") {
if (next === mode) return;
if (next === "custom") {
onChange({ mode: "custom", z: defaultZ });
} else {
// Zurück auf Geschoss-Bindung: bei vorhandenem expliziten Geschoss dieses,
// sonst Default-Geschoss → entspricht dem Geschoss-Default-Verhalten.
onChange({ mode: "floor", floorId: defaultFloorId });
}
}
return (
<div className="objinfo-wall-anchor">
<div className="objinfo-field">
<span className="objinfo-flabel">{label}</span>
<div className="objinfo-segment">
<button
type="button"
className={"objinfo-seg-btn" + (mode === "floor" ? " active" : "")}
onClick={() => setMode("floor")}
>
{t("objinfo.wall.anchorFloor")}
</button>
<button
type="button"
className={"objinfo-seg-btn" + (mode === "custom" ? " active" : "")}
onClick={() => setMode("custom")}
>
{t("objinfo.wall.anchorCustom")}
</button>
</div>
</div>
{mode === "floor" ? (
<div className="objinfo-field objinfo-subfield">
<Dropdown
value={anchor?.mode === "floor" ? anchor.floorId : defaultFloorId}
onChange={(id) => onChange({ mode: "floor", floorId: id })}
options={floors.map((f) => ({ value: f.id, label: f.name }))}
title={label}
width={150}
/>
</div>
) : (
<div className="objinfo-subfield">
<DimensionField
label={t("objinfo.z")}
value={anchor?.mode === "custom" ? anchor.z : defaultZ}
min={-Infinity}
onCommit={(z) => onChange({ mode: "custom", z })}
/>
</div>
)}
</div>
);
}
// ── Editierbares Maßfeld ─────────────────────────────────────────────────────
// Kontrolliert über den vom Host gelieferten Wert, hält aber während des
// Tippens einen lokalen Entwurf. Commit auf blur/Enter; Escape verwirft.
// Nach erfolgreichem Resize liefert der Host eine neue bbox → neuer `value` →
// das Feld zeigt automatisch die aktualisierte Größe. `min` erlaubt negative
// Werte (z. B. absolute Z-Höhen unter 0).
function DimensionField({
label,
value,
onCommit,
min = 0,
}: {
label: string;
value: number;
onCommit: (v: number) => void;
min?: number;
}) {
// Entwurf als String, damit Zwischenzustände (leer, „1.") tippbar sind.
// `null` = kein aktiver Entwurf → zeige den Host-Wert.
const [draft, setDraft] = useState<string | null>(null);
const shown = draft ?? value.toFixed(3);
function commit() {
if (draft === null) return;
const v = Number(draft);
setDraft(null);
if (isFinite(v) && v >= min) onCommit(v);
}
return (
<label className="objinfo-field">
<span className="objinfo-flabel">{label}</span>
<input
className="objinfo-input"
type="number"
step={0.01}
min={isFinite(min) ? min : undefined}
value={shown}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") {
commit();
(e.target as HTMLInputElement).blur();
} else if (e.key === "Escape") {
setDraft(null);
(e.target as HTMLInputElement).blur();
}
}}
/>
<span className="objinfo-unit">m</span>
</label>
);
}
+67
View File
@@ -0,0 +1,67 @@
// Panel-Rahmen — Kopfzeile + scrollbarer Körper um einen Panel-Inhalt.
//
// Der Rahmen ist inhaltsneutral: er bekommt eine PanelDef (für Titel und das
// Flag hasDisplayMode) und den generischen PanelContext, mit dem er
// `def.render(ctx)` aufruft. Den eigentlichen Inhalt liefert also das Panel
// selbst; der Rahmen kümmert sich nur um Titelzeile, optionalen
// Darstellungsmodus-Umschalter und den überlaufenden, scrollbaren Körper.
//
// Der Darstellungsmodus wird von außen durchgereicht (value + onChange), damit
// der Rahmen nichts vom Host-Context wissen muss. Der Umschalter erscheint nur,
// wenn das Panel ihn deklariert (def.hasDisplayMode) UND ein onChange anliegt.
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import { DisplayModeSelect } from "./DisplayModeSelect";
import type { DisplayMode, PanelContext, PanelDef } from "./types";
import { t } from "../i18n";
export interface PanelFrameProps {
/** Definition des darzustellenden Panels (liefert Titel + hasDisplayMode). */
def: PanelDef;
/** Generischer Kontext, der an `def.render` weitergereicht wird. */
ctx?: PanelContext;
/**
* Aktueller Darstellungsmodus. Nur relevant, wenn das Panel
* `hasDisplayMode` deklariert; der Umschalter ist sonst ausgeblendet.
*/
displayMode?: DisplayMode;
/**
* Setter für den Darstellungsmodus. Liegt er an und deklariert das Panel
* `hasDisplayMode`, zeigt die Kopfzeile das Dropdown.
*/
onDisplayModeChange?: (mode: DisplayMode) => void;
}
/**
* Container eines einzelnen Panels: Kopfzeile (Titel + optionaler
* Darstellungsmodus-Umschalter) über einem scrollbaren Körper, der den
* gerenderten Panel-Inhalt aufnimmt.
*/
export function PanelFrame({
def,
ctx,
displayMode,
onDisplayModeChange,
}: PanelFrameProps) {
// Umschalter nur, wenn das Panel ihn will UND er verdrahtet ist.
const showMode =
Boolean(def.hasDisplayMode) &&
onDisplayModeChange !== undefined &&
displayMode !== undefined;
return (
<section className="panel-frame">
<header className="panel-head">
<span className="panel-title">{t(def.title)}</span>
{showMode && (
<DisplayModeSelect
value={displayMode as DisplayMode}
onChange={onDisplayModeChange as (m: DisplayMode) => void}
/>
)}
</header>
<div className="panel-body">{def.render(ctx ?? {})}</div>
</section>
);
}
+91
View File
@@ -0,0 +1,91 @@
// Inhalts-Panel „Ressourcen" — dünner Adapter um den ResourceManager.
//
// Wiederverwendet die bestehende <ResourceManager>-Komponente (Bauteile /
// Schraffuren / Linien) UNVERÄNDERT, rendert sie aber INLINE als Panel-Körper
// statt als rechte Schublade. Projekt + Handler kommen aus dem Host-Context.
//
// ResourceManager ist als Overlay-Schublade gebaut (.res-overlay fixed/inset:0
// → .res-drawer mit fester Breite, Rand und Schatten) und erwartet onClose.
// Da styles.css und ResourceManager selbst nicht angefasst werden dürfen,
// zähmt dieser Adapter die Schubladen-Chrome lokal: ein eingebetteter,
// gescopter <style>-Block (kein Eingriff in styles.css) löst das Overlay aus
// der Fixpositionierung, lässt die „Schublade" die Panel-Fläche füllen und
// blendet den Schließen-Knopf aus (ein Panel schließt man über den Rahmen,
// nicht über die Schublade). onClose ist daher ein No-Op.
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md).
import { ResourceManager } from "../ui/ResourceManager";
import type { ResourceManagerHandlers } from "../ui/ResourceManager";
import { usePanelHost } from "./host";
/** Inhalt des Panels „Ressourcen". */
export function ResourcesPanel() {
const host = usePanelHost();
// Handler aus dem Host auf die Signatur des ResourceManager bündeln.
const handlers: ResourceManagerHandlers = {
onPatchComponent: host.onPatchComponent,
onAddComponent: host.onAddComponent,
onDeleteComponent: host.onDeleteComponent,
onPatchHatch: host.onPatchHatch,
onAddHatch: host.onAddHatch,
onDeleteHatch: host.onDeleteHatch,
onPatchLineStyle: host.onPatchLineStyle,
onAddLineStyle: host.onAddLineStyle,
onDeleteLineStyle: host.onDeleteLineStyle,
onImportLineStyles: host.onImportLineStyles,
onImportHatches: host.onImportHatches,
};
return (
<div className="panel-resources-host">
{/* Gescopte Override-Regeln: machen aus der Overlay-Schublade einen
flächenfüllenden Inline-Körper. Betrifft nur Nachfahren dieses
Wrappers — styles.css bleibt unberührt. */}
<style>{PANEL_RESOURCES_CSS}</style>
<ResourceManager
project={host.project}
handlers={handlers}
onClose={NO_OP}
/>
</div>
);
}
/** onClose-Platzhalter: als Panel gibt es keinen Schubladen-Schließer. */
const NO_OP = () => {};
/**
* Lokale Override-Regeln, die die fixe Overlay-Schublade in einen normalen,
* im Panel sitzenden Block verwandeln. Klassen-Selektoren sind unter
* `.panel-resources-host` verschachtelt, damit nichts außerhalb des Panels
* betroffen ist.
*/
const PANEL_RESOURCES_CSS = `
.panel-resources-host {
height: 100%;
min-height: 0;
display: flex;
}
.panel-resources-host .res-overlay {
position: static;
inset: auto;
z-index: auto;
background: transparent;
display: flex;
flex: 1;
min-height: 0;
justify-content: stretch;
}
.panel-resources-host .res-drawer {
width: 100%;
height: 100%;
border-left: none;
box-shadow: none;
}
/* Im Panel ohne Schubladen-Schließer. */
.panel-resources-host .res-close {
display: none;
}
`;
+125
View File
@@ -0,0 +1,125 @@
// Inhalts-Panel „Gelände & Kontext" — verwaltet die Kontext-Schicht
// (importierte Meshes, Höhenlinien-Sätze, abgeleitetes Gelände-TIN). Daten/
// Handler kommen über usePanelHost (kein Prop-Drilling).
//
// Aufbau:
// • Aktions-Zeile: „DXF importieren" (öffnet den in App montierten Datei-
// Dialog) + „Gelände erzeugen" (aus dem gewählten Kontur-Satz).
// • Liste der Kontext-Objekte: Name + Typ-Badge + „Entfernen".
// • DWG-Hinweis am Fuß.
//
// Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md). Alle sichtbaren
// Texte über t(...).
import { useState } from "react";
import { t } from "../i18n";
import type { TranslationKey } from "../i18n";
import { usePanelHost } from "./host";
import type { ContextObject } from "../model/types";
/** Typ-spezifisches Badge-Label (i18n-Key) je Kontext-Objekt-Art. */
function typeBadgeKey(obj: ContextObject): TranslationKey {
switch (obj.type) {
case "importedMesh":
return "site.type.mesh";
case "contourSet":
return "site.type.contours";
case "terrainMesh":
return "site.type.terrain";
}
}
export function SitePanel() {
const host = usePanelHost();
const objects = host.contextObjects;
const contourSets = objects.filter((o) => o.type === "contourSet");
// Gewählter Kontur-Satz für „Gelände erzeugen" (default: erster vorhandener).
const [selectedSet, setSelectedSet] = useState<string>("");
const effectiveSet =
selectedSet && contourSets.some((c) => c.id === selectedSet)
? selectedSet
: (contourSets[0]?.id ?? "");
const onGenerate = () => {
if (!effectiveSet) return;
const id = host.onGenerateTerrain(effectiveSet);
if (id === null) {
// Kein sinnvolles TIN — ruhiger Hinweis (keine harte Fehlerbox).
// eslint-disable-next-line no-alert
alert(t("site.terrainFailed"));
}
};
return (
<div className="site-panel">
{/* Aktions-Zeile: Import + Gelände-Erzeugung. */}
<div className="site-actions">
<button
type="button"
className="site-btn"
onClick={() => host.onImportDxf()}
>
{t("site.import")}
</button>
</div>
{contourSets.length > 0 && (
<div className="site-terrain-row">
{contourSets.length > 1 ? (
<select
className="site-select"
value={effectiveSet}
onChange={(e) => setSelectedSet(e.target.value)}
aria-label={t("site.contourSet")}
>
{contourSets.map((cs) => (
<option key={cs.id} value={cs.id}>
{cs.name}
</option>
))}
</select>
) : (
<span className="site-terrain-src" title={t("site.contourSet")}>
{contourSets[0].name}
</span>
)}
<button type="button" className="site-btn" onClick={onGenerate}>
{t("site.generateTerrain")}
</button>
</div>
)}
<div className="site-sep" />
{/* Liste der Kontext-Objekte (Name + Typ-Badge + Entfernen). */}
{objects.length === 0 ? (
<div className="site-empty">{t("site.empty")}</div>
) : (
<ul className="site-list">
{objects.map((obj) => (
<li key={obj.id} className="site-row">
<span className="site-name" title={obj.name}>
{obj.name}
</span>
<span className={"site-badge site-badge-" + obj.type}>
{t(typeBadgeKey(obj))}
</span>
<button
type="button"
className="site-remove"
title={t("site.delete")}
aria-label={t("site.delete")}
onClick={() => host.onRemoveContextObject(obj.id)}
>
×
</button>
</li>
))}
</ul>
)}
<div className="site-hint">{t("site.dwgHint")}</div>
</div>
);
}

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