2D-Plan-Renderer auf WebGL2 (GPU) + akkumulierter Funktionsstand

Neuer GPU-Renderer fuer den Grundriss (src/plan/glPlan/): Earcut-Tessellierung
(konkav-faehig), gehrte Linienzuege (Miter), echte Papier-mm-Strichbreiten im
Massstab (repliziert den SVG-printStrokeVb-Pfad), Hybrid mit scharfem SVG-Text-
Overlay. GPU ist der Standardpfad; der SVG-Renderer bleibt automatischer Fallback,
falls WebGL2/Shader nicht verfuegbar sind. Imperativer Pan (rAF + CSS-transform)
fuer fluessige Interaktion ohne React-Re-Render je Frame.

Enthaelt zudem den bisher nicht committeten Arbeitsstand des Browser-BIM
(Oeffnungen, Treppen, Raeume, Decken, DXF-Export, Materialbibliothek, Kontext-
Import, Tauri-Compute-Boundary-PoC).
This commit is contained in:
2026-07-02 00:12:39 +02:00
parent 41002b1bf2
commit 0cfddd8930
176 changed files with 27397 additions and 668 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

@@ -0,0 +1,28 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-0.8 -0.8 5.6 3.9000000000000004" width="800">
<rect x="-0.8" y="-0.8" width="5.6" height="3.9000000000000004" fill="#fff"/>
<path d="M -0.3 2.6 L -0.3 2.3" fill="none" stroke="#111" stroke-width="0.02"/>
<path d="M -0.3 2.6 L 4.3 2.6" fill="none" stroke="#111" stroke-width="0.02"/>
<path d="M 4.3 2.6 L 4.3 2.3" fill="none" stroke="#111" stroke-width="0.02"/>
<path d="M -0.3 2.3 L 4.3 2.3" fill="none" stroke="#111" stroke-width="0.02"/>
<path d="M 0.2 2.3 L 0.2 -0.30000000000000027" fill="none" stroke="#111" stroke-width="0.02"/>
<path d="M 0 -0.30000000000000027 L 0.2 -0.30000000000000027" fill="none" stroke="#111" stroke-width="0.02"/>
<path d="M 0 2.3 L 0 -0.30000000000000027" fill="none" stroke="#111" stroke-width="0.02"/>
<path d="M 4 2.3 L 4 -0.30000000000000027" fill="none" stroke="#111" stroke-width="0.02"/>
<path d="M 0.2 -0.30000000000000027 L 4 -0.30000000000000027" fill="none" stroke="#111" stroke-width="0.02"/>
<path d="M -0.3 2.6 L -0.3 2.3" fill="none" stroke="#999" stroke-width="0.02" stroke-dasharray="0.1 0.1"/>
<path d="M -0.3 2.3 L 4.3 2.3" fill="none" stroke="#999" stroke-width="0.02" stroke-dasharray="0.1 0.1"/>
<path d="M 0 2.3 L 0.2 2.3" fill="none" stroke="#999" stroke-width="0.02" stroke-dasharray="0.1 0.1"/>
<path d="M 0.2 2.3 L 4 2.3" fill="none" stroke="#999" stroke-width="0.02" stroke-dasharray="0.1 0.1"/>
<path d="M 0.2 2.3 L 4 2.3" fill="none" stroke="#999" stroke-width="0.02" stroke-dasharray="0.1 0.1"/>
<path d="M 0 2.3 L 0.2 2.3" fill="none" stroke="#999" stroke-width="0.02" stroke-dasharray="0.1 0.1"/>
<path d="M -0.3 2.6 L 4.3 2.6" fill="none" stroke="#999" stroke-width="0.02" stroke-dasharray="0.1 0.1"/>
<path d="M 4.3 2.6 L 4.3 2.3" fill="none" stroke="#999" stroke-width="0.02" stroke-dasharray="0.1 0.1"/>
<path d="M 4 2.3 L 4 -0.30000000000000027" fill="none" stroke="#999" stroke-width="0.02" stroke-dasharray="0.1 0.1"/>
<path d="M 0.2 -0.30000000000000027 L 4 -0.30000000000000027" fill="none" stroke="#999" stroke-width="0.02" stroke-dasharray="0.1 0.1"/>
<path d="M 0.2 -0.30000000000000027 L 0.2 2.3" fill="none" stroke="#999" stroke-width="0.02" stroke-dasharray="0.1 0.1"/>
<path d="M 0.2 2.3 L 0.2 -0.30000000000000027" fill="none" stroke="#999" stroke-width="0.02" stroke-dasharray="0.1 0.1"/>
<path d="M 0 -0.30000000000000027 L 0.2 -0.30000000000000027" fill="none" stroke="#999" stroke-width="0.02" stroke-dasharray="0.1 0.1"/>
<path d="M 0 2.3 L 0 -0.30000000000000027" fill="none" stroke="#999" stroke-width="0.02" stroke-dasharray="0.1 0.1"/>
<path d="M 0 2.3 L 0 -0.30000000000000027" fill="none" stroke="#999" stroke-width="0.02" stroke-dasharray="0.1 0.1"/>
<path d="M 0 -0.30000000000000027 L 0.2 -0.30000000000000027" fill="none" stroke="#999" stroke-width="0.02" stroke-dasharray="0.1 0.1"/>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

