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:
+13
@@ -0,0 +1,13 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Probe / screenshot artifacts (regenerated)
|
||||
scripts/*.png
|
||||
|
||||
# Editor / OS
|
||||
.DS_Store
|
||||
*.log
|
||||
@@ -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
@@ -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>
|
||||
Generated
+2260
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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";
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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 },
|
||||
],
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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` },
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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 },
|
||||
],
|
||||
};
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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?.();
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Vendored
+19
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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 Gehrungslinien
|
||||
* (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)];
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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: [],
|
||||
};
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
@@ -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
Reference in New Issue
Block a user