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