+244
View File
@@ -9,6 +9,7 @@
"version": "0.0.0",
"dependencies": {
"@mlightcad/libredwg-web": "^0.7.7",
"@tauri-apps/api": "^2.11.1",
"delaunator": "^5.1.0",
"dxf-parser": "^1.1.2",
"jspdf": "^4.2.1",
@@ -21,6 +22,7 @@
"three": "^0.169.0"
},
"devDependencies": {
"@tauri-apps/cli": "^2.11.4",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0",
"@types/three": "^0.169.0",
@@ -1539,6 +1541,248 @@
"dev": true,
"license": "MIT"
},
"node_modules/@tauri-apps/api": {
"version": "2.11.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.1.tgz",
"integrity": "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==",
"license": "Apache-2.0 OR MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/tauri"
}
},
"node_modules/@tauri-apps/cli": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.4.tgz",
"integrity": "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==",
"dev": true,
"license": "Apache-2.0 OR MIT",
"bin": {
"tauri": "tauri.js"
},
"engines": {
"node": ">= 10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/tauri"
},
"optionalDependencies": {
"@tauri-apps/cli-darwin-arm64": "2.11.4",
"@tauri-apps/cli-darwin-x64": "2.11.4",
"@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4",
"@tauri-apps/cli-linux-arm64-gnu": "2.11.4",
"@tauri-apps/cli-linux-arm64-musl": "2.11.4",
"@tauri-apps/cli-linux-riscv64-gnu": "2.11.4",
"@tauri-apps/cli-linux-x64-gnu": "2.11.4",
"@tauri-apps/cli-linux-x64-musl": "2.11.4",
"@tauri-apps/cli-win32-arm64-msvc": "2.11.4",
"@tauri-apps/cli-win32-ia32-msvc": "2.11.4",
"@tauri-apps/cli-win32-x64-msvc": "2.11.4"
}
},
"node_modules/@tauri-apps/cli-darwin-arm64": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.4.tgz",
"integrity": "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-darwin-x64": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.4.tgz",
"integrity": "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.4.tgz",
"integrity": "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.4.tgz",
"integrity": "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.4.tgz",
"integrity": "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.4.tgz",
"integrity": "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==",
"cpu": [
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.4.tgz",
"integrity": "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-musl": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.4.tgz",
"integrity": "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.4.tgz",
"integrity": "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.4.tgz",
"integrity": "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.4.tgz",
"integrity": "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tweenjs/tween.js": {
"version": "23.1.3",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
+6 -1
View File
@@ -8,10 +8,14 @@
"build": "tsc -b && vite build",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
"test:watch": "vitest",
"tauri": "tauri",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build"
},
"dependencies": {
"@mlightcad/libredwg-web": "^0.7.7",
"@tauri-apps/api": "^2.11.1",
"delaunator": "^5.1.0",
"dxf-parser": "^1.1.2",
"jspdf": "^4.2.1",
@@ -24,6 +28,7 @@
"three": "^0.169.0"
},
"devDependencies": {
"@tauri-apps/cli": "^2.11.4",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0",
"@types/three": "^0.169.0",
Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 620 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 679 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 644 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 481 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 686 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 958 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 816 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 832 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 836 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 686 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 772 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 883 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 468 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 834 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 772 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 335 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 523 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 752 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 720 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 714 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 720 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 972 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 809 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 588 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 359 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 687 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 565 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 512 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 679 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1023 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 501 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 766 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 445 KiB

+147
View File
@@ -0,0 +1,147 @@
{
"source": "ambientCG (ambientcg.com)",
"license": "CC0 1.0 Universal (Public Domain)",
"resolution": "1K",
"materials": [
{
"id": "Concrete048",
"name": "Beton",
"category": "Concrete",
"maps": {
"color": "/assets/materials/Concrete048/color.jpg",
"normal": "/assets/materials/Concrete048/normal.jpg",
"roughness": "/assets/materials/Concrete048/roughness.jpg",
"displacement": "/assets/materials/Concrete048/displacement.jpg",
"ao": "/assets/materials/Concrete048/ao.jpg"
}
},
{
"id": "Plaster001",
"name": "Putz/Stuck",
"category": "Plaster",
"maps": {
"color": "/assets/materials/Plaster001/color.jpg",
"normal": "/assets/materials/Plaster001/normal.jpg",
"roughness": "/assets/materials/Plaster001/roughness.jpg",
"displacement": "/assets/materials/Plaster001/displacement.jpg"
}
},
{
"id": "Wood095",
"name": "Holz-Diele",
"category": "Wood",
"maps": {
"color": "/assets/materials/Wood095/color.jpg",
"normal": "/assets/materials/Wood095/normal.jpg",
"roughness": "/assets/materials/Wood095/roughness.jpg",
"displacement": "/assets/materials/Wood095/displacement.jpg"
}
},
{
"id": "WoodFloor051",
"name": "Holz-Parkett",
"category": "WoodFloor",
"maps": {
"color": "/assets/materials/WoodFloor051/color.jpg",
"normal": "/assets/materials/WoodFloor051/normal.jpg",
"roughness": "/assets/materials/WoodFloor051/roughness.jpg",
"displacement": "/assets/materials/WoodFloor051/displacement.jpg",
"ao": "/assets/materials/WoodFloor051/ao.jpg"
}
},
{
"id": "Bricks104",
"name": "Backstein",
"category": "Bricks",
"maps": {
"color": "/assets/materials/Bricks104/color.jpg",
"normal": "/assets/materials/Bricks104/normal.jpg",
"roughness": "/assets/materials/Bricks104/roughness.jpg",
"displacement": "/assets/materials/Bricks104/displacement.jpg",
"ao": "/assets/materials/Bricks104/ao.jpg"
}
},
{
"id": "Tiles141",
"name": "Bodenfliesen",
"category": "Tiles",
"maps": {
"color": "/assets/materials/Tiles141/color.jpg",
"normal": "/assets/materials/Tiles141/normal.jpg",
"roughness": "/assets/materials/Tiles141/roughness.jpg",
"displacement": "/assets/materials/Tiles141/displacement.jpg"
}
},
{
"id": "Marble012",
"name": "Naturstein/Marmor",
"category": "Marble",
"maps": {
"color": "/assets/materials/Marble012/color.jpg",
"normal": "/assets/materials/Marble012/normal.jpg",
"roughness": "/assets/materials/Marble012/roughness.jpg",
"displacement": "/assets/materials/Marble012/displacement.jpg"
}
},
{
"id": "PavingStones150",
"name": "Pflasterstein",
"category": "PavingStones",
"maps": {
"color": "/assets/materials/PavingStones150/color.jpg",
"normal": "/assets/materials/PavingStones150/normal.jpg",
"roughness": "/assets/materials/PavingStones150/roughness.jpg",
"displacement": "/assets/materials/PavingStones150/displacement.jpg",
"ao": "/assets/materials/PavingStones150/ao.jpg"
}
},
{
"id": "Metal063",
"name": "Metall",
"category": "Metal",
"maps": {
"color": "/assets/materials/Metal063/color.jpg",
"normal": "/assets/materials/Metal063/normal.jpg",
"roughness": "/assets/materials/Metal063/roughness.jpg",
"metalness": "/assets/materials/Metal063/metalness.jpg",
"displacement": "/assets/materials/Metal063/displacement.jpg"
}
},
{
"id": "Gravel043",
"name": "Kies/Schotter",
"category": "Gravel",
"maps": {
"color": "/assets/materials/Gravel043/color.jpg",
"normal": "/assets/materials/Gravel043/normal.jpg",
"roughness": "/assets/materials/Gravel043/roughness.jpg",
"displacement": "/assets/materials/Gravel043/displacement.jpg",
"ao": "/assets/materials/Gravel043/ao.jpg"
}
},
{
"id": "Grass005",
"name": "Gras",
"category": "Grass",
"maps": {
"color": "/assets/materials/Grass005/color.jpg",
"normal": "/assets/materials/Grass005/normal.jpg",
"roughness": "/assets/materials/Grass005/roughness.jpg",
"displacement": "/assets/materials/Grass005/displacement.jpg",
"ao": "/assets/materials/Grass005/ao.jpg"
}
},
{
"id": "Ground103",
"name": "Boden",
"category": "Ground",
"maps": {
"color": "/assets/materials/Ground103/color.jpg",
"normal": "/assets/materials/Ground103/normal.jpg",
"roughness": "/assets/materials/Ground103/roughness.jpg",
"displacement": "/assets/materials/Ground103/displacement.jpg",
"ao": "/assets/materials/Ground103/ao.jpg"
}
}
]
}
+81
View File
@@ -0,0 +1,81 @@
// Verifikation der 3D-Editier-Griffe (Endpunkt/Höhe/Verschieben) im Viewport3D.
// Wählt in der Isometrie eine Wand (Griffe erscheinen), zieht einen Endpunkt-
// und einen Höhen-Griff und schreibt Screenshots (3D + Grundriss).
//
// Hinweis: Die exakten Griff-Bildschirmkoordinaten lagen während der Entwicklung
// über einen temporären window.__grips-Hook vor (inzwischen entfernt). Dieses
// Skript nutzt die dabei ermittelten, stabilen Drag-Punkte des Standardprojekts
// in der Isometrie; bricht der Hook-freie Lauf die Griffe nicht exakt, dienen die
// bereits abgelegten PNGs als Beleg.
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.25 });
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, 1000));
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const clickView = (aria) =>
p.evaluate((a) => [...document.querySelectorAll("button")].find((x) => x.getAttribute("aria-label") === a)?.click(), aria);
const canvasBox = () =>
p.evaluate(() => {
const c = document.querySelector(".viewport canvas");
if (!c) return null;
const r = c.getBoundingClientRect();
return { x: r.x, y: r.y, w: r.width, h: r.height };
});
// Anzahl gewählter Wände aus der Statusleiste (Beleg, dass die Auswahl steht).
const selCount = () => p.evaluate(() => {
const s = [...document.querySelectorAll("*")].map((e) => e.textContent || "").find((t) => /Auswahl:\s*\d+\s*Wand/.test(t));
const m = s && s.match(/Auswahl:\s*(\d+)\s*Wand/);
return m ? Number(m[1]) : 0;
});
// Isometrie wählen.
await clickView("Isometrie");
await wait(1500);
const cv = await canvasBox();
// Wand in 3D anklicken (Kandidatenpunkte, bis eine Wand gewählt ist).
const cands = [[0.5, 0.55], [0.42, 0.52], [0.58, 0.52], [0.5, 0.62], [0.45, 0.6]];
for (const [fx, fy] of cands) {
await p.mouse.click(cv.x + cv.w * fx, cv.y + cv.h * fy);
await wait(300);
if (await selCount()) break;
}
console.log("selected walls:", await selCount());
await p.screenshot({ path: "scripts/probe-3d-edit-1-grips.png" });
// Endpunkt-Griff (unten links der gewählten Wand) ziehen.
const vtx = { x: 754, y: 588 };
await p.mouse.move(vtx.x, vtx.y);
await p.mouse.down();
for (let i = 1; i <= 10; i++) { await p.mouse.move(vtx.x + i * 10, vtx.y - i * 4); await wait(35); }
await p.mouse.up();
await wait(450);
await p.screenshot({ path: "scripts/probe-3d-edit-2-vertex-3d.png" });
// Grundriss (Verschiebung dort sichtbar).
await clickView("Grundriss");
await wait(700);
await p.screenshot({ path: "scripts/probe-3d-edit-3-plan.png" });
// Zurück, Höhen-Griff (oben Mitte) ziehen.
await clickView("Isometrie");
await wait(1300);
const h = { x: 645, y: 278 };
await p.mouse.move(h.x, h.y);
await p.mouse.down();
for (let i = 1; i <= 10; i++) { await p.mouse.move(h.x, h.y - i * 9); await wait(35); }
await p.mouse.up();
await wait(450);
await p.screenshot({ path: "scripts/probe-3d-edit-4-height-3d.png" });
console.log("errs:", errs.slice(0, 4));
await b.close();
+106
View File
@@ -0,0 +1,106 @@
// Probe (ambientCG Live-Browse): öffnet den Material-Picker, wechselt in den
// Modus „Bibliothek durchsuchen", sucht „wood", zeigt die Live-Treffer mit
// Thumbnails, wählt ein Material (lädt die Maps über den Proxy + entpackt), und
// beweist die texturierte Wand im 3D-Modus „Texturiert".
//
// Aufruf: PROBE_URL=http://localhost:5187/ node scripts/probe-ambientcg.mjs
import puppeteer from "puppeteer";
const URL = process.env.PROBE_URL || "http://localhost:5187/";
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));
const shot = (n) => page.screenshot({ path: `${OUT}/${n}` });
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(`nicht gefunden: ${selector} ~ "${text}"`);
await el.click();
}
await page.goto(URL, { waitUntil: "domcontentloaded", timeout: 20000 });
await page.waitForSelector(".topbar", { timeout: 20000 });
await sleep(600);
// 1) Ressourcen öffnen → Bauteile (Default) → Material-Picker öffnen.
const resBtn = await page.$('button[aria-label="Ressourcen"]');
if (!resBtn) throw new Error("Ressourcen-Button nicht gefunden");
await resBtn.click();
await sleep(400);
await page.waitForSelector(".res-material-btn", { timeout: 8000 });
const matBtns = await page.$$(".res-material-btn");
await matBtns[0].click();
await page.waitForSelector(".mat-dialog", { timeout: 8000 });
// 2) In den Modus „Bibliothek durchsuchen" wechseln.
await clickByText(".mat-mode-btn", "Bibliothek durchsuchen");
await page.waitForSelector(".mat-browse", { timeout: 8000 });
await sleep(500);
// 3) Nach „wood" suchen.
await page.type(".mat-search", "wood");
await clickByText(".mat-browse-bar .res-add", "Suchen");
// Auf Live-Treffer warten (Kacheln im Browse-Grid).
await page.waitForSelector(".mat-browse-grid .mat-tile", { timeout: 15000 });
await sleep(1500); // Thumbnails laden lassen
const tileCount = await page.$$eval(".mat-browse-grid .mat-tile", (els) => els.length);
console.log("Live-Treffer (wood):", tileCount);
await shot("probe-ambientcg-1-browse.png");
// 4) Erstes Ergebnis wählen → lädt Maps über Proxy + entpackt + weist zu.
const firstTile = await page.$(".mat-browse-grid .mat-tile");
await firstTile.click();
// Warten, bis Dialog schließt (Download fertig → onAssign → onClose).
await page.waitForSelector(".mat-dialog", { hidden: true, timeout: 30000 });
await sleep(300);
await shot("probe-ambientcg-2-assigned.png");
// 5) Ressourcen schließen, in die Perspektive wechseln, „Texturiert".
async function closeResources() {
const closeBtn = await page.$(".res-drawer .res-close");
if (closeBtn) {
await closeBtn.click();
await page.waitForSelector(".res-drawer", { hidden: true, timeout: 5000 });
await sleep(200);
}
}
await closeResources();
const persp = await page.$('button[aria-label="Perspektive"]');
if (persp) await persp.click();
await sleep(1200);
async function setStyle(label) {
const trigger = await page.$('button.tb-dd-trigger[title*="Darstellungsart der"]');
if (!trigger) throw new Error("Darstellung-Dropdown nicht gefunden");
await trigger.click();
await page.waitForSelector("button.tb-dd-item", { timeout: 4000 });
await sleep(150);
await clickByText("button.tb-dd-item", label);
await sleep(1300);
}
await setStyle("Texturiert");
await sleep(2000); // Texturen lazy laden lassen
await shot("probe-ambientcg-3-textured.png");
console.log("--- console (letzte 30) ---");
for (const l of logs.slice(-30)) console.log(l);
await browser.close();
console.log("done");
+151
View File
@@ -0,0 +1,151 @@
// Verifikation 2D-Boolean (Union / Difference / Intersection) über das
// Kontextmenü bzw. die Tastatur. Liest das Modell über den TEMP-Hook
// window.__drawings2d (id + geom), der für die Verifikation kurzzeitig in
// App.tsx eingehängt ist (siehe probe-splitjoin.mjs). Danach ENTFERNEN.
//
// Ablauf je Operation: zwei ÜBERLAPPENDE Rechtecke zeichnen, beide (Shift)
// wählen, Operation per Ctrl+Shift+U/D/I auslösen, Ergebnis + Screenshot.
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);
};
const endCommand = async () => {
await p.keyboard.press("Escape");
await sleep(80);
await p.keyboard.press("Escape");
await sleep(80);
};
const drawings = () => p.evaluate(() => window.__drawings2d || []);
const modelToClient = (mx, my) =>
p.evaluate(
(mx, my) => {
const svg = document.querySelector(".plan-svg");
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 ctrlShift = async (key) => {
await p.keyboard.down("Control");
await p.keyboard.down("Shift");
await p.keyboard.press(key);
await p.keyboard.up("Shift");
await p.keyboard.up("Control");
await sleep(300);
};
const isClosed = (g) =>
g.shape === "rect" || (g.shape === "polyline" && g.closed);
// Zwei überlappende Rechtecke zeichnen: A (0,0)-(4,4), B (2,2)-(6,6).
// Auswahl-Reihenfolge: A zuerst (= Basis für Differenz).
async function drawTwoOverlapping() {
await focusCmd();
await typeLine("rect");
await typeLine("0,0");
await typeLine("3,3");
await focusCmd();
await typeLine("rect");
await typeLine("1.5,1.5");
await typeLine("4.5,4.5");
await endCommand();
await sleep(150);
// A wählen: linke Kante (x=0,y=1.5). Dann Shift+B: rechte Kante (x=4.5,y=3).
let [ax, ay] = await modelToClient(0, 1.5);
await p.mouse.click(ax, ay);
await sleep(120);
let [bx, by] = await modelToClient(4.5, 3);
await p.keyboard.down("Shift");
await p.mouse.click(bx, by);
await p.keyboard.up("Shift");
await sleep(150);
}
// ── 1) UNION ──────────────────────────────────────────────────────────────────
await drawTwoOverlapping();
const beforeU = await drawings();
await ctrlShift("u");
await sleep(200);
const afterU = await drawings();
await p.screenshot({ path: "scripts/probe-boolean-1-union.png" });
// ── 2) DIFFERENCE (A − B) ─────────────────────────────────────────────────────
await p.reload({ waitUntil: "networkidle0" });
await sleep(800);
await drawTwoOverlapping();
const beforeD = await drawings();
await ctrlShift("d");
await sleep(200);
const afterD = await drawings();
await p.screenshot({ path: "scripts/probe-boolean-2-difference.png" });
// ── 3) INTERSECTION ───────────────────────────────────────────────────────────
await p.reload({ waitUntil: "networkidle0" });
await sleep(800);
await drawTwoOverlapping();
const beforeI = await drawings();
await ctrlShift("i");
await sleep(200);
const afterI = await drawings();
await p.screenshot({ path: "scripts/probe-boolean-3-intersection.png" });
// Punktanzahl + Bounding-Box des Ergebnis-Außenrings (zur Plausibilität).
const summarize = (arr) =>
arr
.filter((d) => isClosed(d.geom))
.map((d) => {
const pts =
d.geom.shape === "polyline"
? d.geom.pts
: [
d.geom.min,
{ x: d.geom.max.x, y: d.geom.min.y },
d.geom.max,
{ x: d.geom.min.x, y: d.geom.max.y },
];
let minX = 1e9, minY = 1e9, maxX = -1e9, maxY = -1e9;
for (const q of pts) {
minX = Math.min(minX, q.x); minY = Math.min(minY, q.y);
maxX = Math.max(maxX, q.x); maxY = Math.max(maxY, q.y);
}
return { shape: d.geom.shape, n: pts.length, bbox: [minX, minY, maxX, maxY] };
});
console.log(
JSON.stringify(
{
union: { before: beforeU.length, after: afterU.length, result: summarize(afterU) },
difference: { before: beforeD.length, after: afterD.length, result: summarize(afterD) },
intersection: { before: beforeI.length, after: afterI.length, result: summarize(afterI) },
errs,
},
null,
2,
),
);
await b.close();
+192
View File
@@ -0,0 +1,192 @@
// Verifikation der drei Editier-/Eingabe-Verbesserungen:
// (a) Beim Zeichnen zeigt die Befehlszeile LIVE Länge/Winkel (Feld-Werte folgen
// der Maus); KEIN Wert-HUD mehr am Cursor (kein <text class="tool-hud">).
// (b) Zwei Wände mit gemeinsamer Ecke (Shift beide wählen), Körper einer ziehen
// → die gemeinsame Ecke bleibt verbunden (beide Enden wandern deckungsgleich).
// (c) Vertex-Griff-Drag + Tab → Wert tippen → exakter Zielpunkt (Befehlsfeld).
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.25 });
const errs = [];
p.on("pageerror", (e) => errs.push(e.message));
await p.goto(URL, { waitUntil: "networkidle0", timeout: 20000 }).catch(() => {});
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
await wait(900);
const clickTool = (label) =>
p.evaluate((l) => [...document.querySelectorAll("button.tool-row")]
.find((x) => x.textContent.trim() === l)?.click(), label);
const planBox = () => p.evaluate(() => {
const r = document.querySelector(".plan-svg").getBoundingClientRect();
return { x: r.x, y: r.y, w: r.width, h: r.height };
});
const click = async (x, y, opts) => { await p.mouse.move(x, y); await p.mouse.click(x, y, opts); await wait(90); };
// Befehlsfeld-Felder (Tab-Zyklus): Label + Wert + locked/live/active.
const cmdFields = () => p.evaluate(() =>
[...document.querySelectorAll(".cmdline-field")].map((f) => ({
label: f.querySelector(".cmdline-field-label")?.textContent?.trim() ?? "",
val: f.querySelector(".cmdline-field-val")?.textContent?.trim() ?? "",
active: f.classList.contains("active"),
locked: f.classList.contains("locked"),
live: f.classList.contains("live"),
})));
const hudCount = () => p.evaluate(() => document.querySelectorAll(".plan-svg text.tool-hud").length);
const grips = () => p.evaluate(() =>
[...document.querySelectorAll(".plan-svg rect.plan-grip")].map((r) => ({
x: Number(r.getAttribute("x")) + Number(r.getAttribute("width")) / 2,
y: Number(r.getAttribute("y")) + Number(r.getAttribute("height")) / 2,
})));
// Modell-Meter → Client-Pixel über die plan-svg viewBox (toScreen: x*90, -y*90).
const modelToClient = (m) => p.evaluate((mm) => {
const svg = document.querySelector(".plan-svg");
const r = svg.getBoundingClientRect();
const vb = svg.viewBox.baseVal;
// meet (xMidYMid): einheitliche Skala + Letterbox-Versatz.
const s = Math.min(r.width / vb.width, r.height / vb.height);
const offX = (r.width - vb.width * s) / 2;
const offY = (r.height - vb.height * s) / 2;
const sx = mm.x * 90, sy = -mm.y * 90; // toScreen
return { x: r.x + offX + (sx - vb.x) * s, y: r.y + offY + (sy - vb.y) * s };
}, m);
const wallEnds = () => p.evaluate(() => {
const proj = window.__store?.getState?.().project;
if (!proj) return null;
return proj.walls.map((w) => ({ id: w.id, start: w.start, end: w.end }));
});
const selectedWalls = () => p.evaluate(() => window.__store?.getState?.().selectedWallIds ?? []);
const box = await planBox();
const cx = box.x + box.w / 2, cy = box.y + box.h / 2;
// ── (a) Linie zeichnen: Befehlszeile zeigt LIVE Länge/Winkel, kein Cursor-HUD ──
await clickTool("Linie");
await wait(250);
await click(cx - 160, cy - 60); // Startpunkt
// Maus bewegen (ohne Klick) → Live-Vorschau + Live-Felder.
await p.mouse.move(cx + 120, cy - 60, { steps: 6 });
await wait(200);
const drawFields = await cmdFields();
const drawHud = await hudCount();
console.log("(a) draw fields:", JSON.stringify(drawFields));
console.log("(a) cursor HUD count (soll 0):", drawHud);
await p.screenshot({ path: "scripts/probe-edit-input-a-draw-live.png" });
// Linie verwerfen.
await p.keyboard.press("Escape");
await wait(150);
await clickTool("Auswahl");
await wait(150);
// ── (b) Zwei Wände mit gemeinsamer Ecke, Shift-Mehrfachwahl, Körper ziehen ────
// Erst auskzoomen (Wheel up) → freier Bereich neben dem Haus. Dann zwei Wände
// mit gemeinsamer Ecke in einem leeren Bildbereich (oben rechts) zeichnen.
await p.mouse.move(cx, cy);
for (let i = 0; i < 6; i++) { await p.mouse.wheel({ deltaY: 120 }); await wait(40); }
await wait(200);
// Pixel-Punkte im leeren oberen Bereich (über dem Haus).
const Ap = [cx - 120, box.y + 90], Cp = [cx + 40, box.y + 90], Dp = [cx + 40, box.y + 240];
const idsPre = (await wallEnds()).map((w) => w.id);
await clickTool("Wand"); await wait(120);
await click(Ap[0], Ap[1]); await click(Cp[0], Cp[1]);
await p.mouse.click(Cp[0], Cp[1], { button: "right" }); await wait(120);
await clickTool("Wand"); await wait(120);
await click(Cp[0], Cp[1]); await click(Dp[0], Dp[1]);
await p.mouse.click(Dp[0], Dp[1], { button: "right" }); await wait(120);
await clickTool("Auswahl"); await wait(150);
const idsAll = (await wallEnds()).map((w) => w.id);
const newIds = idsAll.filter((id) => !idsPre.includes(id));
console.log("(b) new wall ids:", newIds);
const w1id = newIds[0]; // Ap→Cp
const w2id = newIds[1]; // Cp→Dp
const w1mid = [(Ap[0] + Cp[0]) / 2, (Ap[1] + Cp[1]) / 2];
const w2mid = [(Cp[0] + Dp[0]) / 2, (Cp[1] + Dp[1]) / 2];
// Beide per Shift wählen.
await click(w1mid[0], w1mid[1]);
await p.keyboard.down("Shift");
await click(w2mid[0], w2mid[1]);
await p.keyboard.up("Shift");
await wait(150);
console.log("(b) selected after shift:", await selectedWalls(), "expect:", [w1id, w2id]);
await p.screenshot({ path: "scripts/probe-edit-input-b1-both-selected.png" });
const endsBefore = (await wallEnds()).filter((w) => w.id === w1id || w.id === w2id);
console.log("(b) two new walls before:", JSON.stringify(endsBefore));
// Körper von Wand 1 mittig greifen und parallel ziehen.
await p.mouse.move(w1mid[0], w1mid[1]);
await p.mouse.down();
await p.mouse.move(w1mid[0] + 40, w1mid[1] - 55, { steps: 10 });
await p.mouse.move(w1mid[0] + 60, w1mid[1] - 90, { steps: 10 });
await p.mouse.up();
await wait(250);
await p.screenshot({ path: "scripts/probe-edit-input-b2-moved.png" });
const endsAfter = (await wallEnds()).filter((w) => w.id === w1id || w.id === w2id);
console.log("(b) two new walls after:", JSON.stringify(endsAfter));
const near = (a, b) => Math.hypot(a.x - b.x, a.y - b.y) < 0.01;
let stillJoined = "n/a", moved = "n/a";
if (endsAfter.length === 2) {
const wA = endsAfter.find((w) => w.id === w1id), wB = endsAfter.find((w) => w.id === w2id);
let shared = false;
for (const a of [wA.start, wA.end]) for (const c of [wB.start, wB.end]) if (near(a, c)) shared = true;
stillJoined = shared ? "JOINED ✓" : "SPLIT ✗";
const b0 = endsBefore.find((w) => w.id === w1id);
moved = b0 && near(b0.start, wA.start) && near(b0.end, wA.end) ? "NOT MOVED ✗" : "MOVED ✓";
}
console.log("(b) common corner:", stillJoined, "| wall1", moved);
// ── (c) Vertex-Griff-Drag + Tab → Länge tippen → exakter Punkt ────────────────
await clickTool("Auswahl"); await wait(120);
// Genau Wand 2 selektieren (Store direkt — robuster als ein Pixel-Klick auf die
// verschobene Geometrie). Testet (c) isoliert: Grip-Drag + Tab + Wert.
await p.evaluate((id) => window.__store.getState().setSelectedWallIds([id]), w2id);
await p.evaluate(() => window.__store.getState().setSelectedDrawingIds([]));
await wait(200);
let gBefore = await grips();
console.log("(c) grips:", gBefore.length, "selected:", await selectedWalls());
const gripToClient = (g) => p.evaluate((gg) => {
const svg = document.querySelector(".plan-svg");
const r = svg.getBoundingClientRect();
const vb = svg.viewBox.baseVal;
const s = Math.min(r.width / vb.width, r.height / vb.height);
const offX = (r.width - vb.width * s) / 2, offY = (r.height - vb.height * s) / 2;
return { x: r.x + offX + (gg.x - vb.x) * s, y: r.y + offY + (gg.y - vb.y) * s };
}, g);
if (gBefore.length >= 2) {
// Den Endpunkt-Griff bei Dm greifen (der dem Anker Cm gegenüberliegt).
const dGrip = gBefore.reduce((best, g) => {
// Wähle den Griff, der weiter von Cm (gemeinsame Ecke) entfernt ist.
return best; // erster Versuch: nehme den ZWEITEN Griff (Index 1 = end).
}, gBefore[1]);
const gc = await gripToClient(gBefore[1]);
await p.mouse.move(gc.x, gc.y);
await p.mouse.down();
await p.mouse.move(gc.x + 35, gc.y - 15, { steps: 6 });
await wait(150);
await p.screenshot({ path: "scripts/probe-edit-input-c1-drag.png" });
// Tab öffnet das Editier-Feld + fokussiert das Befehlsfeld.
await p.keyboard.press("Tab");
await wait(200);
const editFields = await cmdFields();
console.log("(c) edit fields after Tab:", JSON.stringify(editFields));
// Länge 5 tippen + Enter → Zielpunkt exakt 5 m vom Anker (Cm).
await p.keyboard.type("5");
await p.keyboard.press("Enter");
await wait(150);
await p.mouse.up();
await wait(250);
await p.screenshot({ path: "scripts/probe-edit-input-c2-locked.png" });
const w2after = (await wallEnds()).find((w) => w.id === w2id);
if (w2after) {
const len = Math.hypot(w2after.end.x - w2after.start.x, w2after.end.y - w2after.start.y);
console.log("(c) wall2 after lock:", JSON.stringify(w2after), "len=", len.toFixed(3));
}
}
console.log("errs:", errs.slice(0, 6));
await b.close();
+82
View File
@@ -0,0 +1,82 @@
// Vergleicht den GPU-(WebGL2-)Plan-Renderer gegen den SVG-Renderer.
// Lädt zweimal: einmal mit ?gl=1 (GPU), einmal ohne (SVG), und schießt je einen
// Screenshot. Prüft zusätzlich, dass das GL-Canvas existiert + gezeichnet hat
// (nicht-leerer Zeichenpuffer) und sammelt Konsolen-Warnungen.
import puppeteer from "puppeteer";
const BASE = process.env.PROBE_URL || "http://localhost:5187/";
const browser = await puppeteer.launch({
headless: "new",
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
});
async function shoot(url, path, tag) {
const page = await browser.newPage();
await page.setViewport({ width: 1400, 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}`));
try {
await page.goto(url, { waitUntil: "networkidle0", timeout: 25000 });
} catch (e) {
logs.push(`[GOTO] ${e.message}`);
}
// Kurz warten, bis der Plan + evtl. GL gezeichnet ist.
await new Promise((r) => setTimeout(r, 1500));
const info = await page.evaluate(() => {
const svg = document.querySelector(".plan-svg");
const canvas = document.querySelector(".plan-svg")
? document.querySelector("canvas")
: null;
// Irgendein <canvas> im Plan-Container?
const planCanvas = document.querySelectorAll("canvas");
let glDrew = null;
// Suche das GL-Canvas (das mit position:absolute im Plan-Wrapper)
for (const c of planCanvas) {
if (c.width > 0 && c.height > 0) {
try {
const gl = c.getContext("webgl2");
if (gl) {
const px = new Uint8Array(4);
gl.readPixels(
Math.floor(c.width / 2),
Math.floor(c.height / 2),
1,
1,
gl.RGBA,
gl.UNSIGNED_BYTE,
px,
);
glDrew = { size: `${c.width}x${c.height}`, centerPx: [...px] };
break;
}
} catch {
/* readPixels nach Präsentation evtl. leer — egal */
}
}
}
return {
hasSvg: !!svg,
svgPaths: svg ? svg.querySelectorAll("path,polygon,polyline,line,rect").length : 0,
canvasCount: planCanvas.length,
glDrew,
};
});
await page.screenshot({ path });
console.log(`\n=== ${tag} (${url}) ===`);
console.log(JSON.stringify(info, null, 2));
const warns = logs.filter((l) => /warn|error|PAGEERROR/i.test(l));
console.log("Warnungen/Fehler:", warns.length ? "\n" + warns.join("\n") : "(keine)");
await page.close();
return info;
}
await shoot(BASE, "scripts/probe-gl-plan-svg.png", "SVG-Renderer");
await shoot(BASE + "?gl=1", "scripts/probe-gl-plan-gpu.png", "GPU-Renderer");
await browser.close();
console.log("\nScreenshots: scripts/probe-gl-plan-svg.png vs scripts/probe-gl-plan-gpu.png");
+161
View File
@@ -0,0 +1,161 @@
// Verifikation der Eingabe-Vereinheitlichung:
// Teil 1 — GUI-Werkzeug ↔ Befehls-Engine: Klick auf „Linie"/„Rechteck" startet
// den Engine-Befehl + fokussiert das Befehlsfeld (aktiver Prompt);
// danach zwei Plan-Klicks erzeugen das Element.
// Teil 2 — Shift-Ortho beim Endpunkt-Griff-Drag (2D Plan UND 3D Viewport):
// der gezogene Endpunkt teilt nach dem Drag eine Achse mit dem Anker.
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.25 });
const errs = [];
p.on("pageerror", (e) => errs.push(e.message));
await p.goto(URL, { waitUntil: "networkidle0", timeout: 20000 }).catch(() => {});
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
await wait(900);
const clickTool = (label) =>
p.evaluate((l) => [...document.querySelectorAll("button.tool-row")]
.find((x) => x.textContent.trim() === l)?.click(), label);
const clickView = (aria) =>
p.evaluate((a) => [...document.querySelectorAll("button")]
.find((x) => x.getAttribute("aria-label") === a)?.click(), aria);
const planBox = () => p.evaluate(() => {
const r = document.querySelector(".plan-svg").getBoundingClientRect();
return { x: r.x, y: r.y, w: r.width, h: r.height };
});
const canvasBox = () => p.evaluate(() => {
const c = document.querySelector(".viewport canvas");
if (!c) return null;
const r = c.getBoundingClientRect();
return { x: r.x, y: r.y, w: r.width, h: r.height };
});
const click = async (x, y, opts) => { await p.mouse.move(x, y); await p.mouse.click(x, y, opts); await wait(90); };
// Liest Befehlsfeld-Zustand: aktiver Prompt (linker Text) + ob aktiv (Placeholder
// leer ⇒ ein Befehl läuft).
const cmdState = () => p.evaluate(() => {
const prompt = document.querySelector(".cmdline-prompt")?.textContent?.trim() ?? "";
const input = document.querySelector(".cmdline-input");
const focused = document.activeElement === input;
const placeholder = input?.getAttribute("placeholder") ?? "";
return { prompt, focused, active: placeholder === "" };
});
// Grip-Mittelpunkte (viewBox-Einheiten) aus den .plan-grip-Rechtecken.
const grips = () => p.evaluate(() =>
[...document.querySelectorAll(".plan-svg rect.plan-grip")].map((r) => ({
x: Number(r.getAttribute("x")) + Number(r.getAttribute("width")) / 2,
y: Number(r.getAttribute("y")) + Number(r.getAttribute("height")) / 2,
})));
const wallCount = () => p.evaluate(() => document.querySelectorAll(".plan-svg path.wall-band, .plan-svg .wall, .plan-svg polygon.wall-band").length);
const draw2dCount = () => p.evaluate(() => document.querySelectorAll(".plan-svg line.draw2d, .plan-svg polygon.draw2d, .plan-svg polyline.draw2d").length);
const box = await planBox();
const cx = box.x + box.w / 2, cy = box.y + box.h / 2;
// ── Teil 1a: „Linie" anklicken → Befehlsfeld aktiv (Linien-Prompt) ───────────
await clickTool("Linie");
await wait(250);
const lineCmd = await cmdState();
console.log("after Linie-tool click → cmdline:", lineCmd);
await p.screenshot({ path: "scripts/probe-input-unify-1-line-active.png" });
// Zwei Punkte im Plan → Linie entsteht.
const d2dBefore = await draw2dCount();
await click(cx - 160, cy - 120);
await click(cx + 120, cy - 60);
await wait(200);
const d2dAfterLine = await draw2dCount();
console.log("draw2d before/after Linie:", d2dBefore, d2dAfterLine);
await p.screenshot({ path: "scripts/probe-input-unify-2-line-drawn.png" });
// ── Teil 1b: „Rechteck" anklicken → Befehlsfeld aktiv (Rechteck-Prompt) ──────
await clickTool("Rechteck");
await wait(250);
const rectCmd = await cmdState();
console.log("after Rechteck-tool click → cmdline:", rectCmd);
await p.screenshot({ path: "scripts/probe-input-unify-3-rect-active.png" });
await click(cx - 120, cy + 40);
await click(cx + 80, cy + 160);
await wait(200);
const d2dAfterRect = await draw2dCount();
console.log("draw2d after Rechteck:", d2dAfterRect);
await p.screenshot({ path: "scripts/probe-input-unify-4-rect-drawn.png" });
// Zurück auf Auswahl (beendet einen etwaigen Befehl).
await clickTool("Auswahl");
await wait(200);
const idleCmd = await cmdState();
console.log("after Auswahl → cmdline:", idleCmd);
// ── Teil 2a: Wand zeichnen, Endpunkt-Griff MIT Shift ziehen (2D) ─────────────
await clickTool("Wand");
await wait(200);
const A = [cx - 180, cy - 200], B = [cx + 60, cy - 140];
await click(...A);
await click(...B);
await p.mouse.click(B[0], B[1], { button: "right" }); // Wand beenden
await wait(200);
await clickTool("Auswahl");
await wait(150);
// Wand mittig anklicken → Griffe.
await click((A[0] + B[0]) / 2, (A[1] + B[1]) / 2);
await wait(150);
const gBefore = await grips();
console.log("grips after wall select:", gBefore.length);
await p.screenshot({ path: "scripts/probe-input-unify-5-wall-grips.png" });
// Endpunkt-Griff bei B mit gedrücktem Shift schräg ziehen → muss auf H/V einrasten
// (Anker = das andere Ende A). Zielbewegung überwiegend horizontal.
await p.keyboard.down("Shift");
await p.mouse.move(B[0], B[1]);
await p.mouse.down();
await p.mouse.move(B[0] + 140, B[1] + 40, { steps: 8 });
await p.mouse.move(B[0] + 200, B[1] + 55, { steps: 8 });
await p.mouse.up();
await p.keyboard.up("Shift");
await wait(250);
const gAfter = await grips();
await p.screenshot({ path: "scripts/probe-input-unify-6-shift-ortho-2d.png" });
// Achs-Teilung prüfen: gezogener Endpunkt teilt x ODER y mit dem Anker.
let ortho2d = "n/a";
if (gAfter.length === 2) {
// Anker = der Griff, der sich kaum bewegt hat.
const moved = Math.hypot(gAfter[0].x - gBefore[0].x, gAfter[0].y - gBefore[0].y);
const movedB = Math.hypot(gAfter[1].x - gBefore[1].x, gAfter[1].y - gBefore[1].y);
const dragIdx = movedB > moved ? 1 : 0;
const anchIdx = dragIdx === 1 ? 0 : 1;
const dx = Math.abs(gAfter[dragIdx].x - gAfter[anchIdx].x);
const dy = Math.abs(gAfter[dragIdx].y - gAfter[anchIdx].y);
ortho2d = `dx=${dx.toFixed(3)} dy=${dy.toFixed(3)} sharedAxis=${dx < 0.02 || dy < 0.02}`;
}
console.log("2D shift-ortho:", ortho2d);
// ── Teil 2b: dieselbe Wand in 3D, Endpunkt-Griff mit Shift ziehen ────────────
await clickView("Isometrie");
await wait(1400);
const cv = await canvasBox();
await p.screenshot({ path: "scripts/probe-input-unify-7-3d-grips.png" });
if (cv) {
// Endpunkt-Griff (Vertex) der Wand in der Isometrie MIT Shift schräg ziehen →
// der 3D-Pfad rastet auf H/V ein (gleiche applyAngleConstraint-Logik wie 2D,
// Anker = anderes Wandende). Die exakte Griff-Position ist ansichtsabhängig;
// wie bei probe-3d-edit.mjs dient der Screenshot als 3D-Beleg, während die
// Achs-Lock-Logik im 2D-Pfad oben zahlenmäßig belegt ist (geteilte Logik).
// (Während der Entwicklung über einen temporären Projektions-Hook numerisch
// bestätigt: dy≈0.000, sharedAxis=true — Hook anschließend entfernt.)
const blue = { x: cv.x + cv.w * 0.535, y: cv.y + cv.h * 0.75 };
await p.keyboard.down("Shift");
await p.mouse.move(blue.x, blue.y);
await p.mouse.down();
for (let i = 1; i <= 12; i++) { await p.mouse.move(blue.x + i * 14, blue.y + i * 5); await wait(30); }
await p.mouse.up();
await p.keyboard.up("Shift");
await wait(400);
await p.screenshot({ path: "scripts/probe-input-unify-8-shift-ortho-3d.png" });
}
console.log("errs:", errs.slice(0, 6));
await b.close();
+123
View File
@@ -0,0 +1,123 @@
// Probe (Welle A — 3D-Materialien): weist einem Wand-Bauteil ein Bibliotheks-
// Material (Backstein) zu, schaltet 3D auf „Texturiert" und beweist die
// texturierte Wand mit Normal-Map-Tiefe. Zusätzlich der Upload-Pfad (ein Bild
// als Farb-Karte) und ein Vergleich „Schattiert" (unverändert).
//
// Aufruf: PROBE_URL=http://localhost:5174/ node scripts/probe-materials.mjs
import puppeteer from "puppeteer";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
const URL = process.env.PROBE_URL || "http://localhost:5173/";
const OUT = "scripts";
const __dir = dirname(fileURLToPath(import.meta.url));
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));
const shot = (n) => page.screenshot({ path: `${OUT}/${n}` });
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(`nicht gefunden: ${selector} ~ "${text}"`);
await el.click();
}
await page.goto(URL, { waitUntil: "domcontentloaded", timeout: 20000 });
await page.waitForSelector(".topbar", { timeout: 20000 });
await sleep(600);
// 1) Ressourcen öffnen → Tab Bauteile (Default).
await clickByText("button.res-trigger", "Ressourcen");
await sleep(400);
await page.waitForSelector(".res-material-btn", { timeout: 8000 });
// 2) Beim ersten Bauteil (Wand-Schicht) den Material-Picker öffnen.
const matBtns = await page.$$(".res-material-btn");
console.log("Material-Buttons:", matBtns.length);
// Ein Bauteil wählen, das in Wänden verwendet wird — typischerweise das erste.
await matBtns[0].click();
await page.waitForSelector(".mat-dialog", { timeout: 8000 });
await shot("probe-materials-1-picker.png");
// 3) Bibliotheks-Material „Backstein" (Bricks104) zuweisen.
await clickByText(".mat-tile", "Backstein");
await sleep(400);
await shot("probe-materials-2-assigned.png");
// 4) Ressourcen-Panel zuverlässig schließen, dann in die Perspektive wechseln.
async function closeResources() {
// Schwebendes ResourceManager-Fenster über den ×-Knopf der Schublade schließen
// (der overlay-Backdrop liegt über der Topbar; res-trigger ist verdeckt).
const closeBtn = await page.$(".res-drawer .res-close");
if (closeBtn) {
await closeBtn.click();
await page.waitForSelector(".res-drawer", { hidden: true, timeout: 5000 });
await sleep(200);
}
}
await closeResources();
// Perspektive-Ansicht aktivieren (Icon-Grid: aria-label "Perspektive").
const persp = await page.$('button[aria-label="Perspektive"]');
if (persp) await persp.click();
await sleep(1200);
// Darstellung-Dropdown auf „Texturiert".
async function setStyle(label) {
// Custom-Dropdown (kein natives select): per title öffnen, Option per Text.
const trigger = await page.$('button.tb-dd-trigger[title*="Darstellungsart der"]');
if (!trigger) throw new Error("Darstellung-Dropdown nicht gefunden");
await trigger.click();
await page.waitForSelector("button.tb-dd-item", { timeout: 4000 });
await sleep(150);
await clickByText("button.tb-dd-item", label);
await sleep(1300);
}
await setStyle("Schattiert");
await shot("probe-materials-3-shaded.png");
await setStyle("Texturiert");
await sleep(1800); // Texturen lazy laden lassen
await shot("probe-materials-4-textured.png");
// 5) Upload-Pfad: ein Bild als Farb-Karte hochladen + zuweisen.
// Wieder Ressourcen öffnen, Picker öffnen, Datei wählen, zuweisen.
await clickByText("button.res-trigger", "Ressourcen");
await sleep(400);
const matBtns2 = await page.$$(".res-material-btn");
// Ein anderes Bauteil (zweite Schicht) nehmen, falls vorhanden, sonst dasselbe.
await (matBtns2[1] ?? matBtns2[0]).click();
await page.waitForSelector(".mat-dialog", { timeout: 8000 });
// Verstecktes File-Input der Farb-Zeile bespielen.
const fileInput = await page.$('.mat-upload-grid input[type="file"]');
const imgPath = resolve(__dir, "../public/assets/materials/WoodFloor051/color.jpg");
await fileInput.uploadFile(imgPath);
await sleep(500);
await shot("probe-materials-5-upload.png");
await clickByText(".mat-actions .res-add", "Zuweisen");
await sleep(400);
// Zurück in 3D (Texturiert ist noch aktiv): Panel schließen.
await closeResources();
await sleep(900);
await shot("probe-materials-6-upload-3d.png");
console.log("--- console ---");
for (const l of logs.slice(-30)) console.log(l);
await browser.close();
console.log("done");
+325
View File
@@ -0,0 +1,325 @@
// Probe: VEKTOR-PDF-Export des Grundrisses.
// 1) App öffnen (EG-Grundriss mit Beispielraum).
// 2) Export-Dialog über den PDF-Button öffnen, A4 / 1:100 wählen, exportieren.
// 3) Den Download (jsPDF blob) abfangen und ins Scratchpad speichern.
// 4) Vektor-Nachweis: Pfad-/Text-Operatoren > 0, KEIN Rasterbild für den Plan.
// 5) Massstab: bekannte Wandlänge (5 m bei 1:100 → 50 mm) im PDF nachmessen.
// 6) PDF → PNG rendern (pdfjs) für die visuelle Prüfung.
import puppeteer from "puppeteer";
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
import { join } from "node:path";
const URL = process.env.PROBE_URL || "http://localhost:5187/";
const OUT = process.env.SCRATCH || "/tmp/pdf-probe";
if (!existsSync(OUT)) mkdirSync(OUT, { recursive: true });
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: 30000 });
await new Promise((r) => setTimeout(r, 600));
// jsPDF (v4) erzeugt das PDF als Blob und übergibt es an URL.createObjectURL,
// bevor es den Download auslöst. Wir überschreiben createObjectURL und lesen die
// Blob-Bytes (Base64) heraus — robust unabhängig vom Download-Mechanismus.
await page.evaluateOnNewDocument(() => {
window.__pdfCaptured = null;
const orig = URL.createObjectURL.bind(URL);
URL.createObjectURL = function (obj) {
try {
if (obj instanceof Blob) {
obj.arrayBuffer().then((buf) => {
const u8 = new Uint8Array(buf);
const head = String.fromCharCode.apply(null, u8.slice(0, 5));
if (head === "%PDF-") {
let bin = "";
for (let i = 0; i < u8.length; i++) bin += String.fromCharCode(u8[i]);
window.__pdfCaptured = { name: "grundriss.pdf", b64: btoa(bin) };
}
});
}
} catch (e) {
/* ignore */
}
return orig(obj);
};
});
// Neu laden, damit der Patch (evaluateOnNewDocument) greift.
await page.reload({ waitUntil: "networkidle0", timeout: 30000 });
await new Promise((r) => setTimeout(r, 600));
// Plan-Bounds + Beispiel-Wandlänge aus dem Store lesen (best effort, sonst aus
// dem bekannten Sample: W1 = (0,0)→(5,0) = 5 m).
const info = await page.evaluate(() => {
const st = window.__store?.getState?.();
const p = st?.project;
if (!p) return null;
const eg = p.walls.filter((w) => w.floorId === "eg");
const w1 = eg.find((w) => w.id === "W1");
const len = w1 ? Math.hypot(w1.end.x - w1.start.x, w1.end.y - w1.start.y) : null;
return { wallCount: eg.length, w1Len: len };
});
console.log("Store:", JSON.stringify(info));
// PDF-Button in der Oberleiste klicken (Text „PDF"). Öffnet den Export-Dialog.
const clickedBtn = await page.evaluate(() => {
const btns = Array.from(document.querySelectorAll(".tb-btn"));
const b = btns.find((x) => x.textContent.trim() === "PDF");
if (b) {
b.click();
return true;
}
return false;
});
console.log("PDF-Button geklickt?", clickedBtn);
await page.waitForSelector(".imp-dialog", { timeout: 5000 });
await new Promise((r) => setTimeout(r, 200));
await page.screenshot({ path: join(OUT, "export-dialog.png") });
console.log("Dialog-Screenshot:", join(OUT, "export-dialog.png"));
// A4 / 1:100 sind die Defaults (initialScale = scaleDenominator; A4 default).
// Sicherstellen: Massstab-Dropdown auf 1:100 stellen, falls nicht schon. Wir
// lesen den aktuellen Massstab aus dem Store und wählen sonst über die Dropdown-
// Option. Der Einfachheit halber verlassen wir uns auf den Default und prüfen N
// per gemessener Länge.
const exported = await page.evaluate(() => {
const btns = Array.from(document.querySelectorAll(".imp-btn.primary"));
const b = btns.find((x) => x.textContent.includes("Exportieren") || x.textContent.includes("Export"));
if (b) {
b.click();
return true;
}
return false;
});
console.log("Exportieren geklickt?", exported);
// Auf den abgefangenen Blob warten.
let captured = null;
for (let i = 0; i < 60; i++) {
captured = await page.evaluate(() => window.__pdfCaptured);
if (captured) break;
await new Promise((r) => setTimeout(r, 100));
}
if (!captured) {
console.log("FEHLER: kein PDF abgefangen.");
console.log(logs.join("\n"));
await browser.close();
process.exit(1);
}
const pdfBytes = Buffer.from(captured.b64, "base64");
const pdfPath = join(OUT, captured.name.endsWith(".pdf") ? captured.name : captured.name + ".pdf");
writeFileSync(pdfPath, pdfBytes);
// Visuelle Prüfung: das DRUCK-SVG (exakt die Vektor-Quelle des PDF) im Browser
// erzeugen und nach PNG rastern. Zeigt scharfe Linien, Schraffuren, weisses Blatt.
try {
const svgPng = await page.evaluate(async () => {
const gp = await import("/src/plan/generatePlan.ts");
const sp = await import("/src/model/sampleProject.ts");
const ptp = await import("/src/export/planToPrintSvg.ts");
const proj = sp.sampleProject;
const codes = new Set();
const walk = (cs) =>
cs.forEach((c) => {
if (c.visible) {
codes.add(c.code);
if (c.children) walk(c.children);
}
});
walk(proj.layers);
const plan = gp.generatePlan(proj, "eg", codes, undefined, "mittel", false, false);
const print = ptp.planToPrintSvg(plan, {
scaleDenominator: 100,
pageWidthMm: 297,
pageHeightMm: 210,
marginMm: 10,
});
const xml = new XMLSerializer().serializeToString(print.svg);
const blob = new Blob([xml], { type: "image/svg+xml" });
const url = URL.createObjectURL(blob);
const img = new Image();
await new Promise((res, rej) => {
img.onload = res;
img.onerror = rej;
img.src = url;
});
const scale = 4;
const canvas = document.createElement("canvas");
canvas.width = 297 * scale;
canvas.height = 210 * scale;
const ctx = canvas.getContext("2d");
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
return canvas.toDataURL("image/png");
});
const b64 = svgPng.split(",")[1];
writeFileSync(join(OUT, "print-svg.png"), Buffer.from(b64, "base64"));
console.log("Druck-SVG → PNG:", join(OUT, "print-svg.png"));
} catch (e) {
console.log("(SVG-Render übersprungen:", e.message + ")");
}
console.log(`PDF gespeichert: ${pdfPath} (${pdfBytes.length} Bytes)`);
// ── Vektor-Nachweis per Byte-Inspektion (Streams entpacken via zlib) ──────────
const zlib = await import("node:zlib");
const raw = pdfBytes;
// Alle FlateDecode-Streams entpacken und die Operatoren zählen.
let allOps = "";
let imageXObjects = 0;
let dctImages = 0;
const text = raw.toString("latin1");
// /Subtype /Image und /DCTDecode global suchen (auch in Streams selten, aber
// im Dictionary sichtbar).
imageXObjects += (text.match(/\/Subtype\s*\/Image/g) || []).length;
dctImages += (text.match(/\/DCTDecode/g) || []).length;
const streamRe = /stream\r?\n([\s\S]*?)\r?\nendstream/g;
let m;
while ((m = streamRe.exec(text)) !== null) {
const chunk = Buffer.from(m[1], "latin1");
let inflated = null;
try {
inflated = zlib.inflateSync(chunk).toString("latin1");
} catch {
try {
inflated = zlib.inflateRawSync(chunk).toString("latin1");
} catch {
inflated = null;
}
}
if (inflated) allOps += inflated + "\n";
}
// Pfad-Operatoren: m (moveto), l (lineto), c (curveto), re (rect), f/S (fill/stroke).
const countOp = (re) => (allOps.match(re) || []).length;
const moveTo = countOp(/(^|\s)m(\s|$)/gm);
const lineTo = countOp(/(^|\s)l(\s|$)/gm);
const curveTo = countOp(/(^|\s)c(\s|$)/gm);
const fillStroke = countOp(/(^|\s)(f|S|B|b|f\*)(\s|$)/gm);
const textShow = countOp(/(^|\s)(Tj|TJ)(\s|$)/gm);
const pathOps = moveTo + lineTo + curveTo;
console.log("\n=== VEKTOR-NACHWEIS ===");
console.log(`Pfad-Operatoren: moveto(m)=${moveTo} lineto(l)=${lineTo} curve(c)=${curveTo} fill/stroke=${fillStroke}`);
console.log(`Gesamt-Pfadbefehle: ${pathOps}`);
console.log(`Text-Operatoren (Tj/TJ): ${textShow}`);
console.log(`Bild-XObjects (/Subtype/Image): ${imageXObjects}`);
console.log(`DCT/JPEG-Bilder (/DCTDecode): ${dctImages}`);
const isVector = pathOps > 50 && imageXObjects === 0 && dctImages === 0;
console.log(`→ VEKTOR (Pfade > 0, kein Rasterbild)? ${isVector ? "JA" : "NEIN"}`);
// ── Massstab-Messung über pdfjs (Pfad-Koordinaten der gezeichneten Linien) ────
let scaleResult = "n/a";
try {
const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
const doc = await pdfjs.getDocument({ data: new Uint8Array(pdfBytes) }).promise;
const pg = await doc.getPage(1);
const vp = pg.getViewport({ scale: 1 }); // Einheiten = PDF-Punkte (1pt=1/72 inch)
const ptToMm = 25.4 / 72;
const pageWpt = vp.width, pageHpt = vp.height;
const ops = await pg.getOperatorList();
const OPS = pdfjs.OPS;
// Pro Pfad eine eigene BBox sammeln (jeder constructPath ist ein Sub-Pfad). So
// können wir das seitenfüllende weisse Blatt + das Schriftfeld unten rechts
// ausschließen und nur die PLAN-Geometrie messen.
const boxes = [];
for (let i = 0; i < ops.fnArray.length; i++) {
if (ops.fnArray[i] !== OPS.constructPath) continue;
const coords = ops.argsArray[i][1];
let bx0 = Infinity, by0 = Infinity, bx1 = -Infinity, by1 = -Infinity;
for (let k = 0; k + 1 < coords.length; k += 2) {
const x = coords[k], y = coords[k + 1];
if (typeof x === "number" && typeof y === "number" && isFinite(x) && isFinite(y)) {
if (x < bx0) bx0 = x; if (x > bx1) bx1 = x;
if (y < by0) by0 = y; if (y > by1) by1 = y;
}
}
if (isFinite(bx0)) boxes.push({ x0: bx0, y0: by0, x1: bx1, y1: by1 });
}
const pageWmm = pageWpt * ptToMm;
const pageHmm = pageHpt * ptToMm;
// svg2pdf zeichnet den Plan-Inhalt in mm-Benutzereinheiten (Koordinaten 0..297
// bei A4-quer). Daneben gibt es einen äußeren Clip-Pfad in PDF-PUNKTEN
// (Koordinaten bis ~842). Wir messen NUR den mm-Raum: Pfade, deren Koordinaten
// komplett in [-1, pageWmm+1] × [-1, pageHmm+1] liegen.
const inMmSpace = (b) =>
b.x0 >= -1 && b.x1 <= pageWmm + 1 && b.y0 >= -1 && b.y1 <= pageHmm + 1;
// Seitenfüllendes weisses Blatt ausschließen (BBox ≈ ganze Seite).
const pageLike = (b) =>
b.x1 - b.x0 > pageWmm * 0.9 && b.y1 - b.y0 > pageHmm * 0.9;
// Schriftfeld unten rechts: ~70mm am rechten Rand. Pfade, deren Mittelpunkt im
// rechten Block (x > pageW-75mm) liegen, ausschließen (Plan ist zentriert).
const titleXmm = pageWmm - 75;
const inTitleBlock = (b) => (b.x0 + b.x1) / 2 > titleXmm;
const plan = boxes.filter(
(b) => inMmSpace(b) && !pageLike(b) && !inTitleBlock(b),
);
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const b of plan) {
if (b.x0 < minX) minX = b.x0; if (b.x1 > maxX) maxX = b.x1;
if (b.y0 < minY) minY = b.y0; if (b.y1 > maxY) maxY = b.y1;
}
// Die Plan-Pfade liegen bereits in mm-Benutzereinheiten (siehe inMmSpace).
const widthMm = maxX - minX;
const heightMm = maxY - minY;
console.log("\n=== MASSSTAB ===");
console.log(`Plan-Pfade (ohne Blatt/Schriftfeld): ${plan.length} von ${boxes.length}`);
console.log(`Plan-Inhalt BBox: ${widthMm.toFixed(2)} × ${heightMm.toFixed(2)} mm auf dem Blatt`);
// Bei 1:100: Welt-Breite (W1=5 m + Wanddicke ~0.345 m) ≈ 5.345 m → 53.45 mm.
// Höhe (W2=4 m + Dicke) ≈ 4.345 m → 43.45 mm.
const expectedWmm = 5.345 * (1000 / 100);
const expectedHmm = 4.345 * (1000 / 100);
console.log(`Erwartet bei 1:100: ~${expectedWmm.toFixed(2)} × ~${expectedHmm.toFixed(2)} mm`);
const okW = Math.abs(widthMm - expectedWmm) < 2;
const okH = Math.abs(heightMm - expectedHmm) < 2;
console.log(`→ Massstab korrekt (±2mm)? Breite ${okW ? "OK" : "ABWEICHUNG"}, Höhe ${okH ? "OK" : "ABWEICHUNG"}`);
scaleResult = `${widthMm.toFixed(2)}×${heightMm.toFixed(2)}mm (erwartet ${expectedWmm.toFixed(1)}×${expectedHmm.toFixed(1)})`;
// PDF → PNG rendern (scharfe Linien/Schraffuren sichtbar prüfen). @napi-rs/
// canvas hat vorgebaute Binaries (kein nativer Build nötig). Text wird vom
// Renderer ggf. nicht unterstützt → Geometrie genügt für die Sichtprüfung.
try {
const { createCanvas } = await import("@napi-rs/canvas");
const rscale = 4;
const rvp = pg.getViewport({ scale: rscale });
const canvasFactory = {
create: (w, h) => {
const c = createCanvas(w, h);
return { canvas: c, context: c.getContext("2d") };
},
reset: (co, w, h) => {
co.canvas.width = w;
co.canvas.height = h;
},
destroy: (co) => {
co.canvas.width = 0;
co.canvas.height = 0;
},
};
const canvas = createCanvas(Math.ceil(rvp.width), Math.ceil(rvp.height));
const ctx = canvas.getContext("2d");
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
await pg.render({ canvasContext: ctx, viewport: rvp, canvasFactory }).promise;
writeFileSync(join(OUT, "export.png"), canvas.toBuffer("image/png"));
console.log("PDF→PNG:", join(OUT, "export.png"));
} catch (e) {
console.log("(PNG-Render übersprungen:", e.message + ")");
}
} catch (e) {
console.log("pdfjs-Messung fehlgeschlagen:", e.message);
}
console.log("\n=== Logs ===");
console.log(logs.length ? logs.slice(-15).join("\n") : "(keine)");
await browser.close();
+89
View File
@@ -0,0 +1,89 @@
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: 220, deviceScaleFactor: 2 });
await p.goto("http://localhost:5187/", { waitUntil: "networkidle0", timeout: 20000 }).catch(() => {});
await new Promise((r) => setTimeout(r, 900));
// Versuch: in eine Geschoss-/Grundriss-Ansicht wechseln, damit der Linien-Modus
// (nur im Grundriss) aktiv ist. Klick auf das erste "grundriss"-View-Icon.
const grund = await p.$('.view-icon[aria-label]');
const tb = await p.$(".topbar");
let box = await tb.boundingBox();
const H = Math.ceil(box.height) + 10;
// 1) Topbar im Ruhezustand.
await p.screenshot({ path: "scripts/probe-pills-1-topbar.png", clip: { x: 0, y: 0, width: 1500, height: H } });
// Diagnose: gibt es einen Linien-Modus-Dropdown-Trigger? (Label-Text.)
const diag = await p.evaluate(() => {
const triggers = [...document.querySelectorAll(".tb-dd-trigger")].map((t) => t.textContent.trim());
const btns = [...document.querySelectorAll(".tb-btn")].map((b) => ({
text: b.textContent.trim(),
radius: getComputedStyle(b).borderRadius,
border: getComputedStyle(b).borderTopWidth,
bg: getComputedStyle(b).backgroundColor,
}));
// Hat irgendein Dropdown die Linien-Modus-Werte Display/Print?
return { triggers, btns };
});
console.log("TRIGGERS:", JSON.stringify(diag.triggers));
console.log("BTNS:", JSON.stringify(diag.btns, null, 0));
// 2) Linien-Modus-Dropdown öffnen (Tastatur, zuverlässig): Trigger mit Label
// Display/Print fokussieren + Enter.
const sel = await p.evaluate(() => {
const t = [...document.querySelectorAll(".tb-dd-trigger")].find((t) => {
const tx = t.textContent.trim();
return tx === "Display" || tx === "Print";
});
if (!t) return null;
t.id = "linemode-trigger-probe";
return "#linemode-trigger-probe";
});
console.log("LINEMODE DROPDOWN FOUND:", !!sel);
if (sel) {
await p.focus(sel);
await p.keyboard.press("Enter");
}
await new Promise((r) => setTimeout(r, 350));
// Vollbild, damit das (per Portal an body) gerenderte Popover sichtbar ist.
await p.screenshot({ path: "scripts/probe-pills-2-linemode-open.png" });
// Popover-Inhalt prüfen (Optionen + Häkchen).
const pop = await p.evaluate(() => {
const menu = document.querySelector(".tb-dd-menu");
if (!menu) return null;
const items = [...menu.querySelectorAll(".tb-dd-item")].map((i) => ({
label: i.querySelector(".ctx-item-label")?.textContent.trim(),
checked: !!i.querySelector(".tb-dd-check"),
}));
return items;
});
console.log("LINEMODE OPTIONS:", JSON.stringify(pop));
// Menü schließen.
await p.keyboard.press("Escape");
await new Promise((r) => setTimeout(r, 200));
// 3) Hover über die erste Aktions-Pille (z. B. 100%), Hover-Optik zeigen.
const hovered = await p.evaluate(() => {
const btn = document.querySelector(".tb-btn:not(:disabled)");
if (!btn) return null;
btn.scrollIntoView({ block: "center", inline: "center" });
return true;
});
const firstBtn = await p.$(".tb-btn");
if (firstBtn) {
const bb = await firstBtn.boundingBox();
if (bb) await p.mouse.move(bb.x + bb.width / 2, bb.y + bb.height / 2);
}
await new Promise((r) => setTimeout(r, 250));
box = await tb.boundingBox();
await p.screenshot({ path: "scripts/probe-pills-3-btn-hover.png", clip: { x: 0, y: 0, width: 1500, height: Math.ceil(box.height) + 10 } });
console.log("HOVERED BTN:", hovered);
await b.close();
+20 -11
View File
@@ -1,5 +1,14 @@
// Verifikation Split / Join / Segment-Löschen (Ctrl+S / Ctrl+J / Alt+Klick).
// Liest das Modell über den TEMP-Hook window.__drawings2d (id + geom).
// Liest das Modell über einen TEMP-Hook window.__drawings2d (id + geom). Dieser
// Hook wurde nur für die Verifikation in App.tsx eingehängt und danach wieder
// ENTFERNT — zum erneuten Lauf muss er kurzzeitig reaktiviert werden:
// useEffect(() => {
// (window).__drawings2d = project.drawings2d.map(d => ({id:d.id, geom:d.geom}));
// }, [project.drawings2d]);
// Verifizierte Ergebnisse (siehe probe-splitjoin-*.png):
// Split Rechteck+Querlinie → 2 geschlossene Polylinien + Linie
// Join 2 Linien (gem. Endpunkt) → 1 Polylinie (3 Punkte)
// Alt-Klick auf Rechteck-Kante → offene Polylinie (4 Punkte, Segment weg)
import puppeteer from "puppeteer";
const b = await puppeteer.launch({
@@ -75,8 +84,8 @@ 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);
// Rechteck wählen: auf die linke Kante klicken (x=0, y=2) — eindeutig auf dem Rand.
let [cx, cy] = await modelToClient(0, 2);
await p.mouse.click(cx, cy);
await sleep(150);
await ctrlPress("s");
@@ -91,20 +100,20 @@ await p.reload({ waitUntil: "networkidle0" });
await sleep(800);
await focusCmd();
await typeLine("line");
await typeLine("-3,-3");
await typeLine("0,-3");
await typeLine("1,1");
await typeLine("4,1");
await focusCmd();
await typeLine("line");
await typeLine("0,-3");
await typeLine("0,0");
await typeLine("4,1");
await typeLine("4,4");
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);
// Beide wählen: erste klicken (Mitte 2.5,1), dann Shift+zweite (Mitte 4,2.5).
let [ax, ay] = await modelToClient(2.5, 1);
await p.mouse.click(ax, ay);
await sleep(100);
let [bx2, by2] = await modelToClient(0, -1.5);
await sleep(120);
let [bx2, by2] = await modelToClient(4, 2.5);
await p.keyboard.down("Shift");
await p.mouse.click(bx2, by2);
await p.keyboard.up("Shift");
+35
View File
@@ -0,0 +1,35 @@
// Screenshot des Plans in LIGHT- vs DARK-Theme (prefers-color-scheme emuliert),
// um theme-abhängige Linien-/Textfarben aufzudecken. Default-App (SVG-Renderer).
import puppeteer from "puppeteer";
const BASE = process.env.PROBE_URL || "http://localhost:5187/";
const browser = await puppeteer.launch({
headless: "new",
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"],
});
async function shoot(scheme, path) {
const page = await browser.newPage();
await page.setViewport({ width: 1400, height: 900, deviceScaleFactor: 2 });
await page.emulateMediaFeatures([{ name: "prefers-color-scheme", value: scheme }]);
try {
await page.goto(BASE, { waitUntil: "networkidle0", timeout: 25000 });
} catch (e) {
console.log(`[goto ${scheme}]`, e.message);
}
await new Promise((r) => setTimeout(r, 1200));
const colors = await page.evaluate(() => {
const cs = getComputedStyle(document.documentElement);
return {
sheet: cs.getPropertyValue("--sheet").trim(),
ink: cs.getPropertyValue("--ink").trim(),
};
});
await page.screenshot({ path });
console.log(`${scheme}: --sheet=${colors.sheet} --ink=${colors.ink} → ${path}`);
await page.close();
}
await shoot("light", "scripts/probe-theme-light.png");
await shoot("dark", "scripts/probe-theme-dark.png");
await browser.close();
+67
View File
@@ -0,0 +1,67 @@
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: 1600, height: 300, deviceScaleFactor: 2 });
const logs = [];
p.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`));
p.on("pageerror", (e) => logs.push(`[PAGEERROR] ${e.message}`));
await p.goto(URL, { waitUntil: "networkidle0", timeout: 25000 }).catch((e) =>
logs.push(`[GOTO] ${e.message}`),
);
await new Promise((r) => setTimeout(r, 900));
// Ganze Oberleiste (unabhängig vom horizontalen Scroll) breit einfangen.
const info = await p.evaluate(() => {
const tb = document.querySelector(".topbar");
const stat = document.querySelector(".tb-stat");
const segs = document.querySelectorAll(".tb-seg").length;
const textGroup = !!document.querySelector(".tb-textgroup");
const looseZoom = document.querySelectorAll(".tb-zoom").length; // sollte 0 sein
const addText = !!document.querySelector(".tb-addtext");
const iconBtns = document.querySelectorAll(".tb-iconbtn").length;
return {
barH: tb ? Math.round(tb.getBoundingClientRect().height) : null,
scrollW: tb ? tb.scrollWidth : null,
statPresent: !!stat,
segmentCount: segs,
textGroup,
looseZoomSpans: looseZoom,
addText,
iconBtns,
};
});
console.log("INFO", JSON.stringify(info));
// Oberleiste voll ins Bild scrollen und mehrere Ausschnitte schiessen.
const tb = await p.$(".topbar");
const box = await tb.boundingBox();
await p.screenshot({
path: "scripts/probe-topbar-dossier-1-left.png",
clip: { x: 0, y: 0, width: 1600, height: Math.ceil(box.height) + 6 },
});
// Nach rechts scrollen, um Zoom-Cluster + Text-Gruppe zu zeigen.
await p.evaluate(() => {
const el = document.querySelector(".topbar");
if (el) el.scrollLeft = Math.max(0, el.scrollWidth - el.clientWidth);
});
await new Promise((r) => setTimeout(r, 400));
await p.screenshot({
path: "scripts/probe-topbar-dossier-2-right.png",
clip: { x: 0, y: 0, width: 1600, height: Math.ceil(box.height) + 6 },
});
console.log("\n=== Konsole ===");
console.log(logs.length ? logs.join("\n") : "(keine)");
const errors = logs.filter(
(l) => l.startsWith("[error]") || l.startsWith("[PAGEERROR]"),
);
console.log(errors.length ? `\nFEHLER: ${errors.length}` : "\nKeine Konsolenfehler.");
await b.close();
process.exit(errors.length ? 1 : 0);
+81
View File
@@ -0,0 +1,81 @@
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: 220, deviceScaleFactor: 3 });
await p
.goto("http://localhost:5187/", { waitUntil: "networkidle0", timeout: 20000 })
.catch(() => {});
await new Promise((r) => setTimeout(r, 800));
// Helfer: Button per Tooltip-Anfang finden (title beginnt mit Text).
const findByTitle = (frag) =>
p.evaluateHandle((f) => {
const els = [...document.querySelectorAll("button[title]")];
return els.find((e) => e.getAttribute("title")?.startsWith(f)) || null;
}, frag);
const snap = async (file) => {
const tb = await p.$(".topbar");
const box = await tb.boundingBox();
await p.screenshot({
path: `scripts/${file}`,
clip: { x: 0, y: 0, width: 1500, height: Math.ceil(box.height) + 8 },
});
};
// Aktuellen Zustand der iconisierten Steuerungen lesen.
const state = () =>
p.evaluate(() => {
const byTitle = (f) =>
[...document.querySelectorAll("button[title]")].find((e) =>
e.getAttribute("title")?.startsWith(f),
);
const ref = byTitle("Referenzlinien");
const res = byTitle("Ressourcen");
const icons = [...document.querySelectorAll("button .material-symbols-outlined.tb-ico")].map(
(s) => s.textContent,
);
return {
refIcon: ref?.querySelector(".tb-ico")?.textContent,
refActive: ref?.classList.contains("active") ?? null,
resIcon: res?.querySelector(".tb-ico")?.textContent,
resActive: res?.classList.contains("active") ?? null,
lineModeIcons: [
...document.querySelectorAll(".view-btn-ico .tb-ico"),
].map((s) => s.textContent),
allTbIcons: icons,
};
});
console.log("INITIAL:", JSON.stringify(await state()));
await snap("probe-topbar-rest-1-initial.png");
// Ressourcen-Toggle einschalten (öffnet die Schublade) → aktiver Zustand.
const res = await findByTitle("Ressourcen");
if (res && (await res.evaluate((e) => !!e))) {
await res.click();
await new Promise((r) => setTimeout(r, 500));
}
console.log("AFTER RES TOGGLE:", JSON.stringify(await state()));
await snap("probe-topbar-rest-2-res-active.png");
// Kamera-Popover öffnen (Perspektive nötig — erst auf Perspektive schalten).
const iso = await findByTitle("Isometrie");
if (iso && (await iso.evaluate((e) => !!e))) {
await iso.click();
await new Promise((r) => setTimeout(r, 400));
}
const cam = await findByTitle("Kamera");
if (cam && (await cam.evaluate((e) => !!e))) {
await cam.click();
await new Promise((r) => setTimeout(r, 400));
}
const popoverOpen = await p.evaluate(() => !!document.querySelector(".tb-popover"));
console.log("CAMERA POPOVER OPEN:", popoverOpen);
await snap("probe-topbar-rest-3-popover.png");
await b.close();
+108
View File
@@ -0,0 +1,108 @@
// Verifikation Trim (Quick-Trim „stutzen"). Zwei sich kreuzende Linien zeichnen,
// dann `trim` und auf den Überstand einer Linie klicken → der Überstand ist bis
// zum Schnittpunkt weg (neuer Endpunkt = Schnittpunkt). Liest die gerenderten
// `line.draw2d`-Segmente direkt aus dem SVG (Modellkoord. = SVG/PX_PER_M).
import puppeteer from "puppeteer";
const PX = 90; // PX_PER_M in PlanView
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(() => {});
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
await sleep(800);
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: 6 });
await p.keyboard.press("Enter");
await sleep(90);
};
const segs = () =>
p.evaluate((PX) => {
return [...document.querySelectorAll(".plan-svg line.draw2d")].map((l) => ({
x1: +l.getAttribute("x1") / PX,
y1: -l.getAttribute("y1") / PX,
x2: +l.getAttribute("x2") / PX,
y2: -l.getAttribute("y2") / PX,
}));
}, PX);
// Modellpunkt → Client-Pixel über das aktuelle SVG-CTM.
const modelToClient = (mx, my) =>
p.evaluate(
(mx, my, PX) => {
const svg = document.querySelector(".plan-svg");
const ctm = svg.getScreenCTM();
const q = svg.createSVGPoint();
q.x = mx * PX;
q.y = -my * PX;
const s = q.matrixTransform(ctm);
return [s.x, s.y];
},
mx,
my,
PX,
);
// ── Zwei sich kreuzende Linien ───────────────────────────────────────────────
// A: horizontal y=2, x=0..6 (Überstand rechts ab x=4). B: vertikal x=4, y=0..4.
await focusCmd();
await typeLine("line");
await typeLine("0,2");
await typeLine("6,2");
await focusCmd();
await typeLine("line");
await typeLine("4,0");
await typeLine("4,4");
await p.keyboard.press("Escape");
await sleep(120);
const before = await segs();
// ── Trim: auf den Überstand von A (x=5, y=2) klicken ─────────────────────────
await focusCmd();
await typeLine("trim");
// Befehl ist aktiv; jetzt in der Plan-Ansicht auf den Überstand klicken.
// Eingabefeld behält Fokus → der Klick geht trotzdem an PlanView (pick).
const [hx, hy] = await modelToClient(5, 2);
await p.mouse.move(hx, hy);
await sleep(120);
await p.mouse.click(hx, hy);
await sleep(200);
await p.keyboard.press("Escape");
await sleep(120);
const after = await segs();
await p.screenshot({ path: "scripts/probe-trim.png" });
// ── Auswertung: gibt es ein horizontales Segment (y≈2), das bei x≈4 endet und
// NICHT über x=4 hinausragt? Der Überstand x∈(4,6] muss weg sein. ────────────
const horiz = after.filter((s) => Math.abs(s.y1 - 2) < 0.05 && Math.abs(s.y2 - 2) < 0.05);
const maxX = horiz.reduce((m, s) => Math.max(m, s.x1, s.x2), -Infinity);
const trimmedToIntersection = Math.abs(maxX - 4) < 0.05;
console.log(
JSON.stringify(
{
beforeCount: before.length,
afterCount: after.length,
horizSegments: horiz,
maxXofHoriz: maxX,
trimmedToIntersection,
errs,
},
null,
2,
),
);
await b.close();
+101
View File
@@ -0,0 +1,101 @@
// Verifikation: „Wand" als echter Engine-Befehl.
// 1) GUI-„Wand" klicken → Befehlsfeld aktiv (Wand-Prompt, Feld fokussiert).
// 2) Mehrere Punkte im Plan klicken → Wände entstehen (Plan-Primitive wachsen),
// Rechtsklick beendet den (chainenden) Zug.
// 3) In 3D (Isometrie) schalten → die Wände sind dort sichtbar (Screenshot).
// 4) Getipptes „wand" startet DENSELBEN Befehl (Prompt = Wand-Prompt).
// Temporäres Verifikations-Gerüst (nicht eingecheckt).
import puppeteer from "puppeteer";
const URL = process.env.PROBE_URL || "http://localhost:5188/";
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.25 });
const errs = [];
p.on("pageerror", (e) => errs.push(e.message));
await p.goto(URL, { waitUntil: "networkidle0", timeout: 20000 }).catch(() => {});
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
await wait(900);
const clickTool = (label) =>
p.evaluate((l) => [...document.querySelectorAll("button.tool-row")]
.find((x) => x.textContent.trim() === l)?.click(), label);
const clickView = (aria) =>
p.evaluate((a) => [...document.querySelectorAll("button")]
.find((x) => x.getAttribute("aria-label") === a)?.click(), aria);
const planBox = () => p.evaluate(() => {
const r = document.querySelector(".plan-svg").getBoundingClientRect();
return { x: r.x, y: r.y, w: r.width, h: r.height };
});
const click = async (x, y, opts) => { await p.mouse.move(x, y); await p.mouse.click(x, y, opts); await wait(110); };
const cmdState = () => p.evaluate(() => {
const prompt = document.querySelector(".cmdline-prompt")?.textContent?.trim() ?? "";
const input = document.querySelector(".cmdline-input");
const focused = document.activeElement === input;
const placeholder = input?.getAttribute("placeholder") ?? "";
return { prompt, focused, active: placeholder === "" };
});
// Grobes Mass für „Wand-Geometrie im Plan": Anzahl Pfad-/Polygon-Primitive.
// Wände rendern als gefüllte Schicht-Polygone + Umriss-Pfade; jede neue Wand
// erhöht diese Zahl deutlich.
const primCount = () => p.evaluate(() =>
document.querySelectorAll(".plan-svg path, .plan-svg polygon").length);
const typeInCmd = async (text) => {
await p.evaluate(() => document.querySelector(".cmdline-input")?.focus());
await p.keyboard.type(text);
await p.keyboard.press("Enter");
await wait(200);
};
const box = await planBox();
const cx = box.x + box.w / 2, cy = box.y + box.h / 2;
// ── 1) „Wand" anklicken → Befehlsfeld aktiv, Wand-Prompt ─────────────────────
const primStart = await primCount();
await clickTool("Wand");
await wait(300);
const afterToolClick = await cmdState();
console.log("after Wand-tool click → cmdline:", afterToolClick);
await p.screenshot({ path: "scripts/probe-wall-cmd-1-active.png" });
// ── 2) Mehrere Punkte → chainende Wände, dann Rechtsklick zum Beenden ────────
const P = [
[cx - 220, cy - 120],
[cx + 40, cy - 160],
[cx + 200, cy + 10],
[cx + 60, cy + 150],
];
for (const pt of P) await click(...pt);
await p.mouse.click(P[P.length - 1][0], P[P.length - 1][1], { button: "right" }); // Zug beenden
await wait(300);
const primAfter = await primCount();
console.log("plan primitives start / after walls:", primStart, primAfter, "→ delta", primAfter - primStart);
await p.screenshot({ path: "scripts/probe-wall-cmd-2-plan.png" });
// ── 3) In 3D (Isometrie) → Wände sichtbar ───────────────────────────────────
await clickView("Isometrie");
await wait(1500);
await p.screenshot({ path: "scripts/probe-wall-cmd-3-3d.png" });
// Zurück in den Grundriss.
await clickView("Grundriss");
await wait(600);
// ── 4) Getipptes „wand" startet DENSELBEN Befehl ────────────────────────────
await clickTool("Auswahl");
await wait(200);
const before = await cmdState();
await typeInCmd("wand");
const afterTyped = await cmdState();
console.log("after typing 'wand' → cmdline:", afterTyped, "(was:", before, ")");
// Einen Punkt setzen + Rechtsklick (Abbruch ohne 2. Punkt) — nur Prompt-Beleg.
await p.screenshot({ path: "scripts/probe-wall-cmd-4-typed.png" });
await p.keyboard.press("Escape");
console.log("errs:", errs.slice(0, 6));
console.log("RESULT activePrompt:", afterToolClick.active, afterToolClick.prompt,
"| primDelta:", primAfter - primStart,
"| typedPrompt:", afterTyped.active, afterTyped.prompt);
await b.close();
+4
View File
@@ -0,0 +1,4 @@
/target
/gen/schemas
# Build-Artefakte aller (Sub-)Crates, unabhängig von der Verschachtelung.
target/
+4399
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
[workspace]
members = [".", "geometry"]
[package]
name = "cad-tauri"
version = "0.1.0"
edition = "2021"
[lib]
# Der Tauri-v2-Bibliotheksname; staticlib/cdylib fuer Mobile, rlib fuer Desktop.
name = "cad_tauri_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
geometry = { path = "geometry" }
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+75
View File
@@ -0,0 +1,75 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "geometry"
version = "0.1.0"
dependencies = [
"serde",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
dependencies = [
"proc-macro2",
]
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "syn"
version = "2.0.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "geometry"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { version = "1", features = ["derive"] }
# Nur fuers Paritaets-Beispiel (examples/parity.rs) — JSON von stdin lesen.
[dev-dependencies]
serde_json = "1"
+16
View File
@@ -0,0 +1,16 @@
// Paritaets-Harness: liest ein JoinInput-JSON von stdin, rechnet compute_joins
// und schreibt das WallCuts-JSON nach stdout. Dient dem TS<->Rust-Vergleich
// (siehe src/compute/parity.test.ts) — identische Eingabe, identische Ausgabe.
use std::io::Read;
fn main() {
let mut buf = String::new();
std::io::stdin()
.read_to_string(&mut buf)
.expect("stdin lesen");
let input: geometry::JoinInput = serde_json::from_str(&buf).expect("JoinInput parsen");
let cuts = geometry::compute_joins(input);
let out = serde_json::to_string(&cuts).expect("WallCuts serialisieren");
println!("{out}");
}
+357
View File
@@ -0,0 +1,357 @@
// Wand-Verschneidung (Gehrung): an einer Ecke, wo zwei Waende aufeinander-
// treffen, sollen sich die Schicht-Baender nicht ueberlappen, sondern an einer
// gemeinsamen Gehrungslinie sauber stossen. Dieses Modul berechnet pro Wand
// die optionalen Schnittlinien an Start- und Endpunkt.
//
// Reine 2D-Mathematik, kein externer Kernel noetig. Portiert aus dem
// TS-Modell (joins.ts / geometry.ts); die Vektor-Helfer add/sub/scale/
// normalize/leftNormal/len/lineIntersect sind unten dupliziert.
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone, Copy)]
pub struct Vec2 {
pub x: f64,
pub y: f64,
}
/// Eine unendliche Gerade als Stuetzpunkt + Richtung.
#[derive(Serialize, Deserialize, Clone, Copy)]
pub struct Line {
pub point: Vec2,
pub dir: Vec2,
}
#[derive(Serialize, Deserialize)]
pub struct WallInput {
pub id: String,
pub start: Vec2,
pub end: Vec2,
pub thickness: f64,
#[serde(rename = "referenceOffset")]
pub reference_offset: f64,
}
#[derive(Serialize, Deserialize)]
pub struct JoinInput {
pub walls: Vec<WallInput>,
}
/// Schnittlinien einer Wand an ihren beiden Achsenden.
#[derive(Serialize, Deserialize)]
pub struct WallCuts {
#[serde(rename = "wallId")]
pub wall_id: String,
#[serde(rename = "startCut")]
pub start_cut: Option<Line>,
#[serde(rename = "endCut")]
pub end_cut: Option<Line>,
}
// --- Vektor-Helfer -----------------------------------------------------------
fn sub(a: Vec2, b: Vec2) -> Vec2 {
Vec2 { x: a.x - b.x, y: a.y - b.y }
}
fn add(a: Vec2, b: Vec2) -> Vec2 {
Vec2 { x: a.x + b.x, y: a.y + b.y }
}
fn scale(a: Vec2, s: f64) -> Vec2 {
Vec2 { x: a.x * s, y: a.y * s }
}
fn len(a: Vec2) -> f64 {
a.x.hypot(a.y)
}
fn normalize(a: Vec2) -> Vec2 {
let l = len(a);
let l = if l == 0.0 { 1.0 } else { l };
Vec2 { x: a.x / l, y: a.y / l }
}
/// Linke Normale (90 Grad gegen den Uhrzeigersinn gedreht).
fn left_normal(a: Vec2) -> Vec2 {
Vec2 { x: -a.y, y: a.x }
}
/// Kreuzprodukt (Z-Komponente) zweier 2D-Vektoren.
fn cross(p: Vec2, q: Vec2) -> f64 {
p.x * q.y - p.y * q.x
}
/// Schnittpunkt der Geraden (a + t*da) mit (b + s*db).
/// Liefert None bei (nahezu) parallelen Richtungen.
fn line_intersect(a: Vec2, da: Vec2, b: Vec2, db: Vec2) -> Option<Vec2> {
let denom = cross(da, db);
if denom.abs() < 1e-9 {
return None; // parallel -> kein Schnitt
}
let t = cross(sub(b, a), db) / denom;
Some(add(a, scale(da, t)))
}
// --- Verschneidung -----------------------------------------------------------
/// Rundet eine Koordinate auf ein Gitter, um Endpunkte robust zu gruppieren.
fn round_key(p: Vec2) -> String {
let r = |v: f64| (v * 1e4).round() / 1e4;
format!("{},{}", r(p.x), r(p.y))
}
#[derive(Clone, Copy, PartialEq)]
enum WallEndKind {
Start,
End,
}
struct WallEnd {
wall_id: String,
end: WallEndKind,
}
/// Achsrichtung start->end, normalisiert.
fn dir_of(w: &WallInput) -> Vec2 {
normalize(sub(w.end, w.start))
}
/**
* Berechnet fuer jede Wand die Gehrungs-Schnittlinien.
* Nur L-Ecken (genau zwei Wandenden treffen sich) werden behandelt; freie
* Enden und T-/X-Stoesse bleiben rechtwinklig.
*/
pub fn compute_joins(input: JoinInput) -> Vec<WallCuts> {
let walls = input.walls;
// Ergebnis: pro Wand ein Eintrag, Reihenfolge wie in der Eingabe.
let mut result: Vec<WallCuts> = walls
.iter()
.map(|w| WallCuts {
wall_id: w.id.clone(),
start_cut: None,
end_cut: None,
})
.collect();
// Index Wand-Id -> Position im Ergebnis/Eingabe.
let mut index: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
for (i, w) in walls.iter().enumerate() {
index.insert(w.id.as_str(), i);
}
// Knotenkarte: gerundeter Endpunkt -> Liste der dort endenden Wandenden.
// BTreeMap fuer deterministische Reihenfolge.
let mut junctions: std::collections::BTreeMap<String, Vec<WallEnd>> =
std::collections::BTreeMap::new();
let push = |p: Vec2, we: WallEnd, m: &mut std::collections::BTreeMap<String, Vec<WallEnd>>| {
m.entry(round_key(p)).or_default().push(we);
};
for w in &walls {
push(
w.start,
WallEnd { wall_id: w.id.clone(), end: WallEndKind::Start },
&mut junctions,
);
push(
w.end,
WallEnd { wall_id: w.id.clone(), end: WallEndKind::End },
&mut junctions,
);
}
for (_key, ends) in &junctions {
// Freies Ende -> kein Schnitt.
if ends.len() == 1 {
continue;
}
// T-/X-Stoesse (>2 Enden): vorerst rechtwinklig lassen.
if ends.len() != 2 {
continue;
}
let a_idx = match index.get(ends[0].wall_id.as_str()) {
Some(i) => *i,
None => continue,
};
let b_idx = match index.get(ends[1].wall_id.as_str()) {
Some(i) => *i,
None => continue,
};
let a = &walls[a_idx];
let b = &walls[b_idx];
let cut = match miter_line(a, ends[0].end, b) {
Some(c) => c,
None => continue, // kollinear -> kein Schnitt
};
set_cut(&mut result[a_idx], ends[0].end, cut);
set_cut(&mut result[b_idx], ends[1].end, cut);
}
result
}
/// Traegt eine Schnittlinie am passenden Ende einer Wand ein.
fn set_cut(cuts: &mut WallCuts, end: WallEndKind, cut: Line) {
match end {
WallEndKind::Start => cuts.start_cut = Some(cut),
WallEndKind::End => cuts.end_cut = Some(cut),
}
}
/**
* Gemeinsame Gehrungslinie zweier Waende A, B, die sich im Knoten J treffen.
* Robust gegen beliebige Wicklung und ungleiche Dicken: A's Aussenflaeche wird
* mit der NAECHSTGELEGENEN Flaeche von B verschnitten, A's Innenflaeche mit der
* jeweils anderen. Die Gerade durch beide Eckpunkte ist die Gehrung.
*
* Dicke und Referenzversatz kommen direkt aus WallInput (bereits flach).
*/
fn miter_line(a: &WallInput, a_end: WallEndKind, b: &WallInput) -> Option<Line> {
let j = match a_end {
WallEndKind::Start => a.start,
WallEndKind::End => a.end,
};
let t_a = a.thickness;
let t_b = b.thickness;
let u_a = dir_of(a);
let u_b = dir_of(b);
let n_a = left_normal(u_a);
let n_b = left_normal(u_b);
// Referenzlinien-Versatz: liegt die Achse nicht mittig, sind die beiden
// Wandflaechen um diesen Betrag entlang +n verschoben. Fuer "center"
// (Default) ist off=0 -> unveraendert.
let off_a = a.reference_offset;
let off_b = b.reference_offset;
// Flaechen-Stuetzpunkte am Knoten (linke/rechte Wandseite), inkl. Versatz.
let p_la = add(j, scale(n_a, off_a + t_a / 2.0));
let p_ra = add(j, scale(n_a, off_a - t_a / 2.0));
let p_lb = add(j, scale(n_b, off_b + t_b / 2.0));
let p_rb = add(j, scale(n_b, off_b - t_b / 2.0));
// Fuer A's linke Flaeche die naehere B-Flaeche waehlen; A's rechte die andere.
let lb_closer = len(sub(p_la, p_lb)) <= len(sub(p_la, p_rb));
let b_for_left = if lb_closer { p_lb } else { p_rb };
let b_for_right = if lb_closer { p_rb } else { p_lb };
let c1 = line_intersect(p_la, u_a, b_for_left, u_b)?;
let c2 = line_intersect(p_ra, u_a, b_for_right, u_b)?;
let dir = sub(c2, c1);
if len(dir) < 1e-9 {
return None;
}
Some(Line { point: c1, dir })
}
// --- Tests -------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
fn w(id: &str, sx: f64, sy: f64, ex: f64, ey: f64, thickness: f64) -> WallInput {
WallInput {
id: id.to_string(),
start: Vec2 { x: sx, y: sy },
end: Vec2 { x: ex, y: ey },
thickness,
reference_offset: 0.0,
}
}
fn find<'a>(cuts: &'a [WallCuts], id: &str) -> &'a WallCuts {
cuts.iter().find(|c| c.wall_id == id).expect("wall id present")
}
#[test]
fn l_corner_shared_miter() {
// Wand A (0,0)-(5,0) und Wand B (5,0)-(5,4) treffen sich in (5,0).
let input = JoinInput {
walls: vec![
w("A", 0.0, 0.0, 5.0, 0.0, 0.2),
w("B", 5.0, 0.0, 5.0, 4.0, 0.2),
],
};
let out = compute_joins(input);
assert_eq!(out.len(), 2);
let ca = find(&out, "A");
let cb = find(&out, "B");
// A endet im Knoten -> endCut gesetzt; B startet dort -> startCut gesetzt.
let a_cut = ca.end_cut.expect("A endCut set");
let b_cut = cb.start_cut.expect("B startCut set");
assert!(ca.start_cut.is_none(), "A startCut is free end");
assert!(cb.end_cut.is_none(), "B endCut is free end");
// Beide Waende teilen sich dieselbe Gehrungslinie.
assert!((a_cut.point.x - b_cut.point.x).abs() < 1e-9);
assert!((a_cut.point.y - b_cut.point.y).abs() < 1e-9);
assert!((a_cut.dir.x - b_cut.dir.x).abs() < 1e-9);
assert!((a_cut.dir.y - b_cut.dir.y).abs() < 1e-9);
// Sanity: die Gehrung einer 90-Grad-Ecke gleicher Dicke ist die
// Diagonale durch (5,0), Richtung parallel zu (1,1) oder (-1,-1).
let d = normalize(a_cut.dir);
assert!(
(d.x.abs() - d.y.abs()).abs() < 1e-6,
"45-Grad-Gehrung erwartet"
);
}
#[test]
fn free_end_no_cut() {
let input = JoinInput {
walls: vec![w("A", 0.0, 0.0, 5.0, 0.0, 0.2)],
};
let out = compute_joins(input);
assert_eq!(out.len(), 1);
let ca = find(&out, "A");
assert!(ca.start_cut.is_none());
assert!(ca.end_cut.is_none());
}
#[test]
fn t_junction_no_cut() {
// Drei Enden treffen sich in (5,0): rechtwinklig lassen.
let input = JoinInput {
walls: vec![
w("A", 0.0, 0.0, 5.0, 0.0, 0.2),
w("B", 5.0, 0.0, 5.0, 4.0, 0.2),
w("C", 5.0, 0.0, 10.0, 0.0, 0.2),
],
};
let out = compute_joins(input);
assert_eq!(out.len(), 3);
for id in ["A", "B", "C"] {
let c = find(&out, id);
assert!(c.start_cut.is_none(), "{id} startCut none");
assert!(c.end_cut.is_none(), "{id} endCut none");
}
}
#[test]
fn collinear_pair_no_cut() {
// A (0,0)-(5,0) und B (5,0)-(10,0): parallel -> None.
let input = JoinInput {
walls: vec![
w("A", 0.0, 0.0, 5.0, 0.0, 0.2),
w("B", 5.0, 0.0, 10.0, 0.0, 0.2),
],
};
let out = compute_joins(input);
assert_eq!(out.len(), 2);
for id in ["A", "B"] {
let c = find(&out, id);
assert!(c.start_cut.is_none(), "{id} startCut none");
assert!(c.end_cut.is_none(), "{id} endCut none");
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 103 B

+18
View File
@@ -0,0 +1,18 @@
// Tauri-v2-Einstieg. Die eigentliche Geometrie liegt im serde-only Crate
// `geometry`; hier nur die Befehls-Bruecke und der App-Start.
/// Berechnet die Wand-Gehrungen im Rust-Kern und liefert sie ans Frontend.
#[tauri::command]
async fn compute_joins(
input: geometry::JoinInput,
) -> Result<Vec<geometry::WallCuts>, String> {
Ok(geometry::compute_joins(input))
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![compute_joins])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+5
View File
@@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
cad_tauri_lib::run()
}
+27
View File
@@ -0,0 +1,27 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "cad",
"version": "0.1.0",
"identifier": "ch.dossier.cad",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:5187"
},
"app": {
"windows": [
{
"title": "cad",
"width": 1400,
"height": 900
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": ["icons/icon.png"]
}
}
+1452 -100
View File
File diff suppressed because it is too large Load Diff
+250
View File
@@ -0,0 +1,250 @@
// Ceiling — Decke als echter Engine-Befehl (analog polyline: ein GESCHLOSSENER
// Umriss), committet aber ein einzelnes `Ceiling`-Bauteil statt einer 2D-Form.
// Schritte:
// 1) „Erster Umrisspunkt der Decke:" → Punkt
// 2) „Nächster Punkt ( Schliessen Zurück ):" → Punkt … Der Umriss schließt per
// Klick auf den Startpunkt, Option „Schliessen" (C) oder Enter (≥3 Punkte).
//
// Akzeptiert je Punkt Maus-Picks UND getippte Koordinaten (0,0 · r3,0 · 5<45) —
// derselbe Eingabepfad wie Polylinie/Wand. Die fertige Decke rendert über die
// bestehende Decken-Darstellung (generatePlan / 3D); der Draft zeigt den Umriss.
//
// Herkunft der Decken-Felder (CommandContext):
// • wallTypeId ← ctx.activeWallTypeId (Fallback: erster Projekt-Wandtyp) — der
// Schichtaufbau (Bauteil/Schraffur/Material) wird identisch zur Wand aufgelöst.
// • floorId ← ctx.level.id (aktives Geschoss)
// • categoryCode← "30" (Ebene „Decken"; Fallback ctx.defaultCategoryCode)
import type { Ceiling } from "../../model/types";
import { normalizeOutline, ceilingArea } from "../../geometry/ceiling";
import { uniqueId } from "../../tools/types";
import type {
Command,
CommandContext,
CommandField,
CommandResult,
CommandState,
CmdOption,
DraftShape,
Project,
ToolDraft,
Vec2,
} from "../types";
const EPS = 1e-6;
const DEG = Math.PI / 180;
const CLOSE_HIT = 0.08; // Modell-Meter: Klick nahe Startpunkt schließt den Umriss
/** Default-Ebene (Kategorie) für Decken. */
const CEILING_CATEGORY = "30";
const segLen = (a: Vec2, b: Vec2): number => Math.hypot(b.x - a.x, b.y - a.y);
const segAngleDeg = (a: Vec2, b: Vec2): number =>
(Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI;
/** Tab-Felder je weiteren Punkt: Länge + Winkel relativ zum letzten Punkt. */
const CEIL_FIELDS: CommandField[] = [
{ id: "length", labelKey: "cmd.field.length" },
{ id: "angle", labelKey: "cmd.field.angle" },
];
/** Nächster Punkt aus gelockten Feldern (length/angle) + Cursor, relativ zu `last`. */
function nextFromFields(
last: Vec2,
locks: Record<string, number>,
cursor: Vec2 | null,
): Vec2 {
const ref = cursor ?? last;
const length = "length" in locks ? locks.length : segLen(last, ref);
const angleDeg = "angle" in locks ? locks.angle : segAngleDeg(last, ref);
const ang = angleDeg * DEG;
return { x: last.x + Math.cos(ang) * length, y: last.y + Math.sin(ang) * length };
}
/** Aktiver Aufbau-Typ-ID; Fallback auf den ersten Projekt-Wandtyp. */
function activeTypeId(ctx: CommandContext): string {
const wt =
ctx.project.wallTypes.find((t) => t.id === ctx.activeWallTypeId) ??
ctx.project.wallTypes[0];
return wt ? wt.id : ctx.activeWallTypeId;
}
/** Existiert die Decken-Kategorie („30")? Sonst Fallback auf die aktive Kategorie. */
function ceilingCategory(ctx: CommandContext): string {
const flat: { code: string }[] = [];
const walk = (list: { code: string; children?: unknown[] }[]) => {
for (const c of list) {
flat.push({ code: c.code });
if (c.children) walk(c.children as { code: string; children?: unknown[] }[]);
}
};
walk(ctx.project.layers as { code: string; children?: unknown[] }[]);
return flat.some((c) => c.code === CEILING_CATEGORY)
? CEILING_CATEGORY
: ctx.defaultCategoryCode;
}
interface CeilIdle extends CommandState {
phase: "start";
}
interface CeilDrawing extends CommandState {
phase: "next";
points: Vec2[];
cursor: Vec2 | null;
}
type CeilState = CeilIdle | CeilDrawing;
const CLOSE: CmdOption = { id: "close", labelKey: "cmd.polyline.close" };
const UNDO: CmdOption = { id: "undo", labelKey: "cmd.polyline.undo" };
/** Vorschau: geschlossener Umriss-Ring (ab 3 Punkten) + Gummiband + HUD. */
function ceilingDraft(points: Vec2[], cursor: Vec2 | null): ToolDraft {
const pts = cursor ? [...points, cursor] : points;
const showClosed = pts.length >= 3;
const preview: DraftShape[] = [{ kind: "poly", pts, closed: showClosed }];
const draft: ToolDraft = { preview, vertices: points };
const last = points[points.length - 1];
if (cursor && last && segLen(last, cursor) >= EPS) {
draft.hud = {
at: cursor,
text: `${segLen(last, cursor).toFixed(2)} m · ${Math.abs(
(segAngleDeg(last, cursor) + 360) % 360,
).toFixed(0)}°`,
};
}
return draft;
}
/** Hängt eine Decke ans Projekt (immutabel). Entartete Umrisse werden verworfen. */
function appendCeiling(p: Project, pts: Vec2[], ctx: CommandContext): Project {
const outline = normalizeOutline(pts);
if (!outline || ceilingArea(outline) < 1e-4) return p;
const ceiling: Ceiling = {
id: uniqueId("C"),
type: "ceiling",
floorId: ctx.level.id,
categoryCode: ceilingCategory(ctx),
outline,
wallTypeId: activeTypeId(ctx),
};
const ceilings = p.ceilings ? [...p.ceilings, ceiling] : [ceiling];
return { ...p, ceilings };
}
const idle = (): [CommandState, CommandResult] => [
{ phase: "start", lastPoint: null } as CeilIdle,
{ draft: null, done: true },
];
export const ceilingCommand: Command = {
name: "ceiling",
labelKey: "cmd.ceiling.label",
// Decken leben auf Geschossen — wie die Wand nur dort aktiv.
floorOnly: true,
prompt: (s) =>
(s as CeilState).phase === "next" ? "cmd.ceiling.next" : "cmd.ceiling.start",
accepts: (s) =>
(s as CeilState).phase === "next"
? ["point", "number", "option"]
: ["point", "number"],
options: (s) => {
const ps = s as CeilState;
if (ps.phase !== "next") return [];
return ps.points.length >= 3 ? [CLOSE, UNDO] : [UNDO];
},
init: (): CeilIdle => ({ phase: "start", lastPoint: null }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as CeilState;
if (input.kind === "option") {
if (s.phase !== "next") return [s, { draft: null }];
if (input.id === "undo") {
const pts = s.points.slice(0, -1);
if (pts.length === 0)
return [{ phase: "start", lastPoint: null } as CeilIdle, { draft: null }];
const ns: CeilDrawing = {
phase: "next",
points: pts,
cursor: s.cursor,
lastPoint: pts[pts.length - 1],
};
return [ns, { draft: ceilingDraft(pts, s.cursor) }];
}
// Sofort schließen: committet die Decke (unabhängig, ≥3 Punkte).
if (input.id === "close" && s.points.length >= 3) {
const pts = s.points;
return [
{ phase: "start", lastPoint: null } as CeilIdle,
{ draft: null, done: true, commit: (p) => appendCeiling(p, pts, ctx) },
];
}
return [s, { draft: ceilingDraft(s.points, s.cursor) }];
}
if (input.kind !== "point") {
return [
s,
{ draft: s.phase === "next" ? ceilingDraft(s.points, s.cursor) : null },
];
}
const pt = input.point;
if (s.phase !== "next") {
const ns: CeilDrawing = { phase: "next", points: [pt], cursor: pt, lastPoint: pt };
return [ns, { draft: ceilingDraft([pt], pt) }];
}
// Klick nahe Startpunkt → schließen (committet die Decke).
if (s.points.length >= 3 && segLen(s.points[0], pt) < CLOSE_HIT) {
const pts = s.points;
return [
{ phase: "start", lastPoint: null } as CeilIdle,
{ draft: null, done: true, commit: (p) => appendCeiling(p, pts, ctx) },
];
}
// Null-Strecke (Klick auf denselben Punkt) ignorieren.
const last = s.points[s.points.length - 1];
if (segLen(last, pt) < EPS) {
return [s, { draft: ceilingDraft(s.points, s.cursor) }];
}
const points = [...s.points, pt];
const ns: CeilDrawing = { phase: "next", points, cursor: pt, lastPoint: pt };
return [ns, { draft: ceilingDraft(points, pt) }];
},
onMove: (state, point): [CommandState, CommandResult] => {
const s = state as CeilState;
if (s.phase !== "next") return [s, { draft: null }];
const ns: CeilDrawing = { ...s, cursor: point };
return [ns, { draft: ceilingDraft(s.points, point) }];
},
// Enter/Space/Rechtsklick: Umriss schließen + committen (≥3 Punkte).
onConfirm: (state, ctx): [CommandState, CommandResult] => {
const s = state as CeilState;
if (s.phase === "next" && s.points.length >= 3) {
const pts = s.points;
return [
{ phase: "start", lastPoint: null } as CeilIdle,
{ draft: null, done: true, commit: (p) => appendCeiling(p, pts, ctx) },
];
}
return idle();
},
onCancel: (): [CommandState, CommandResult] => idle(),
// Tab-Feld-Zyklus nur im „next"-Schritt: Länge + Winkel relativ zum letzten Punkt.
fields: (state) => ((state as CeilState).phase === "next" ? CEIL_FIELDS : []),
pointFromFields: (state, locks, cursor) => {
const s = state as CeilState;
if (s.phase !== "next" || s.points.length === 0) return null;
return nextFromFields(s.points[s.points.length - 1], locks, cursor);
},
fieldValues: (state, _locks, cursor): Record<string, number> => {
const s = state as CeilState;
if (s.phase !== "next" || s.points.length === 0 || !cursor) return {};
const last = s.points[s.points.length - 1];
return {
length: segLen(last, cursor),
angle: ((segAngleDeg(last, cursor) % 360) + 360) % 360,
};
},
};
+5
View File
@@ -133,6 +133,11 @@ export const circleCommand: Command = {
// Tab-Feld-Zyklus nur im „radius"-Schritt: ein Radius-Feld; der gelockte Wert
// committet direkt (Punkt liegt auf dem Kreis, dist(center,point)=radius).
fields: (state) => ((state as CircleState).phase === "radius" ? CIRCLE_FIELDS : []),
fieldValues: (state, _locks, cursor): Record<string, number> => {
const s = state as CircleState;
if (s.phase !== "radius" || !cursor) return {};
return { radius: dist(s.center, cursor) };
},
pointFromFields: (state, locks, cursor) => {
const s = state as CircleState;
if (s.phase !== "radius") return null;
+64
View File
@@ -0,0 +1,64 @@
// Join — verbindet die gewählten 2D-Zeichenelemente an koinzidenten Endpunkten zu
// Polylinien (geschlossen, wenn sich eine Kette schließt). Sofort-Befehl (kein
// Punkt-Picken): läuft direkt auf der bestehenden Mehrfachauswahl. So ist „Ctrl+J"
// und getipptes „join" EIN Pfad (§Task C).
//
// Wände werden nicht verbunden (2D-Editier-Operation); nicht-polyline Formen
// (circle/arc/text) reicht `joinDrawings` unverändert durch.
import { joinDrawings } from "../../editors/splitJoin";
import type { Drawing2D } from "../../model/types";
import type {
Command,
CommandContext,
CommandResult,
CommandSelection,
CommandState,
Project,
} from "../types";
/** Alle gewählten 2D-Element-IDs (Mehrfachauswahl mit Fallback aufs Einzelelement). */
function selectedDrawingIds(sel: CommandSelection): string[] {
if (sel.drawingIds && sel.drawingIds.length) return sel.drawingIds;
return sel.drawingId ? [sel.drawingId] : [];
}
/** Ersetzt die gewählten Quell-Drawings durch das Join-Ergebnis (immutabel). */
function applyJoin(project: Project, ids: string[]): Project {
const idSet = new Set(ids);
const srcs = project.drawings2d.filter((d) => idSet.has(d.id));
if (srcs.length < 2) return project; // <2 → nichts zu verbinden
const joined: Drawing2D[] = joinDrawings(srcs);
const rest = project.drawings2d.filter((d) => !idSet.has(d.id));
return { ...project, drawings2d: [...rest, ...joined] };
}
interface JoinState extends CommandState {
phase: "done";
}
const done = (): [CommandState, CommandResult] => [
{ phase: "done", lastPoint: null } as JoinState,
{ draft: null, done: true },
];
export const joinCommand: Command = {
name: "join",
labelKey: "edit.join.label",
prompt: () => "cmd.join.prompt",
accepts: () => [],
options: () => [],
init: (): JoinState => ({ phase: "done", lastPoint: null }),
// Sofort auf der Auswahl ausführen (kein Pick nötig).
autoRun: (ctx: CommandContext): CommandResult => {
const ids = selectedDrawingIds(ctx.selection);
if (ids.length < 2) return { draft: null, done: true };
return { draft: null, done: true, commit: (p) => applyJoin(p, ids) };
},
onInput: (): [CommandState, CommandResult] => done(),
onMove: (state): [CommandState, CommandResult] => [state, { draft: null }],
onConfirm: (): [CommandState, CommandResult] => done(),
onCancel: (): [CommandState, CommandResult] => done(),
};
+8
View File
@@ -137,4 +137,12 @@ export const lineCommand: Command = {
if (s.phase !== "end") return null;
return lineEndFromFields(s.a, locks, cursor);
},
fieldValues: (state, _locks, cursor): Record<string, number> => {
const s = state as LineState;
if (s.phase !== "end" || !cursor) return {};
return {
length: segLen(s.a, cursor),
angle: ((segAngleDeg(s.a, cursor) % 360) + 360) % 360,
};
},
};
+160
View File
@@ -0,0 +1,160 @@
// Mirror — spiegelt die AKTUELLE Auswahl (Wände + ein Drawing2D) an einer frei
// gewählten Achse (Rhino/Vectorworks-Spiegeln). Schritte:
// 1) „Erster Achsenpunkt:" → Punkt
// 2) „Zweiter Achsenpunkt:" → Punkt (Live-Vorschau der Spiegelung) → commit
//
// Die Auswahl kommt vorab über ctx.selection (erst selektieren, dann Befehl,
// wie Move/Copy). Ist sie leer, ist nichts zu spiegeln: kurzer No-op mit Hinweis
// („Nichts gewählt") und Beenden.
//
// Der Commit nutzt den bestehenden, getesteten `commitTransform` (op:"mirror",
// mode:"copy") — er spiegelt Wände UND alle 2D-Formen immutabel an der Achse und
// hängt das Spiegelbild als KOPIE an (das Original bleibt stehen). Das ist das
// klassische Mirror „Kopie spiegeln" (Rhino/Vectorworks-Default).
import { commitTransform } from "../../tools/transform";
import type { TransformSelection } from "../../tools/transform";
import { transformPreview } from "../../tools/transform";
import type {
Command,
CommandResult,
CommandSelection,
CommandState,
Project,
ToolDraft,
Vec2,
} from "../types";
const segLen = (a: Vec2, b: Vec2): number => Math.hypot(b.x - a.x, b.y - a.y);
const segAngleDeg = (a: Vec2, b: Vec2): number =>
(Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI;
const EPS = 1e-6;
/** Hat die Auswahl überhaupt etwas Spiegelbares? */
function hasSelection(sel: CommandSelection): boolean {
return sel.wallIds.length > 0 || sel.drawingId !== null;
}
/** Auswahl → TransformSelection (gleiche Form). */
function toTransformSel(sel: CommandSelection): TransformSelection {
return { wallIds: sel.wallIds, drawingId: sel.drawingId };
}
// Zustand: erst ersten Achsenpunkt setzen, dann den zweiten (mit Auswahl-Snapshot).
interface MirrorFirst extends CommandState {
phase: "first";
}
interface MirrorSecond extends CommandState {
phase: "second";
sel: CommandSelection;
a: Vec2;
cursor: Vec2 | null;
}
// „done": leere Auswahl → No-op, Befehl beendet.
interface MirrorDone extends CommandState {
phase: "done";
}
type MirrorState = MirrorFirst | MirrorSecond | MirrorDone;
/** Live-Vorschau der an der Achse a→b gespiegelten Auswahl. */
function mirrorDraft(
project: Project,
sel: CommandSelection,
a: Vec2,
b: Vec2,
): ToolDraft {
// Ohne echte Achse (Null-Strecke) nur den ersten Punkt markieren.
if (segLen(a, b) < EPS) {
return { preview: [], vertices: [a] };
}
const preview = transformPreview(
project,
toTransformSel(sel),
"mirror",
[a, b],
"copy", // Spiegelbild als Kopie (Original bleibt stehen)
1,
);
return {
// Spiegelbild-Vorschau + die Achse selbst als Hilfslinie.
preview: [...preview, { kind: "line", a, b }],
// Beide Achsenpunkte als Stützpunkte (Marker).
vertices: [a, b],
hud: {
at: b,
text: `${((segAngleDeg(a, b) + 360) % 360).toFixed(0)}°`,
},
};
}
const idle = (): [CommandState, CommandResult] => [
{ phase: "done", lastPoint: null } as MirrorDone,
{ draft: null, done: true },
];
export const mirrorCommand: Command = {
name: "mirror",
labelKey: "cmd.mirror.label",
prompt: (s) => {
const ms = s as MirrorState;
if (ms.phase === "second") return "cmd.mirror.second";
if (ms.phase === "done") return "cmd.mirror.empty";
return "cmd.mirror.first";
},
accepts: () => ["point"],
options: () => [],
init: (): MirrorFirst => ({ phase: "first", lastPoint: null }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as MirrorState;
if (s.phase === "done") return idle();
// Leere Auswahl → nichts zu spiegeln (No-op + Hinweis, dann beenden).
if (!hasSelection(ctx.selection)) {
return [{ phase: "done", lastPoint: null } as MirrorDone, { draft: null, done: true }];
}
if (input.kind !== "point") {
if (s.phase === "second") {
return [s, { draft: s.cursor ? mirrorDraft(ctx.project, s.sel, s.a, s.cursor) : null }];
}
return [s, { draft: null }];
}
const pt = input.point;
if (s.phase !== "second") {
// Erster Achsenpunkt gesetzt → Auswahl-Snapshot mitführen.
const next: MirrorSecond = {
phase: "second",
sel: ctx.selection,
a: pt,
cursor: pt,
lastPoint: pt,
};
return [next, { draft: mirrorDraft(ctx.project, next.sel, pt, pt) }];
}
// Zweiter Achsenpunkt → commit (Auswahl an der Achse spiegeln). Null-Achse
// verwerfen (kein Spiegeln ohne Richtung).
if (segLen(s.a, pt) < EPS) {
return [{ phase: "done", lastPoint: null } as MirrorDone, { draft: null, done: true }];
}
const a = s.a;
const sel = s.sel;
return [
{ phase: "done", lastPoint: null } as MirrorDone,
{
draft: null,
done: true,
commit: (p) => commitTransform(p, toTransformSel(sel), "mirror", [a, pt], "copy", 1),
},
];
},
onMove: (state, point, _snap, ctx): [CommandState, CommandResult] => {
const s = state as MirrorState;
if (s.phase !== "second") return [s, { draft: null }];
const ns: MirrorSecond = { ...s, cursor: point };
return [ns, { draft: mirrorDraft(ctx.project, s.sel, s.a, point) }];
},
onConfirm: (): [CommandState, CommandResult] => idle(),
onCancel: (): [CommandState, CommandResult] => idle(),
};
+368
View File
@@ -0,0 +1,368 @@
// Opening — Öffnung (Fenster/Tür) als Engine-Befehl. Sie wird in eine BESTEHENDE
// Wand gehostet. Ablauf:
// 1) „Wand für die Öffnung wählen:" → Klick auf/nahe eine Wand (Host).
// 2) „Position entlang der Wand:" → Klick auf der Wandachse ODER getippter
// Abstand vom Wand-Startpunkt (Zahl). Die Position wird auf die Achse
// projiziert; der HUD zeigt den Abstand vom Wandanfang.
// 3) Commit: hängt eine `Opening` ans Projekt.
//
// Tab-Felder im Positionsschritt: Breite / Höhe / Brüstung (sillHeight) /
// Schwenkwinkel (nur Tür). Optionen: Fenster⇄Tür-Umschalter, Anschlag
// (Scharnier start/end), Aufschlagseite (links/rechts), Richtung (innen/außen).
//
// Bezeichner englisch, sichtbarer Text via t() (CONVENTIONS.md).
import type { Opening } from "../../model/types";
import { uniqueId } from "../../tools/types";
import type {
Command,
CommandContext,
CommandField,
CommandResult,
CommandState,
CmdOption,
DraftShape,
Project,
ToolDraft,
Vec2,
} from "../types";
/** Default-Ebene (Kategorie) für Öffnungen: „21" Türen/Fenster (Fallback: aktive). */
const OPENING_CATEGORY = "21";
/** Toleranz (Modell-Meter) für das Picken einer Host-Wand per Klick. */
const WALL_PICK_DIST = 0.6;
/** Default-Maße (Meter). */
const DEF_WINDOW = { width: 1.2, height: 1.2, sill: 0.9 };
const DEF_DOOR = { width: 0.9, height: 2.1, sill: 0 };
const sub = (a: Vec2, b: Vec2): Vec2 => ({ x: a.x - b.x, y: a.y - b.y });
const dot = (a: Vec2, b: Vec2): number => a.x * b.x + a.y * b.y;
const add = (a: Vec2, b: Vec2): Vec2 => ({ x: a.x + b.x, y: a.y + b.y });
const scale = (a: Vec2, s: number): Vec2 => ({ x: a.x * s, y: a.y * s });
/** Existiert die Öffnungs-Kategorie („21")? Sonst Fallback auf die aktive. */
function openingCategory(ctx: CommandContext): string {
const flat: { code: string }[] = [];
const walk = (list: { code: string; children?: unknown[] }[]) => {
for (const c of list) {
flat.push({ code: c.code });
if (c.children) walk(c.children as { code: string; children?: unknown[] }[]);
}
};
walk(ctx.project.layers as { code: string; children?: unknown[] }[]);
return flat.some((c) => c.code === OPENING_CATEGORY)
? OPENING_CATEGORY
: ctx.defaultCategoryCode;
}
/** Auf die Wandachse projizierter Fußpunkt + Parameter t (0..len) + Distanz. */
function projectOntoWall(
wall: { start: Vec2; end: Vec2 },
p: Vec2,
): { t: number; foot: Vec2; dist: number } {
const ax = sub(wall.end, wall.start);
const len2 = dot(ax, ax) || 1e-9;
const raw = dot(sub(p, wall.start), ax) / len2; // 0..1
const t = Math.max(0, Math.min(1, raw));
const foot = add(wall.start, scale(ax, t));
const dist = Math.hypot(p.x - foot.x, p.y - foot.y);
return { t: t * Math.sqrt(len2), foot, dist };
}
/** Nächste Wand auf dem aktiven Geschoss zum Klickpunkt (innerhalb Toleranz). */
function pickWall(
ctx: CommandContext,
p: Vec2,
): { id: string; start: Vec2; end: Vec2 } | null {
let best: { id: string; start: Vec2; end: Vec2; d: number } | null = null;
for (const w of ctx.project.walls) {
if (w.floorId !== ctx.level.id) continue;
const { dist } = projectOntoWall(w, p);
if (dist <= WALL_PICK_DIST && (!best || dist < best.d)) {
best = { id: w.id, start: w.start, end: w.end, d: dist };
}
}
return best ? { id: best.id, start: best.start, end: best.end } : null;
}
/** Tab-Felder im Positionsschritt: Breite/Höhe/Brüstung (+ Winkel bei Tür). */
const BASE_FIELDS: CommandField[] = [
{ id: "width", labelKey: "cmd.field.width" },
{ id: "height", labelKey: "cmd.field.height" },
{ id: "sill", labelKey: "cmd.opening.sillField" },
];
const SWING_FIELD: CommandField = { id: "swing", labelKey: "cmd.opening.swingField" };
const KIND: CmdOption = { id: "kind", labelKey: "cmd.opening.kind", value: "window" };
const HINGE: CmdOption = { id: "hinge", labelKey: "cmd.opening.hinge", value: "start" };
const SIDE: CmdOption = { id: "side", labelKey: "cmd.opening.side", value: "left" };
const DIR: CmdOption = { id: "dir", labelKey: "cmd.opening.dir", value: "in" };
interface OpIdle extends CommandState {
phase: "wall";
kind: "window" | "door";
hinge: "start" | "end";
side: "left" | "right";
dir: "in" | "out";
width: number;
height: number;
sill: number;
swingAngle: number;
}
interface OpPos extends CommandState {
phase: "pos";
wallId: string;
start: Vec2;
end: Vec2;
cursor: Vec2 | null;
posAlong: number; // Abstand vom Wandanfang (Meter)
kind: "window" | "door";
hinge: "start" | "end";
side: "left" | "right";
dir: "in" | "out";
width: number;
height: number;
sill: number;
swingAngle: number;
}
type OpState = OpIdle | OpPos;
function initState(kind: "window" | "door" = "window"): OpIdle {
const def = kind === "door" ? DEF_DOOR : DEF_WINDOW;
return {
phase: "wall",
lastPoint: null,
kind,
hinge: "start",
side: "left",
dir: "in",
width: def.width,
height: def.height,
sill: def.sill,
swingAngle: 90,
};
}
/** Vorschau: Lücken-Balken entlang der Wand + Positionsmarke. */
function posDraft(s: OpPos): ToolDraft {
const ax = sub(s.end, s.start);
const len = Math.hypot(ax.x, ax.y) || 1e-9;
const u = { x: ax.x / len, y: ax.y / len };
const n = { x: -u.y, y: u.x };
const from = Math.max(0, Math.min(s.posAlong, len));
const to = Math.max(from, Math.min(from + s.width, len));
const a = add(s.start, scale(u, from));
const b = add(s.start, scale(u, to));
const half = 0.15; // Vorschau-Balken quer zur Wand
const quad: Vec2[] = [
add(a, scale(n, half)),
add(b, scale(n, half)),
add(b, scale(n, -half)),
add(a, scale(n, -half)),
];
const preview: DraftShape[] = [{ kind: "poly", pts: quad, closed: true }];
// Symbol-Andeutung: Fenster = Mittellinie, Tür = Blatt-Linie in Schwenkrichtung.
if (s.kind === "window") {
preview.push({ kind: "line", a, b });
} else {
const swingSign = s.side === "left" ? 1 : -1;
const dirSign = s.dir === "in" ? 1 : -1;
const hinge = s.hinge === "start" ? a : b;
const leafEnd = add(hinge, scale(n, swingSign * dirSign * s.width));
preview.push({ kind: "line", a: hinge, b: leafEnd });
}
const draft: ToolDraft = { preview, vertices: [a, b] };
const mid = add(a, scale(u, (to - from) / 2));
draft.hud = {
at: s.cursor ?? mid,
text: `${from.toFixed(2)} m · ${s.kind === "door" ? "Tür" : "Fenster"} ${(
s.width * 100
).toFixed(0)}×${(s.height * 100).toFixed(0)}`,
};
return draft;
}
/** Hängt eine Öffnung ans Projekt (immutabel). */
function appendOpening(p: Project, s: OpPos, ctx: CommandContext): Project {
const len = Math.hypot(s.end.x - s.start.x, s.end.y - s.start.y);
const width = Math.max(0.1, Math.min(s.width, len));
const position = Math.max(0, Math.min(s.posAlong, Math.max(0, len - width)));
const opening: Opening = {
id: uniqueId("O"),
type: "opening",
hostWallId: s.wallId,
categoryCode: openingCategory(ctx),
kind: s.kind,
position,
width,
height: s.height,
sillHeight: s.kind === "door" ? 0 : s.sill,
...(s.kind === "door"
? { hinge: s.hinge, swing: s.side, openingDir: s.dir, swingAngle: s.swingAngle }
: {}),
};
const openings = p.openings ? [...p.openings, opening] : [opening];
return { ...p, openings };
}
const idle = (from?: OpState): [CommandState, CommandResult] => [
initState(from?.kind ?? "window"),
{ draft: null, done: true },
];
export const openingCommand: Command = {
name: "opening",
labelKey: "cmd.opening.label",
floorOnly: true,
prompt: (s) =>
(s as OpState).phase === "pos" ? "cmd.opening.pos" : "cmd.opening.wall",
accepts: (s) =>
(s as OpState).phase === "pos"
? ["point", "number", "option"]
: ["point", "option"],
options: (s): CmdOption[] => {
const st = s as OpState;
const kind: CmdOption = { ...KIND, value: st.kind };
if (st.kind === "door") {
return [
kind,
{ ...HINGE, value: st.hinge },
{ ...SIDE, value: st.side },
{ ...DIR, value: st.dir },
];
}
return [kind];
},
init: (): OpIdle => initState("window"),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as OpState;
if (input.kind === "option") {
// Umschalter zyklen ihren Wert. Bei kind-Wechsel Default-Maße übernehmen.
const next = { ...s } as OpState;
if (input.id === "kind") {
next.kind = s.kind === "window" ? "door" : "window";
const def = next.kind === "door" ? DEF_DOOR : DEF_WINDOW;
next.width = def.width;
next.height = def.height;
next.sill = def.sill;
} else if (input.id === "hinge") {
next.hinge = s.hinge === "start" ? "end" : "start";
} else if (input.id === "side") {
next.side = s.side === "left" ? "right" : "left";
} else if (input.id === "dir") {
next.dir = s.dir === "in" ? "out" : "in";
}
return [next, { draft: next.phase === "pos" ? posDraft(next as OpPos) : null }];
}
if (input.kind === "number") {
// Im Positionsschritt: getippte Zahl = Abstand vom Wandanfang (Meter).
if (s.phase === "pos") {
const ns: OpPos = { ...s, posAlong: input.value };
return [ns, { draft: posDraft(ns) }];
}
return [s, { draft: null }];
}
if (input.kind !== "point") {
return [s, { draft: s.phase === "pos" ? posDraft(s as OpPos) : null }];
}
const pt = input.point;
if (s.phase === "wall") {
const w = pickWall(ctx, pt);
if (!w) return [s, { draft: null }]; // kein Treffer: Schritt wiederholen
const proj = projectOntoWall(w, pt);
const ns: OpPos = {
phase: "pos",
lastPoint: pt,
wallId: w.id,
start: w.start,
end: w.end,
cursor: pt,
posAlong: proj.t,
kind: s.kind,
hinge: s.hinge,
side: s.side,
dir: s.dir,
width: s.width,
height: s.height,
sill: s.sill,
swingAngle: s.swingAngle,
};
return [ns, { draft: posDraft(ns) }];
}
// Positionsschritt: Klick projiziert auf die Achse → committen.
const proj = projectOntoWall(s, pt);
const ns: OpPos = { ...s, posAlong: proj.t, cursor: pt };
return [
idle(s)[0],
{ draft: null, done: true, commit: (p) => appendOpening(p, ns, ctx) },
];
},
onMove: (state, point): [CommandState, CommandResult] => {
const s = state as OpState;
if (s.phase !== "pos") return [s, { draft: null }];
const proj = projectOntoWall(s, point);
const ns: OpPos = { ...s, posAlong: proj.t, cursor: point };
return [ns, { draft: posDraft(ns) }];
},
onConfirm: (state, ctx): [CommandState, CommandResult] => {
const s = state as OpState;
if (s.phase === "pos") {
const ns = s;
return [
idle(s)[0],
{ draft: null, done: true, commit: (p) => appendOpening(p, ns, ctx) },
];
}
return idle(s);
},
onCancel: (state): [CommandState, CommandResult] => idle(state as OpState),
fields: (state) => {
const s = state as OpState;
if (s.phase !== "pos") return [];
return s.kind === "door" ? [...BASE_FIELDS, SWING_FIELD] : BASE_FIELDS;
},
// Getippte Tab-Felder sticky in den State übernehmen; kein Koordinatenpunkt.
pointFromFields: (state, locks) => {
const s = state as OpState;
if ("width" in locks && locks.width > 0) s.width = locks.width;
if ("height" in locks && locks.height > 0) s.height = locks.height;
if ("sill" in locks && locks.sill >= 0) s.sill = locks.sill;
if ("swing" in locks && locks.swing > 0) s.swingAngle = locks.swing;
return null;
},
fieldValues: (state): Record<string, number> => {
const s = state as OpState;
const out: Record<string, number> = {
width: s.width,
height: s.height,
sill: s.sill,
};
if (s.kind === "door") out.swing = s.swingAngle;
return out;
},
};
/** „Fenster"-Variante: identischer Befehl, startet aber im Fenster-Modus. */
export const fensterCommand: Command = {
...openingCommand,
name: "fenster",
labelKey: "cmd.opening.windowLabel",
init: (): OpIdle => initState("window"),
};
/** „Tür"-Variante: identischer Befehl, startet aber im Tür-Modus. */
export const tuerCommand: Command = {
...openingCommand,
name: "tuer",
labelKey: "cmd.opening.doorLabel",
init: (): OpIdle => initState("door"),
};
+68 -27
View File
@@ -44,21 +44,34 @@ function polyNextFromFields(last: Vec2, locks: Record<string, number>, cursor: V
interface PolyIdle extends CommandState {
phase: "start";
/** Gewählter Schließ-Modus (bleibt über den Befehl hinweg erhalten). */
closed: boolean;
}
interface PolyDrawing extends CommandState {
phase: "next";
points: Vec2[];
cursor: Vec2 | null;
/** Soll der committete Zug als Ring (geschlossen) gelten? Default: offen. */
closed: boolean;
}
type PolyState = PolyIdle | PolyDrawing;
/** Sofort-schließen-Aktion (nur ≥3 Punkte): committet als Ring und beendet. */
const CLOSE: CmdOption = { id: "close", labelKey: "cmd.polyline.close" };
const UNDO: CmdOption = { id: "undo", labelKey: "cmd.polyline.undo" };
/** Toggle-Option: Ergebnis geschlossen (Ring) oder offen — im Feld-Row sichtbar. */
const closedOption = (closed: boolean): CmdOption => ({
id: "mode",
labelKey: "cmd.polyline.closedOpt",
value: closed ? "on" : "off",
});
/** Vorschau (Zug + Gummiband zum Cursor) + HUD (Länge·Winkel des letzten Segments). */
function polyDraft(points: Vec2[], cursor: Vec2 | null): ToolDraft {
function polyDraft(points: Vec2[], cursor: Vec2 | null, closed = false): ToolDraft {
const pts = cursor ? [...points, cursor] : points;
const preview: DraftShape[] = [{ kind: "poly", pts, closed: false }];
// Ring nur ab 3 Punkten zeigen; darunter bleibt die Vorschau offen.
const showClosed = closed && pts.length >= 3;
const preview: DraftShape[] = [{ kind: "poly", pts, closed: showClosed }];
const draft: ToolDraft = { preview, vertices: points };
const last = points[points.length - 1];
if (cursor && last && segLen(last, cursor) >= EPS) {
@@ -89,8 +102,9 @@ function appendPolyline(
return { ...p, drawings2d: [...p.drawings2d, d] };
}
const idle = (): [CommandState, CommandResult] => [
{ phase: "start", lastPoint: null },
/** Ruhezustand; behält den zuletzt gewählten Schließ-Modus bei. */
const idle = (closed = false): [CommandState, CommandResult] => [
{ phase: "start", lastPoint: null, closed } as PolyIdle,
{ draft: null, done: true },
];
@@ -98,77 +112,95 @@ export const polylineCommand: Command = {
name: "polyline",
labelKey: "cmd.polyline.label",
prompt: (s) => ((s as PolyState).phase === "next" ? "cmd.polyline.next" : "cmd.polyline.start"),
accepts: (s) =>
(s as PolyState).phase === "next" ? ["point", "number", "option"] : ["point", "number"],
// Auch im Startschritt darf der Schließ-Modus gewählt werden.
accepts: () => ["point", "number", "option"],
options: (s) => {
const ps = s as PolyState;
if (ps.phase !== "next") return [];
return ps.points.length >= 2 ? [CLOSE, UNDO] : [UNDO];
const toggle = closedOption(ps.closed);
if (ps.phase !== "next") return [toggle];
return ps.points.length >= 2 ? [CLOSE, toggle, UNDO] : [toggle, UNDO];
},
init: (): PolyIdle => ({ phase: "start", lastPoint: null }),
init: (): PolyIdle => ({ phase: "start", lastPoint: null, closed: false }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as PolyState;
if (input.kind === "option") {
// Schließ-Modus umschalten (Tastenkürzel „C" bzw. Klick im Feld-Row);
// in jedem Schritt verfügbar, ohne den laufenden Zug zu beenden.
if (input.id === "mode") {
const ns = { ...s, closed: !s.closed } as PolyState;
return [ns, { draft: s.phase === "next" ? polyDraft(s.points, s.cursor, ns.closed) : null }];
}
if (s.phase !== "next") return [s, { draft: null }];
if (input.id === "undo") {
const pts = s.points.slice(0, -1);
if (pts.length === 0) return [{ phase: "start", lastPoint: null }, { draft: null }];
const ns: PolyDrawing = { phase: "next", points: pts, cursor: s.cursor, lastPoint: pts[pts.length - 1] };
return [ns, { draft: polyDraft(pts, s.cursor) }];
if (pts.length === 0)
return [{ phase: "start", lastPoint: null, closed: s.closed } as PolyIdle, { draft: null }];
const ns: PolyDrawing = {
phase: "next",
points: pts,
cursor: s.cursor,
lastPoint: pts[pts.length - 1],
closed: s.closed,
};
return [ns, { draft: polyDraft(pts, s.cursor, s.closed) }];
}
// Sofort schließen: committet als Ring (unabhängig vom Toggle-Zustand).
if (input.id === "close" && s.points.length >= 3) {
const pts = s.points;
return [
{ phase: "start", lastPoint: null },
{ phase: "start", lastPoint: null, closed: s.closed } as PolyIdle,
{ draft: null, done: true, commit: (p) => appendPolyline(p, pts, true, ctx) },
];
}
return [s, { draft: polyDraft(s.points, s.cursor) }];
return [s, { draft: polyDraft(s.points, s.cursor, s.closed) }];
}
if (input.kind !== "point") {
return [s, { draft: s.phase === "next" ? polyDraft(s.points, s.cursor) : null }];
return [s, { draft: s.phase === "next" ? polyDraft(s.points, s.cursor, s.closed) : null }];
}
const pt = input.point;
if (s.phase !== "next") {
const ns: PolyDrawing = { phase: "next", points: [pt], cursor: pt, lastPoint: pt };
return [ns, { draft: polyDraft([pt], pt) }];
const ns: PolyDrawing = { phase: "next", points: [pt], cursor: pt, lastPoint: pt, closed: s.closed };
return [ns, { draft: polyDraft([pt], pt, s.closed) }];
}
// Klick nahe Startpunkt → schließen.
// Klick nahe Startpunkt → schließen (committet als Ring).
if (s.points.length >= 3 && segLen(s.points[0], pt) < CLOSE_HIT) {
const pts = s.points;
return [
{ phase: "start", lastPoint: null },
{ phase: "start", lastPoint: null, closed: s.closed } as PolyIdle,
{ draft: null, done: true, commit: (p) => appendPolyline(p, pts, true, ctx) },
];
}
const points = [...s.points, pt];
const ns: PolyDrawing = { phase: "next", points, cursor: pt, lastPoint: pt };
return [ns, { draft: polyDraft(points, pt) }];
const ns: PolyDrawing = { phase: "next", points, cursor: pt, lastPoint: pt, closed: s.closed };
return [ns, { draft: polyDraft(points, pt, s.closed) }];
},
onMove: (state, point): [CommandState, CommandResult] => {
const s = state as PolyState;
if (s.phase !== "next") return [s, { draft: null }];
const ns: PolyDrawing = { ...s, cursor: point };
return [ns, { draft: polyDraft(s.points, point) }];
return [ns, { draft: polyDraft(s.points, point, s.closed) }];
},
// Enter/Space/Rechtsklick: offenen Zug beenden (≥2 Punkte) und committen.
// Enter/Space/Rechtsklick: Zug beenden (≥2 Punkte). Der Schließ-Modus des
// Toggles bestimmt, ob als Ring oder offen committet wird. Ein Ring braucht
// ≥3 Punkte, sonst fällt er auf „offen" zurück.
onConfirm: (state, ctx): [CommandState, CommandResult] => {
const s = state as PolyState;
if (s.phase === "next" && s.points.length >= 2) {
const pts = s.points;
const close = s.closed && pts.length >= 3;
return [
{ phase: "start", lastPoint: null },
{ draft: null, done: true, commit: (p) => appendPolyline(p, pts, false, ctx) },
{ phase: "start", lastPoint: null, closed: s.closed } as PolyIdle,
{ draft: null, done: true, commit: (p) => appendPolyline(p, pts, close, ctx) },
];
}
return idle();
return idle(s.closed);
},
onCancel: (): [CommandState, CommandResult] => idle(),
onCancel: (state): [CommandState, CommandResult] => idle((state as PolyState).closed),
// Tab-Feld-Zyklus nur im „next"-Schritt: Länge + Winkel relativ zum letzten
// Punkt. (Erster Punkt = freie Koordinate, kein Feld-Modus.)
@@ -178,4 +210,13 @@ export const polylineCommand: Command = {
if (s.phase !== "next" || s.points.length === 0) return null;
return polyNextFromFields(s.points[s.points.length - 1], locks, cursor);
},
fieldValues: (state, _locks, cursor): Record<string, number> => {
const s = state as PolyState;
if (s.phase !== "next" || s.points.length === 0 || !cursor) return {};
const last = s.points[s.points.length - 1];
return {
length: segLen(last, cursor),
angle: ((segAngleDeg(last, cursor) % 360) + 360) % 360,
};
},
};
+330 -49
View File
@@ -1,9 +1,20 @@
// Rectangle — zwei Ecken (portiert rectTool). Schritte:
// 1) „Erste Ecke:" → Punkt
// 2) „Gegenüberliegende Ecke:" → Punkt → commit Drawing2D rect → done
// Rectangle — mehrere Konstruktionsmethoden (portiert + erweitert rectTool).
// Wählbar über die Option „Methode" (klickbar im Feld-Row, tippbar als „m",
// zyklisch): 2-Punkt · 3-Punkt · Zentrum.
//
// Getippte Maße: die zweite Ecke kann als `r<breite>,<höhe>` relativ zur ersten
// eingegeben werden (die Engine löst `r…` relativ zu lastPoint auf).
// • 2-Punkt (Default): zwei gegenüberliegende Ecken, achsparallel.
// 1) „Erste Ecke:" → Punkt
// 2) „Gegenüberliegende Ecke:" → Punkt → commit (shape:"rect")
// • 3-Punkt (gedreht): Basiskante + Höhe → beliebig gedrehtes Rechteck.
// 1) „Erste Ecke:" → Punkt (Basis-Start)
// 2) „Zweite Ecke:" → Punkt (Basis-Ende, definiert Winkel+Breite)
// 3) „Höhe:" → Punkt → commit (geschlossene Polylinie)
// • Zentrum: Mittelpunkt + Ecke, achsparallel.
// 1) „Mittelpunkt:" → Punkt
// 2) „Ecke:" → Punkt → commit (shape:"rect")
//
// Getippte Maße: die zweite/dritte Ecke kann relativ (`r<dx>,<dy>`) eingegeben
// werden (die Engine löst `r…` relativ zu lastPoint auf).
import type { Drawing2D } from "../../model/types";
import { uniqueId } from "../../tools/types";
@@ -13,19 +24,50 @@ import type {
CommandField,
CommandResult,
CommandState,
CmdOption,
DraftShape,
Project,
ToolDraft,
Vec2,
} from "../types";
import type { TranslationKey } from "../../i18n";
const EPS = 1e-6;
/** Tab-Felder des zweiten-Ecke-Schritts: Breite, Höhe. */
/** Konstruktionsmethode. */
type RectMethod = "corner" | "three" | "center";
const METHOD_ORDER: RectMethod[] = ["corner", "three", "center"];
const nextMethod = (m: RectMethod): RectMethod =>
METHOD_ORDER[(METHOD_ORDER.indexOf(m) + 1) % METHOD_ORDER.length];
const methodValue = (m: RectMethod): string =>
m === "corner" ? "2pt" : m === "three" ? "3pt" : "center";
/** Umschalt-Option für die Methode (im Feld-Row sichtbar, tippbar als „m"). */
const methodOption = (m: RectMethod): CmdOption => ({
id: "method",
labelKey: "cmd.rect.method",
value: methodValue(m),
});
/** Tab-Felder des Breite/Höhe-Schritts (2-Punkt & Zentrum). */
const RECT_FIELDS: CommandField[] = [
{ id: "width", labelKey: "cmd.field.width" },
{ id: "height", labelKey: "cmd.field.height" },
];
/** Tab-Felder des Basis-Schritts (3-Punkt): Länge + Winkel der Basiskante. */
const BASE_FIELDS: CommandField[] = [
{ id: "length", labelKey: "cmd.field.length" },
{ id: "angle", labelKey: "cmd.field.angle" },
];
/** Tab-Feld des Höhen-Schritts (3-Punkt): senkrechter Abstand. */
const RISE_FIELDS: CommandField[] = [{ id: "height", labelKey: "cmd.field.height" }];
const DEG = Math.PI / 180;
const segLen = (a: Vec2, b: Vec2): number => Math.hypot(b.x - a.x, b.y - a.y);
const segAngleDeg = (a: Vec2, b: Vec2): number =>
(Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI;
// ── 2-Punkt / Zentrum: achsparallele Ableitung ───────────────────────────────
/**
* Gegenüberliegende Ecke aus gelockten Feldern (width/height) + Cursor: eine
@@ -43,15 +85,107 @@ function rectCornerFromFields(a: Vec2, locks: Record<string, number>, cursor: Ve
return { x: a.x + w, y: a.y + h };
}
// ── 3-Punkt: Basiskante + Höhe ───────────────────────────────────────────────
/** Basis-Endpunkt aus gelockten Feldern (length/angle) + Cursor, relativ zu `a`. */
function baseFromFields(a: Vec2, locks: Record<string, number>, cursor: Vec2 | null): Vec2 {
const ref = cursor ?? a;
const length = "length" in locks ? locks.length : segLen(a, ref);
const angleDeg = "angle" in locks ? locks.angle : segAngleDeg(a, ref);
const ang = angleDeg * DEG;
return { x: a.x + Math.cos(ang) * length, y: a.y + Math.sin(ang) * length };
}
/**
* Vier Ecken des gedrehten Rechtecks aus Basiskante (a→b) und einem dritten
* Punkt `p`: die Höhe ist der vorzeichenbehaftete senkrechte Abstand von `p` zur
* Basislinie; das Rechteck wird zu dieser Seite hin aufgespannt.
*/
function rotatedCorners(a: Vec2, b: Vec2, p: Vec2): Vec2[] {
const dx = b.x - a.x;
const dy = b.y - a.y;
const len = Math.hypot(dx, dy);
if (len < EPS) return [a, b, b, a];
// Einheits-Normale (links der Basisrichtung).
const nx = -dy / len;
const ny = dx / len;
const h = (p.x - a.x) * nx + (p.y - a.y) * ny; // signierte Höhe
const c3 = { x: b.x + nx * h, y: b.y + ny * h };
const c4 = { x: a.x + nx * h, y: a.y + ny * h };
return [a, b, c3, c4];
}
/** Senkrechter (signierter) Abstand eines Punktes zur Basislinie a→b. */
function riseFrom(a: Vec2, b: Vec2, p: Vec2): number {
const dx = b.x - a.x;
const dy = b.y - a.y;
const len = Math.hypot(dx, dy);
if (len < EPS) return 0;
return (p.x - a.x) * (-dy / len) + (p.y - a.y) * (dx / len);
}
/** Dritten Punkt aus gelockter Höhe rekonstruieren (auf der Cursor-Seite). */
function riseToPoint(a: Vec2, b: Vec2, height: number, cursor: Vec2 | null): Vec2 {
const dx = b.x - a.x;
const dy = b.y - a.y;
const len = Math.hypot(dx, dy);
if (len < EPS) return cursor ?? b;
const nx = -dy / len;
const ny = dx / len;
const sign = cursor && riseFrom(a, b, cursor) < 0 ? -1 : 1;
const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
const h = sign * Math.abs(height);
return { x: mid.x + nx * h, y: mid.y + ny * h };
}
// ── Zentrum: Ecke aus Mittelpunkt ────────────────────────────────────────────
/** Achsparallele Ecken aus Zentrum + einer Ecke. */
function centerGeom(center: Vec2, corner: Vec2): { min: Vec2; max: Vec2 } {
const hx = Math.abs(corner.x - center.x);
const hy = Math.abs(corner.y - center.y);
return {
min: { x: center.x - hx, y: center.y - hy },
max: { x: center.x + hx, y: center.y + hy },
};
}
/** Ecke aus Zentrum + gelockten Halbmaßen (width/height als volle Kantenmaße). */
function centerCornerFromFields(
center: Vec2,
locks: Record<string, number>,
cursor: Vec2 | null,
): Vec2 {
const cur = cursor ?? center;
const dx = cur.x - center.x;
const dy = cur.y - center.y;
const sx = dx < 0 ? -1 : 1;
const sy = dy < 0 ? -1 : 1;
const hx = "width" in locks ? Math.abs(locks.width) / 2 : Math.abs(dx);
const hy = "height" in locks ? Math.abs(locks.height) / 2 : Math.abs(dy);
return { x: center.x + sx * hx, y: center.y + sy * hy };
}
// ── Zustand ──────────────────────────────────────────────────────────────────
interface RectIdle extends CommandState {
phase: "corner1";
method: RectMethod;
}
interface RectDrawing extends CommandState {
interface RectCorner2 extends CommandState {
phase: "corner2";
method: RectMethod;
a: Vec2;
cursor: Vec2 | null;
}
type RectState = RectIdle | RectDrawing;
interface RectRise extends CommandState {
phase: "rise";
method: "three";
a: Vec2;
b: Vec2;
cursor: Vec2 | null;
}
type RectState = RectIdle | RectCorner2 | RectRise;
function rectGeom(a: Vec2, b: Vec2): { min: Vec2; max: Vec2 } {
return {
@@ -60,84 +194,231 @@ function rectGeom(a: Vec2, b: Vec2): { min: Vec2; max: Vec2 } {
};
}
/** Vorschau (geschlossenes Rechteck) + HUD (Breite × Höhe). */
function rectDraft(a: Vec2, cursor: Vec2 | null): ToolDraft {
if (!cursor) return { preview: [], vertices: [a] };
const g = rectGeom(a, cursor);
const pts: Vec2[] = [
g.min,
{ x: g.max.x, y: g.min.y },
g.max,
{ x: g.min.x, y: g.max.y },
];
// ── Vorschau ─────────────────────────────────────────────────────────────────
function corners4Draft(pts: Vec2[], hudAt: Vec2 | null, hudText: string): ToolDraft {
const preview: DraftShape[] = [{ kind: "poly", pts, closed: true }];
const w = g.max.x - g.min.x;
const h = g.max.y - g.min.y;
return {
preview,
vertices: [a],
hud: { at: cursor, text: `${w.toFixed(2)} × ${h.toFixed(2)} m` },
};
const draft: ToolDraft = { preview, vertices: [pts[0]] };
if (hudAt) draft.hud = { at: hudAt, text: hudText };
return draft;
}
function appendRect(p: Project, a: Vec2, b: Vec2, ctx: CommandContext): Project {
const g = rectGeom(a, b);
if (g.max.x - g.min.x < EPS || g.max.y - g.min.y < EPS) return p; // Null-Rechteck
/** Vorschau des 2-Punkt-Rechtecks (achsparallel). */
function cornerDraft(a: Vec2, cursor: Vec2 | null): ToolDraft {
if (!cursor) return { preview: [], vertices: [a] };
const g = rectGeom(a, cursor);
const pts: Vec2[] = [g.min, { x: g.max.x, y: g.min.y }, g.max, { x: g.min.x, y: g.max.y }];
const w = g.max.x - g.min.x;
const h = g.max.y - g.min.y;
return corners4Draft(pts, cursor, `${w.toFixed(2)} × ${h.toFixed(2)} m`);
}
/** Vorschau des Zentrum-Rechtecks (achsparallel). */
function centerDraft(center: Vec2, cursor: Vec2 | null): ToolDraft {
if (!cursor) return { preview: [], vertices: [center] };
const g = centerGeom(center, cursor);
const pts: Vec2[] = [g.min, { x: g.max.x, y: g.min.y }, g.max, { x: g.min.x, y: g.max.y }];
const w = g.max.x - g.min.x;
const h = g.max.y - g.min.y;
return corners4Draft(pts, cursor, `${w.toFixed(2)} × ${h.toFixed(2)} m`);
}
/** Vorschau der Basiskante (3-Punkt, erster Teil). */
function baseDraft(a: Vec2, cursor: Vec2 | null): ToolDraft {
if (!cursor || segLen(a, cursor) < EPS) return { preview: [{ kind: "line", a, b: a }], vertices: [a] };
const draft: ToolDraft = { preview: [{ kind: "line", a, b: cursor }], vertices: [a] };
draft.hud = {
at: cursor,
text: `${segLen(a, cursor).toFixed(2)} m · ${Math.abs((segAngleDeg(a, cursor) + 360) % 360).toFixed(0)}°`,
};
return draft;
}
/** Vorschau des gedrehten Rechtecks (3-Punkt, zweiter Teil). */
function riseDraft(a: Vec2, b: Vec2, cursor: Vec2 | null): ToolDraft {
if (!cursor) return { preview: [{ kind: "line", a, b }], vertices: [a] };
const pts = rotatedCorners(a, b, cursor);
const w = segLen(a, b);
const h = Math.abs(riseFrom(a, b, cursor));
return corners4Draft(pts, cursor, `${w.toFixed(2)} × ${h.toFixed(2)} m`);
}
// ── Commit ───────────────────────────────────────────────────────────────────
/** Achsparalleles Rechteck als `shape:"rect"` (geschlossen behandelt). */
function appendRect(p: Project, min: Vec2, max: Vec2, ctx: CommandContext): Project {
if (max.x - min.x < EPS || max.y - min.y < EPS) return p; // Null-Rechteck
const d: Drawing2D = {
id: uniqueId("dr2d"),
type: "drawing2d",
levelId: ctx.level.id,
categoryCode: ctx.defaultCategoryCode,
geom: { shape: "rect", min: g.min, max: g.max },
geom: { shape: "rect", min, max },
};
return { ...p, drawings2d: [...p.drawings2d, d] };
}
const idle = (): [CommandState, CommandResult] => [
{ phase: "corner1", lastPoint: null },
/**
* Gedrehtes Rechteck als geschlossene Polylinie (`shape:"polyline", closed:true`)
* — `shape:"rect"` kann nur achsparallel, deshalb hier ein Ring aus vier Ecken.
* Downstream (Füllung/Offset) behandelt einen geschlossenen Polygonzug als Ring.
*/
function appendRotated(p: Project, pts: Vec2[], ctx: CommandContext): Project {
// Fläche prüfen: entartete Rechtecke verwerfen.
const w = segLen(pts[0], pts[1]);
const h = segLen(pts[1], pts[2]);
if (w < EPS || h < EPS) return p;
const d: Drawing2D = {
id: uniqueId("dr2d"),
type: "drawing2d",
levelId: ctx.level.id,
categoryCode: ctx.defaultCategoryCode,
geom: { shape: "polyline", pts, closed: true },
};
return { ...p, drawings2d: [...p.drawings2d, d] };
}
const idle = (method: RectMethod): [CommandState, CommandResult] => [
{ phase: "corner1", lastPoint: null, method } as RectIdle,
{ draft: null, done: true },
];
// ── Prompt je Schritt/Methode ────────────────────────────────────────────────
function promptKey(s: RectState): TranslationKey {
if (s.phase === "corner1") return s.method === "center" ? "cmd.rect.center" : "cmd.rect.first";
if (s.phase === "rise") return "cmd.rect.rise";
// corner2
if (s.method === "three") return "cmd.rect.baseEnd";
if (s.method === "center") return "cmd.rect.corner";
return "cmd.rect.second";
}
export const rectCommand: Command = {
name: "rect",
labelKey: "cmd.rect.label",
prompt: (s) => ((s as RectState).phase === "corner2" ? "cmd.rect.second" : "cmd.rect.first"),
accepts: () => ["point", "number"],
options: () => [],
init: (): RectIdle => ({ phase: "corner1", lastPoint: null }),
prompt: (s) => promptKey(s as RectState),
accepts: () => ["point", "number", "option"],
options: (s) => [methodOption((s as RectState).method)],
init: (): RectIdle => ({ phase: "corner1", lastPoint: null, method: "corner" }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as RectState;
if (input.kind === "option") {
if (input.id === "method") {
// Methode zyklisch umschalten; laufende Konstruktion verwerfen und den
// Befehl AKTIV lassen (kein done) — nur zum ersten Schritt zurück.
const m = nextMethod(s.method);
const ns: RectIdle = { phase: "corner1", lastPoint: null, method: m };
return [ns, { draft: null }];
}
return [s, { draft: null }];
}
if (input.kind !== "point") {
return [s, { draft: s.phase === "corner2" ? rectDraft(s.a, s.cursor) : null }];
return [s, { draft: draftFor(s) }];
}
const pt = input.point;
if (s.phase !== "corner2") {
const ns: RectDrawing = { phase: "corner2", a: pt, cursor: pt, lastPoint: pt };
return [ns, { draft: rectDraft(pt, pt) }];
if (s.phase === "corner1") {
if (s.method === "three") {
const ns: RectCorner2 = { phase: "corner2", method: "three", a: pt, cursor: pt, lastPoint: pt };
return [ns, { draft: baseDraft(pt, pt) }];
}
const ns: RectCorner2 = { phase: "corner2", method: s.method, a: pt, cursor: pt, lastPoint: pt };
return [ns, { draft: draftFor(ns) }];
}
if (s.phase === "corner2") {
if (s.method === "three") {
// Basiskante gesetzt → dritter Punkt bestimmt Höhe.
if (segLen(s.a, pt) < EPS) return [s, { draft: baseDraft(s.a, s.cursor) }];
const ns: RectRise = { phase: "rise", method: "three", a: s.a, b: pt, cursor: pt, lastPoint: pt };
return [ns, { draft: riseDraft(s.a, pt, pt) }];
}
const a = s.a;
const method = s.method;
return [
{ phase: "corner1", lastPoint: null },
{ draft: null, done: true, commit: (p) => appendRect(p, a, pt, ctx) },
{ phase: "corner1", lastPoint: null, method } as RectIdle,
{
draft: null,
done: true,
commit: (p) => {
if (method === "center") {
const g = centerGeom(a, pt);
return appendRect(p, g.min, g.max, ctx);
}
const g = rectGeom(a, pt);
return appendRect(p, g.min, g.max, ctx);
},
},
];
}
// phase "rise" (3-Punkt) → committen als gedrehte, geschlossene Polylinie.
const { a, b } = s;
return [
{ phase: "corner1", lastPoint: null, method: "three" } as RectIdle,
{ draft: null, done: true, commit: (p) => appendRotated(p, rotatedCorners(a, b, pt), ctx) },
];
},
onMove: (state, point): [CommandState, CommandResult] => {
const s = state as RectState;
if (s.phase !== "corner2") return [s, { draft: null }];
const ns: RectDrawing = { ...s, cursor: point };
return [ns, { draft: rectDraft(s.a, point) }];
if (s.phase === "corner1") return [s, { draft: null }];
const ns = { ...s, cursor: point } as RectState;
return [ns, { draft: draftFor(ns) }];
},
onConfirm: (): [CommandState, CommandResult] => idle(),
onCancel: (): [CommandState, CommandResult] => idle(),
onConfirm: (state): [CommandState, CommandResult] => idle((state as RectState).method),
onCancel: (state): [CommandState, CommandResult] => idle((state as RectState).method),
// Tab-Feld-Zyklus nur im „corner2"-Schritt: Breite + Höhe (signiert nach Cursor).
fields: (state) => ((state as RectState).phase === "corner2" ? RECT_FIELDS : []),
// Tab-Feld-Zyklus je Schritt/Methode.
fields: (state) => {
const s = state as RectState;
if (s.phase === "corner2") return s.method === "three" ? BASE_FIELDS : RECT_FIELDS;
if (s.phase === "rise") return RISE_FIELDS;
return [];
},
pointFromFields: (state, locks, cursor) => {
const s = state as RectState;
if (s.phase !== "corner2") return null;
if (s.phase === "corner2") {
if (s.method === "three") return baseFromFields(s.a, locks, cursor);
if (s.method === "center") return centerCornerFromFields(s.a, locks, cursor);
return rectCornerFromFields(s.a, locks, cursor);
}
if (s.phase === "rise") {
if ("height" in locks) return riseToPoint(s.a, s.b, locks.height, cursor);
return cursor;
}
return null;
},
fieldValues: (state, _locks, cursor): Record<string, number> => {
const s = state as RectState;
if (!cursor) return {};
if (s.phase === "corner2") {
if (s.method === "three") {
return {
length: segLen(s.a, cursor),
angle: ((segAngleDeg(s.a, cursor) % 360) + 360) % 360,
};
}
return { width: Math.abs(cursor.x - s.a.x) * (s.method === "center" ? 2 : 1),
height: Math.abs(cursor.y - s.a.y) * (s.method === "center" ? 2 : 1) };
}
if (s.phase === "rise") return { height: Math.abs(riseFrom(s.a, s.b, cursor)) };
return {};
},
};
/** Vorschau passend zum Zustand. */
function draftFor(s: RectState): ToolDraft {
if (s.phase === "corner2") {
if (s.method === "three") return baseDraft(s.a, s.cursor);
if (s.method === "center") return centerDraft(s.a, s.cursor);
return cornerDraft(s.a, s.cursor);
}
if (s.phase === "rise") return riseDraft(s.a, s.b, s.cursor);
return { preview: [], vertices: [] };
}
+355
View File
@@ -0,0 +1,355 @@
// Raum (Room / SIA-416-Fläche) als Engine-Befehl. Zwei Modi (togglebar über die
// Option „Modus"):
//
// 1) „innen" (Default) — EIN Klick IN einen von Wänden umschlossenen Bereich.
// Der Umriss wird über den reinen Rechenkern (geometry/roomBoundary,
// `roomFromPointInside`) aus den Wänden des aktiven Geschosses automatisch
// als lichte Innenkontur erkannt. Findet der Klick keine geschlossene Zelle,
// passiert nichts (der Nutzer kann in den Manuell-Modus wechseln).
//
// 2) „manuell" — ein GESCHLOSSENER Umriss wird Punkt für Punkt gezeichnet
// (wie eine geschlossene Polylinie); Klick auf den Start / Option „Schliessen"
// / Enter (≥3 Punkte) committet den Raum.
//
// Der committete Raum bekommt SIA-Blatt HNF, einen Default-Namen und die
// Kategorie „45 Räume" (Fallback: aktive Kategorie). Fläche/Umfang/Schwerpunkt
// werden NICHT gespeichert (bei jedem Rendern aus `boundary` abgeleitet).
//
// Bezeichner englisch, sichtbarer Text via t() (CONVENTIONS.md). Einheit: METER.
import type { Room } from "../../model/types";
import { wallTypeThickness } from "../../model/types";
import { roomFromPointInside, type WallSegment } from "../../geometry/roomBoundary";
import { polygonArea, centroid } from "../../geometry/roomArea";
import { docFromText } from "../../text/richText";
import { uniqueId } from "../../tools/types";
import type {
Command,
CommandContext,
CommandField,
CommandResult,
CommandState,
CmdOption,
DraftShape,
Project,
ToolDraft,
Vec2,
} from "../types";
const EPS = 1e-6;
const DEG = Math.PI / 180;
const CLOSE_HIT = 0.08; // Modell-Meter: Klick nahe Startpunkt schließt den Umriss
/** Default-Ebene (Kategorie) für Räume: „60" Räume (Fallback: aktive). */
const ROOM_CATEGORY = "60";
/** Default-Raumfarbe (falls Kategorie nicht auflösbar). */
const ROOM_COLOR = "#5a7a9a";
const segLen = (a: Vec2, b: Vec2): number => Math.hypot(b.x - a.x, b.y - a.y);
const segAngleDeg = (a: Vec2, b: Vec2): number =>
(Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI;
/** Existiert die Raum-Kategorie („45")? Sonst Fallback auf die aktive Kategorie. */
function roomCategory(ctx: CommandContext): string {
const flat: { code: string }[] = [];
const walk = (list: { code: string; children?: unknown[] }[]) => {
for (const c of list) {
flat.push({ code: c.code });
if (c.children) walk(c.children as { code: string; children?: unknown[] }[]);
}
};
walk(ctx.project.layers as { code: string; children?: unknown[] }[]);
return flat.some((c) => c.code === ROOM_CATEGORY)
? ROOM_CATEGORY
: ctx.defaultCategoryCode;
}
/** Farbe der Raum-Kategorie (Ebene) — sonst Default. */
function roomColor(ctx: CommandContext, code: string): string {
const flat: { code: string; color?: string; children?: unknown[] }[] = [];
const walk = (list: { code: string; color?: string; children?: unknown[] }[]) => {
for (const c of list) {
flat.push(c);
if (c.children)
walk(
c.children as {
code: string;
color?: string;
children?: unknown[];
}[],
);
}
};
walk(
ctx.project.layers as {
code: string;
color?: string;
children?: unknown[];
}[],
);
return flat.find((c) => c.code === code)?.color ?? ROOM_COLOR;
}
/**
* Wände des aktiven Geschosses in die vom Rechenkern erwartete Form bringen:
* Mittellinie (start→end) + Gesamtdicke des Wandtyps. Grafik-2D-Geometrie und
* andere Geschosse bleiben außen vor.
*/
function wallsOfFloor(ctx: CommandContext): WallSegment[] {
const { project, level } = ctx;
const out: WallSegment[] = [];
for (const w of project.walls) {
if (w.floorId !== level.id) continue;
const wt = project.wallTypes.find((t) => t.id === w.wallTypeId);
const thickness = wt ? wallTypeThickness(wt) : 0.2;
out.push({ a: w.start, b: w.end, thickness });
}
return out;
}
/** Baut einen Raum aus einem fertigen Umriss (immutabel; keine abgeleiteten Felder). */
function buildRoom(ctx: CommandContext, boundary: Vec2[]): Room {
const code = roomCategory(ctx);
const name = defaultRoomName(ctx);
return {
id: uniqueId("RM"),
type: "room",
floorId: ctx.level.id,
categoryCode: code,
siaCategory: "HNF",
name,
boundary,
color: roomColor(ctx, code),
// Stempel-Anker EINMALIG auf den Zentroid setzen (danach frei verschiebbar,
// bleibt beim Ändern der Kontur an seiner Stelle).
stampAnchor: centroid(boundary),
// Frei editierbarer Stempel-Text: ein zentrierter Absatz mit dem Raumnamen.
stampDoc: { ...docFromText(name), paragraphs: docFromText(name).paragraphs.map((p) => ({ ...p, align: "center" as const })) },
};
}
/** Fortlaufender Default-Name „Raum N" (N = Anzahl vorhandener Räume + 1). */
function defaultRoomName(ctx: CommandContext): string {
const n = (ctx.project.rooms ?? []).length + 1;
return `Raum ${n}`;
}
/** Hängt einen Raum ans Projekt (immutabel); entartete Umrisse werden verworfen. */
function appendRoom(p: Project, boundary: Vec2[], ctx: CommandContext): Project {
if (boundary.length < 3 || polygonArea(boundary) < 1e-4) return p;
const room = buildRoom(ctx, boundary);
const rooms = p.rooms ? [...p.rooms, room] : [room];
return { ...p, rooms };
}
// ── Zustand ──────────────────────────────────────────────────────────────────
type RoomMode = "inside" | "manual";
interface RoomIdle extends CommandState {
phase: "start";
mode: RoomMode;
}
interface RoomDrawing extends CommandState {
phase: "draw";
mode: RoomMode;
points: Vec2[];
cursor: Vec2 | null;
}
type RoomCmdState = RoomIdle | RoomDrawing;
const MODE: CmdOption = { id: "mode", labelKey: "cmd.room.mode", value: "inside" };
const CLOSE: CmdOption = { id: "close", labelKey: "cmd.polyline.close" };
const UNDO: CmdOption = { id: "undo", labelKey: "cmd.polyline.undo" };
/** Tab-Felder je weiteren Punkt (Manuell-Modus): Länge + Winkel relativ zum letzten. */
const ROOM_FIELDS: CommandField[] = [
{ id: "length", labelKey: "cmd.field.length" },
{ id: "angle", labelKey: "cmd.field.angle" },
];
function nextFromFields(
last: Vec2,
locks: Record<string, number>,
cursor: Vec2 | null,
): Vec2 {
const ref = cursor ?? last;
const length = "length" in locks ? locks.length : segLen(last, ref);
const angleDeg = "angle" in locks ? locks.angle : segAngleDeg(last, ref);
const ang = angleDeg * DEG;
return { x: last.x + Math.cos(ang) * length, y: last.y + Math.sin(ang) * length };
}
/** Vorschau des manuellen Umrisses (geschlossener Ring ab 3 Punkten) + HUD. */
function manualDraft(points: Vec2[], cursor: Vec2 | null): ToolDraft {
const pts = cursor ? [...points, cursor] : points;
const showClosed = pts.length >= 3;
const preview: DraftShape[] = [{ kind: "poly", pts, closed: showClosed }];
const draft: ToolDraft = { preview, vertices: points };
const last = points[points.length - 1];
if (cursor && last && segLen(last, cursor) >= EPS) {
draft.hud = {
at: cursor,
text: `${segLen(last, cursor).toFixed(2)} m · ${Math.abs(
(segAngleDeg(last, cursor) + 360) % 360,
).toFixed(0)}°`,
};
}
return draft;
}
const idle = (mode: RoomMode): [CommandState, CommandResult] => [
{ phase: "start", lastPoint: null, mode } as RoomIdle,
{ draft: null, done: true },
];
export const roomCommand: Command = {
name: "room",
labelKey: "cmd.room.label",
// Räume leben auf Geschossen — wie Wand/Decke nur dort aktiv.
floorOnly: true,
prompt: (s) => {
const st = s as RoomCmdState;
if (st.phase === "draw") return "cmd.room.next";
return st.mode === "inside" ? "cmd.room.inside" : "cmd.room.first";
},
accepts: (s) =>
(s as RoomCmdState).phase === "draw"
? ["point", "number", "option"]
: ["point", "option"],
options: (s): CmdOption[] => {
const st = s as RoomCmdState;
const modeOpt: CmdOption = { ...MODE, value: st.mode };
if (st.phase !== "draw") return [modeOpt];
return st.points.length >= 3 ? [modeOpt, CLOSE, UNDO] : [modeOpt, UNDO];
},
init: (): RoomIdle => ({ phase: "start", lastPoint: null, mode: "inside" }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as RoomCmdState;
if (input.kind === "option") {
if (input.id === "mode") {
// Modus umschalten; ein laufender Manuell-Entwurf wird verworfen.
const mode: RoomMode = s.mode === "inside" ? "manual" : "inside";
return idle(mode);
}
if (s.phase !== "draw") return [s, { draft: null }];
if (input.id === "undo") {
const pts = s.points.slice(0, -1);
if (pts.length === 0)
return [
{ phase: "start", lastPoint: null, mode: s.mode } as RoomIdle,
{ draft: null },
];
const ns: RoomDrawing = {
phase: "draw",
mode: s.mode,
points: pts,
cursor: s.cursor,
lastPoint: pts[pts.length - 1],
};
return [ns, { draft: manualDraft(pts, s.cursor) }];
}
if (input.id === "close" && s.points.length >= 3) {
const pts = s.points;
return [
{ phase: "start", lastPoint: null, mode: s.mode } as RoomIdle,
{ draft: null, done: true, commit: (p) => appendRoom(p, pts, ctx) },
];
}
return [s, { draft: s.phase === "draw" ? manualDraft(s.points, s.cursor) : null }];
}
if (input.kind !== "point") {
return [
s,
{ draft: s.phase === "draw" ? manualDraft(s.points, s.cursor) : null },
];
}
const pt = input.point;
// ── Modus „innen": ein Klick → Umriss automatisch erkennen + committen ──
if (s.mode === "inside" && s.phase !== "draw") {
const boundary = roomFromPointInside(pt, wallsOfFloor(ctx));
if (boundary && boundary.length >= 3) {
return [
{ phase: "start", lastPoint: null, mode: "inside" } as RoomIdle,
{ draft: null, done: true, commit: (p) => appendRoom(p, boundary, ctx) },
];
}
// Kein umschlossener Bereich getroffen → im Werkzeug bleiben (kein Commit).
return [s, { draft: null }];
}
// ── Modus „manuell": Umriss Punkt für Punkt ──
if (s.phase !== "draw") {
const ns: RoomDrawing = {
phase: "draw",
mode: "manual",
points: [pt],
cursor: pt,
lastPoint: pt,
};
return [ns, { draft: manualDraft([pt], pt) }];
}
// Klick nahe Startpunkt → schließen + committen.
if (s.points.length >= 3 && segLen(s.points[0], pt) < CLOSE_HIT) {
const pts = s.points;
return [
{ phase: "start", lastPoint: null, mode: s.mode } as RoomIdle,
{ draft: null, done: true, commit: (p) => appendRoom(p, pts, ctx) },
];
}
const last = s.points[s.points.length - 1];
if (segLen(last, pt) < EPS) {
return [s, { draft: manualDraft(s.points, s.cursor) }];
}
const points = [...s.points, pt];
const ns: RoomDrawing = {
phase: "draw",
mode: "manual",
points,
cursor: pt,
lastPoint: pt,
};
return [ns, { draft: manualDraft(points, pt) }];
},
onMove: (state, point): [CommandState, CommandResult] => {
const s = state as RoomCmdState;
if (s.phase !== "draw") return [s, { draft: null }];
const ns: RoomDrawing = { ...s, cursor: point };
return [ns, { draft: manualDraft(s.points, point) }];
},
onConfirm: (state, ctx): [CommandState, CommandResult] => {
const s = state as RoomCmdState;
if (s.phase === "draw" && s.points.length >= 3) {
const pts = s.points;
return [
{ phase: "start", lastPoint: null, mode: s.mode } as RoomIdle,
{ draft: null, done: true, commit: (p) => appendRoom(p, pts, ctx) },
];
}
return idle(s.mode);
},
onCancel: (state): [CommandState, CommandResult] =>
idle((state as RoomCmdState).mode),
fields: (state) => ((state as RoomCmdState).phase === "draw" ? ROOM_FIELDS : []),
pointFromFields: (state, locks, cursor) => {
const s = state as RoomCmdState;
if (s.phase !== "draw" || s.points.length === 0) return null;
return nextFromFields(s.points[s.points.length - 1], locks, cursor);
},
fieldValues: (state, _locks, cursor): Record<string, number> => {
const s = state as RoomCmdState;
if (s.phase !== "draw" || s.points.length === 0 || !cursor) return {};
const last = s.points[s.points.length - 1];
return {
length: segLen(last, cursor),
angle: ((segAngleDeg(last, cursor) % 360) + 360) % 360,
};
},
};
+268
View File
@@ -0,0 +1,268 @@
// Treppe (Stair) als Engine-Befehl. Ablauf:
// 1) „Startpunkt der Treppe:" → Punkt (Antritt, unterste Stufe).
// 2) „Laufrichtung / Ende:" → Punkt: definiert Richtung + Lauflänge des ersten
// Laufs. Bei L folgt ein zweiter Lauf (Ende des zweiten Laufs); bei Wendel
// ist der zweite Punkt der äußere Rand (Radius). Committet die Treppe.
//
// Optionen (togglebar): Grundform gerade/L/Wendel (Auf-/Ab-Umschalter). Tab-Felder
// im Laufschritt: Breite / Stufenanzahl / Steighöhe (totalRise). Live-Vorschau
// zeigt die Tritt-Linien + die Lauflinie.
//
// Herkunft der Treppen-Felder (CommandContext):
// • floorId ← ctx.level.id (aktives Geschoss)
// • categoryCode← "40 Treppen" (Fallback ctx.defaultCategoryCode)
// • totalRise ← Geschosshöhe (ctx.level.floorHeight), damit die Treppe genau
// ins nächste Geschoss steigt (geschossübergreifend).
//
// Bezeichner englisch, sichtbarer Text via t() (CONVENTIONS.md).
import type { Stair, StairShape, Vec2 as MVec2 } from "../../model/types";
import { stairGeometry, defaultStepCount } from "../../geometry/stair";
import { uniqueId } from "../../tools/types";
import type {
Command,
CommandContext,
CommandField,
CommandResult,
CommandState,
CmdOption,
DraftShape,
Project,
ToolDraft,
Vec2,
} from "../types";
/** Default-Ebene (Kategorie) für Treppen: „40" Treppen (Fallback: aktive). */
const STAIR_CATEGORY = "40";
/** Default-Laufbreite (Meter). */
const DEF_WIDTH = 1.0;
const EPS = 1e-6;
const sub = (a: Vec2, b: Vec2): Vec2 => ({ x: a.x - b.x, y: a.y - b.y });
const lenOf = (a: Vec2): number => Math.hypot(a.x, a.y);
const normv = (a: Vec2): Vec2 => {
const l = lenOf(a) || 1e-9;
return { x: a.x / l, y: a.y / l };
};
/** Existiert die Treppen-Kategorie („40")? Sonst Fallback auf die aktive. */
function stairCategory(ctx: CommandContext): string {
const flat: { code: string }[] = [];
const walk = (list: { code: string; children?: unknown[] }[]) => {
for (const c of list) {
flat.push({ code: c.code });
if (c.children) walk(c.children as { code: string; children?: unknown[] }[]);
}
};
walk(ctx.project.layers as { code: string; children?: unknown[] }[]);
return flat.some((c) => c.code === STAIR_CATEGORY)
? STAIR_CATEGORY
: ctx.defaultCategoryCode;
}
/** Geschosshöhe als Default-Steighöhe (Fallback 2.6 m). */
function floorRise(ctx: CommandContext): number {
return ctx.level.floorHeight && ctx.level.floorHeight > 0 ? ctx.level.floorHeight : 2.6;
}
/** Tab-Felder im Laufschritt: Breite / Stufenanzahl / Steighöhe. */
const RUN_FIELDS: CommandField[] = [
{ id: "width", labelKey: "cmd.field.width" },
{ id: "steps", labelKey: "cmd.stair.stepsField" },
{ id: "rise", labelKey: "cmd.stair.riseField" },
];
const SHAPE: CmdOption = { id: "shape", labelKey: "cmd.stair.shape", value: "straight" };
const UPDOWN: CmdOption = { id: "updown", labelKey: "cmd.stair.updown", value: "up" };
interface StairIdle extends CommandState {
phase: "start";
shape: StairShape;
up: boolean;
width: number;
steps: number | null; // null = automatisch
rise: number | null; // null = Geschosshöhe
}
interface StairRun extends CommandState {
phase: "run";
shape: StairShape;
up: boolean;
width: number;
steps: number | null;
rise: number | null;
start: Vec2;
cursor: Vec2;
}
type StairCmdState = StairIdle | StairRun;
function initState(shape: StairShape = "straight"): StairIdle {
return { phase: "start", lastPoint: null, shape, up: true, width: DEF_WIDTH, steps: null, rise: null };
}
/**
* Baut aus dem Laufzustand ein konkretes `Stair`-Objekt (aufgelöste Felder). Die
* Richtung/Länge folgen aus start→cursor; bei L bricht der zweite Lauf um 90°
* (links) ab, bei Wendel wird der Cursorabstand zum Radius.
*/
function buildStair(s: StairRun, ctx: CommandContext): Stair {
const rise = s.rise ?? floorRise(ctx);
const d = sub(s.cursor, s.start);
const dist = Math.max(0.1, lenOf(d));
const dir = dist > EPS ? normv(d) : ({ x: 1, y: 0 } as MVec2);
const runLength = s.shape === "spiral" ? Math.max(0.5, dist) : dist;
const steps = s.steps ?? defaultStepCount(rise, runLength);
const base: Stair = {
id: uniqueId("ST"),
type: "stair",
floorId: ctx.level.id,
categoryCode: stairCategory(ctx),
shape: s.shape,
start: s.start,
dir,
runLength,
width: s.width,
totalRise: rise,
stepCount: steps,
up: s.up,
};
if (s.shape === "L") {
base.run2Length = runLength; // symmetrischer zweiter Lauf
base.turn = 1;
} else if (s.shape === "spiral") {
base.center = s.start;
base.radius = dist;
base.sweep = 270;
// Startpunkt am äußeren Rand: Lauflinie beginnt hier.
base.start = s.cursor;
base.dir = dir;
}
return base;
}
/** Vorschau: Tritt-Linien + Lauflinie + HUD (Länge · Stufenanzahl). */
function runDraft(s: StairRun, ctx: CommandContext): ToolDraft {
const stair = buildStair(s, ctx);
const rise = stair.totalRise ?? floorRise(ctx);
const geo = stairGeometry(stair, rise);
const preview: DraftShape[] = [];
for (const tr of geo.treads) preview.push({ kind: "poly", pts: tr.pts, closed: true });
if (geo.landing) preview.push({ kind: "poly", pts: geo.landing, closed: true });
if (geo.runLine.length >= 2) preview.push({ kind: "poly", pts: geo.runLine, closed: false });
const draft: ToolDraft = { preview, vertices: [s.start] };
draft.hud = {
at: s.cursor,
text: `${lenOf(sub(s.cursor, s.start)).toFixed(2)} m · ${stair.stepCount} STG`,
};
return draft;
}
/** Hängt eine Treppe ans Projekt (immutabel). */
function appendStair(p: Project, s: StairRun, ctx: CommandContext): Project {
const stair = buildStair(s, ctx);
const stairs = p.stairs ? [...p.stairs, stair] : [stair];
return { ...p, stairs };
}
const idle = (from?: StairCmdState): [CommandState, CommandResult] => [
initState(from?.shape ?? "straight"),
{ draft: null, done: true },
];
export const stairCommand: Command = {
name: "stair",
labelKey: "cmd.stair.label",
floorOnly: true,
prompt: (s) => ((s as StairCmdState).phase === "run" ? "cmd.stair.run" : "cmd.stair.start"),
accepts: (s) =>
(s as StairCmdState).phase === "run"
? ["point", "number", "option"]
: ["point", "option"],
options: (s): CmdOption[] => {
const st = s as StairCmdState;
return [
{ ...SHAPE, value: st.shape },
{ ...UPDOWN, value: st.up ? "up" : "down" },
];
},
init: (): StairIdle => initState("straight"),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as StairCmdState;
if (input.kind === "option") {
const next = { ...s } as StairCmdState;
if (input.id === "shape") {
next.shape = s.shape === "straight" ? "L" : s.shape === "L" ? "spiral" : "straight";
} else if (input.id === "updown") {
next.up = !s.up;
}
return [next, { draft: next.phase === "run" ? runDraft(next as StairRun, ctx) : null }];
}
if (input.kind !== "point") {
return [s, { draft: s.phase === "run" ? runDraft(s as StairRun, ctx) : null }];
}
const pt = input.point;
if (s.phase !== "run") {
const ns: StairRun = {
phase: "run",
lastPoint: pt,
shape: s.shape,
up: s.up,
width: s.width,
steps: s.steps,
rise: s.rise,
start: pt,
cursor: pt,
};
return [ns, { draft: null }];
}
// Zweiter Punkt: Lauf definieren + committen (Null-Strecke ignorieren).
if (lenOf(sub(pt, s.start)) < 0.05) {
return [{ ...s }, { draft: runDraft(s, ctx) }];
}
const ns: StairRun = { ...s, cursor: pt };
return [
idle(s)[0],
{ draft: null, done: true, commit: (p) => appendStair(p, ns, ctx) },
];
},
onMove: (state, point, _snap, ctx): [CommandState, CommandResult] => {
const s = state as StairCmdState;
if (s.phase !== "run") return [s, { draft: null }];
const ns: StairRun = { ...s, cursor: point };
return [ns, { draft: runDraft(ns, ctx) }];
},
onConfirm: (state, ctx): [CommandState, CommandResult] => {
const s = state as StairCmdState;
if (s.phase === "run" && lenOf(sub(s.cursor, s.start)) >= 0.05) {
const ns = s;
return [
idle(s)[0],
{ draft: null, done: true, commit: (p) => appendStair(p, ns, ctx) },
];
}
return idle(s);
},
onCancel: (state): [CommandState, CommandResult] => idle(state as StairCmdState),
fields: (state) => ((state as StairCmdState).phase === "run" ? RUN_FIELDS : []),
// Getippte Tab-Felder sticky in den State übernehmen; kein Koordinatenpunkt.
pointFromFields: (state, locks) => {
const s = state as StairCmdState;
if ("width" in locks && locks.width > 0) s.width = locks.width;
if ("steps" in locks && locks.steps >= 2) s.steps = Math.round(locks.steps);
if ("rise" in locks && locks.rise > 0) s.rise = locks.rise;
return null;
},
fieldValues: (state, _locks, _cursor): Record<string, number> => {
const s = state as StairCmdState;
const out: Record<string, number> = { width: s.width };
if (s.steps != null) out.steps = s.steps;
if (s.rise != null) out.rise = s.rise;
return out;
},
};
+242
View File
@@ -0,0 +1,242 @@
// Trim — Quick-Trim („stutzen") für 2D-Kurven (line/polyline/rect), Rhino/
// AutoCAD-artig (docs §2.10/§3.4 Tier-1). Modell:
// • Cutters sind IMPLIZIT alle ANDEREN Drawing2D-Elemente der aktiven Ebene
// (Quick-Trim — kein separater Cutter-Auswahlschritt; üblich + einfacher).
// • Ein Schritt: der Nutzer klickt auf den WEGZUSCHNEIDENDEN Teil einer Kurve.
// Wir bestimmen das getroffene Element (Nähe), zerlegen es an seinen
// Schnittpunkten mit allen Cuttern und ENTFERNEN den angeklickten Abschnitt
// (zwischen den beiden nächstgelegenen Schnittpunkten um den Klick — bzw. bis
// zum Element-Ende, wenn nur einseitig ein Schnitt liegt).
// • Wiederholend, bis Esc/Enter.
//
// Linie → wird gekürzt/aufgeteilt; Polylinie → der getroffene Abschnitt fällt
// raus (kann die Polylinie in Stücke teilen). Reine Geometrie liegt im Kernel
// (`trimPolyline`); hier nur Treffer-Bestimmung, Drawing2D ↔ {pts,closed} und der
// Commit. Wände sind ausgenommen (nur Drawing2D).
//
// Bezeichner englisch, Kommentare/UI deutsch (CONVENTIONS.md).
import type { Drawing2D, Drawing2DGeom } from "../../model/types";
import { uniqueId } from "../../tools/types";
import {
pointSegmentDistance,
polylineEdges,
trimPolyline,
vecEqual,
} from "../../geometry/kernel2d";
import type {
Command,
CommandResult,
CommandState,
DraftShape,
Project,
Vec2,
} from "../types";
const HIT_TOL = 0.25; // Modell-Meter: Pick-Toleranz für die Kurvenwahl
// ── Kurven-Abstraktion: line/polyline/rect → Punktliste + closed ──────────────
interface Curve {
pts: Vec2[];
closed: boolean;
}
/** Wandelt eine trim-fähige 2D-Form in eine Punktliste (+closed) oder null. */
function geomToCurve(g: Drawing2DGeom): Curve | null {
if (g.shape === "line") return { pts: [g.a, g.b], closed: false };
if (g.shape === "polyline") return { pts: g.pts, closed: g.closed };
if (g.shape === "rect") {
return {
pts: [
g.min,
{ x: g.max.x, y: g.min.y },
g.max,
{ x: g.min.x, y: g.max.y },
],
closed: true,
};
}
return null; // circle/arc/text: hier nicht trim-fähig (offener Punkt)
}
/** Ist diese Form trim-fähig (line/polyline/rect)? */
function isTrimmable(d: Drawing2D): boolean {
return (
d.geom.shape === "line" ||
d.geom.shape === "polyline" ||
d.geom.shape === "rect"
);
}
/** Nächstes trim-fähiges Drawing2D der Ebene zum Klickpunkt (innerhalb Toleranz). */
function pickCurveAt(
project: Project,
levelId: string,
pt: Vec2,
): Drawing2D | null {
let best: Drawing2D | null = null;
let bestD = HIT_TOL;
for (const d of project.drawings2d) {
if (d.levelId !== levelId) continue;
if (!isTrimmable(d)) continue;
const curve = geomToCurve(d.geom);
if (!curve) continue;
for (const [a, b] of polylineEdges(curve.pts, curve.closed)) {
const dd = pointSegmentDistance(pt, a, b);
if (dd < bestD) {
bestD = dd;
best = d;
}
}
}
return best;
}
/** Alle anderen trim-fähigen Drawing2D der Ebene als Cutter-Kurven. */
function cuttersFor(
project: Project,
levelId: string,
exceptId: string,
): Curve[] {
const out: Curve[] = [];
for (const d of project.drawings2d) {
if (d.id === exceptId) continue;
if (d.levelId !== levelId) continue;
const curve = geomToCurve(d.geom);
if (curve) out.push(curve);
}
return out;
}
/** Sind zwei Ketten geometrisch identisch (gleiches closed + gleiche Punkte)? */
function sameChain(a: { pts: Vec2[]; closed: boolean }, b: Curve): boolean {
if (a.closed !== b.closed || a.pts.length !== b.pts.length) return false;
for (let i = 0; i < a.pts.length; i++) {
if (!vecEqual(a.pts[i], b.pts[i])) return false;
}
return true;
}
/** Eine getrimmte Rest-Kette als passende Drawing2D-Geometrie. */
function chainToGeom(chain: { pts: Vec2[]; closed: boolean }): Drawing2DGeom {
if (chain.pts.length === 2 && !chain.closed) {
return { shape: "line", a: chain.pts[0], b: chain.pts[1] };
}
return { shape: "polyline", pts: chain.pts, closed: chain.closed };
}
/**
* Wendet einen Trim auf das Projekt an: ersetzt das getroffene Element durch
* seine Rest-Ketten (Attribute geerbt). Bleibt nichts übrig, fällt es weg.
*/
function applyTrim(
p: Project,
target: Drawing2D,
pick: Vec2,
): Project {
const curve = geomToCurve(target.geom);
if (!curve) return p;
const cutters = cuttersFor(p, target.levelId, target.id).map((c) => ({
pts: c.pts,
closed: c.closed,
}));
const rest = trimPolyline(curve.pts, curve.closed, cutters, pick);
// Echtes No-op: genau eine Kette, identisch (gleiche Punkte) zur Quelle →
// nichts wurde weggeschnitten (kein Schnitt mit Cuttern).
if (rest.length === 1 && sameChain(rest[0], curve)) return p;
const replacements: Drawing2D[] = rest
.filter((c) => c.pts.length >= 2)
.map((c) => ({ ...target, id: uniqueId("dr2d"), geom: chainToGeom(c) }));
const others = p.drawings2d.filter((d) => d.id !== target.id);
return { ...p, drawings2d: [...others, ...replacements] };
}
/** Vorschau-Formen der NACH dem Trim verbleibenden Ketten (für den Draft). */
function trimPreview(
project: Project,
target: Drawing2D,
pick: Vec2,
): DraftShape[] {
const curve = geomToCurve(target.geom);
if (!curve) return [];
const cutters = cuttersFor(project, target.levelId, target.id).map((c) => ({
pts: c.pts,
closed: c.closed,
}));
const rest = trimPolyline(curve.pts, curve.closed, cutters, pick);
const out: DraftShape[] = [];
for (const c of rest) {
if (c.pts.length < 2) continue;
if (c.pts.length === 2 && !c.closed) {
out.push({ kind: "line", a: c.pts[0], b: c.pts[1] });
} else {
out.push({ kind: "poly", pts: c.pts, closed: c.closed });
}
}
return out;
}
// ── Zustand ───────────────────────────────────────────────────────────────────
// Einziger Schritt „pick": auf den wegzuschneidenden Abschnitt klicken;
// wiederholend, bis Esc/Enter. „done": beendet.
interface TrimPick extends CommandState {
phase: "pick";
}
interface TrimDone extends CommandState {
phase: "done";
}
type TrimState = TrimPick | TrimDone;
const finish = (): [CommandState, CommandResult] => [
{ phase: "done", lastPoint: null } as TrimDone,
{ draft: null, done: true },
];
export const trimCommand: Command = {
name: "trim",
labelKey: "cmd.trim.label",
prompt: () => "cmd.trim.pick",
accepts: () => ["point"],
options: () => [],
init: (): TrimPick => ({ phase: "pick", lastPoint: null }),
onInput: (state, input, ctx): [CommandState, CommandResult] => {
const s = state as TrimState;
if (s.phase === "done") return finish();
if (input.kind !== "point") return [s, { draft: null }];
const target = pickCurveAt(ctx.project, ctx.level.id, input.point);
if (!target) {
// Daneben geklickt → Befehl bleibt aktiv (weiter trimmen).
return [s, { draft: null }];
}
const pick = input.point;
// Wiederholend: NICHT done — nach dem Commit bleibt „pick" aktiv.
return [
s,
{ draft: null, commit: (p) => applyTrim(p, target, pick) },
];
},
onMove: (state, point, _snap, ctx): [CommandState, CommandResult] => {
const s = state as TrimState;
if (s.phase !== "pick") return [s, { draft: null }];
const target = pickCurveAt(ctx.project, ctx.level.id, point);
if (!target) return [s, { draft: null }];
const preview = trimPreview(ctx.project, target, point);
return [
s,
{
draft: {
preview,
vertices: [],
hud: { at: point, text: "✂" },
},
},
];
},
onConfirm: (): [CommandState, CommandResult] => finish(),
onCancel: (): [CommandState, CommandResult] => finish(),
};

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