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).
|
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 |
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
After Width: | Height: | Size: 246 KiB |
|
After Width: | Height: | Size: 620 KiB |
|
After Width: | Height: | Size: 186 KiB |
|
After Width: | Height: | Size: 679 KiB |
|
After Width: | Height: | Size: 296 KiB |
|
After Width: | Height: | Size: 644 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 481 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 686 KiB |
|
After Width: | Height: | Size: 958 KiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 816 KiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 832 KiB |
|
After Width: | Height: | Size: 836 KiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 686 KiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 772 KiB |
|
After Width: | Height: | Size: 883 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 468 KiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 834 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 772 KiB |
|
After Width: | Height: | Size: 335 KiB |
|
After Width: | Height: | Size: 523 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 752 KiB |
|
After Width: | Height: | Size: 720 KiB |
|
After Width: | Height: | Size: 336 KiB |
|
After Width: | Height: | Size: 714 KiB |
|
After Width: | Height: | Size: 382 KiB |
|
After Width: | Height: | Size: 720 KiB |
|
After Width: | Height: | Size: 280 KiB |
|
After Width: | Height: | Size: 972 KiB |
|
After Width: | Height: | Size: 344 KiB |
|
After Width: | Height: | Size: 809 KiB |
|
After Width: | Height: | Size: 588 KiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 359 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 245 KiB |
|
After Width: | Height: | Size: 687 KiB |
|
After Width: | Height: | Size: 565 KiB |
|
After Width: | Height: | Size: 512 KiB |
|
After Width: | Height: | Size: 300 KiB |
|
After Width: | Height: | Size: 679 KiB |
|
After Width: | Height: | Size: 247 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 1023 KiB |
|
After Width: | Height: | Size: 501 KiB |
|
After Width: | Height: | Size: 766 KiB |
|
After Width: | Height: | Size: 445 KiB |
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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");
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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");
|
||||
@@ -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();
|
||||
@@ -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");
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -0,0 +1,4 @@
|
||||
/target
|
||||
/gen/schemas
|
||||
# Build-Artefakte aller (Sub-)Crates, unabhängig von der Verschachtelung.
|
||||
target/
|
||||
@@ -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" }
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 103 B |
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
cad_tauri_lib::run()
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
// Ceiling — Decke als echter Engine-Befehl (analog polyline: ein GESCHLOSSENER
|
||||
// Umriss), committet aber ein einzelnes `Ceiling`-Bauteil statt einer 2D-Form.
|
||||
// Schritte:
|
||||
// 1) „Erster Umrisspunkt der Decke:" → Punkt
|
||||
// 2) „Nächster Punkt ( Schliessen Zurück ):" → Punkt … Der Umriss schließt per
|
||||
// Klick auf den Startpunkt, Option „Schliessen" (C) oder Enter (≥3 Punkte).
|
||||
//
|
||||
// Akzeptiert je Punkt Maus-Picks UND getippte Koordinaten (0,0 · r3,0 · 5<45) —
|
||||
// derselbe Eingabepfad wie Polylinie/Wand. Die fertige Decke rendert über die
|
||||
// bestehende Decken-Darstellung (generatePlan / 3D); der Draft zeigt den Umriss.
|
||||
//
|
||||
// Herkunft der Decken-Felder (CommandContext):
|
||||
// • wallTypeId ← ctx.activeWallTypeId (Fallback: erster Projekt-Wandtyp) — der
|
||||
// Schichtaufbau (Bauteil/Schraffur/Material) wird identisch zur Wand aufgelöst.
|
||||
// • floorId ← ctx.level.id (aktives Geschoss)
|
||||
// • categoryCode← "30" (Ebene „Decken"; Fallback ctx.defaultCategoryCode)
|
||||
|
||||
import type { Ceiling } from "../../model/types";
|
||||
import { normalizeOutline, ceilingArea } from "../../geometry/ceiling";
|
||||
import { uniqueId } from "../../tools/types";
|
||||
import type {
|
||||
Command,
|
||||
CommandContext,
|
||||
CommandField,
|
||||
CommandResult,
|
||||
CommandState,
|
||||
CmdOption,
|
||||
DraftShape,
|
||||
Project,
|
||||
ToolDraft,
|
||||
Vec2,
|
||||
} from "../types";
|
||||
|
||||
const EPS = 1e-6;
|
||||
const DEG = Math.PI / 180;
|
||||
const CLOSE_HIT = 0.08; // Modell-Meter: Klick nahe Startpunkt schließt den Umriss
|
||||
/** Default-Ebene (Kategorie) für Decken. */
|
||||
const CEILING_CATEGORY = "30";
|
||||
|
||||
const segLen = (a: Vec2, b: Vec2): number => Math.hypot(b.x - a.x, b.y - a.y);
|
||||
const segAngleDeg = (a: Vec2, b: Vec2): number =>
|
||||
(Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI;
|
||||
|
||||
/** Tab-Felder je weiteren Punkt: Länge + Winkel relativ zum letzten Punkt. */
|
||||
const CEIL_FIELDS: CommandField[] = [
|
||||
{ id: "length", labelKey: "cmd.field.length" },
|
||||
{ id: "angle", labelKey: "cmd.field.angle" },
|
||||
];
|
||||
|
||||
/** Nächster Punkt aus gelockten Feldern (length/angle) + Cursor, relativ zu `last`. */
|
||||
function nextFromFields(
|
||||
last: Vec2,
|
||||
locks: Record<string, number>,
|
||||
cursor: Vec2 | null,
|
||||
): Vec2 {
|
||||
const ref = cursor ?? last;
|
||||
const length = "length" in locks ? locks.length : segLen(last, ref);
|
||||
const angleDeg = "angle" in locks ? locks.angle : segAngleDeg(last, ref);
|
||||
const ang = angleDeg * DEG;
|
||||
return { x: last.x + Math.cos(ang) * length, y: last.y + Math.sin(ang) * length };
|
||||
}
|
||||
|
||||
/** Aktiver Aufbau-Typ-ID; Fallback auf den ersten Projekt-Wandtyp. */
|
||||
function activeTypeId(ctx: CommandContext): string {
|
||||
const wt =
|
||||
ctx.project.wallTypes.find((t) => t.id === ctx.activeWallTypeId) ??
|
||||
ctx.project.wallTypes[0];
|
||||
return wt ? wt.id : ctx.activeWallTypeId;
|
||||
}
|
||||
|
||||
/** Existiert die Decken-Kategorie („30")? Sonst Fallback auf die aktive Kategorie. */
|
||||
function ceilingCategory(ctx: CommandContext): string {
|
||||
const flat: { code: string }[] = [];
|
||||
const walk = (list: { code: string; children?: unknown[] }[]) => {
|
||||
for (const c of list) {
|
||||
flat.push({ code: c.code });
|
||||
if (c.children) walk(c.children as { code: string; children?: unknown[] }[]);
|
||||
}
|
||||
};
|
||||
walk(ctx.project.layers as { code: string; children?: unknown[] }[]);
|
||||
return flat.some((c) => c.code === CEILING_CATEGORY)
|
||||
? CEILING_CATEGORY
|
||||
: ctx.defaultCategoryCode;
|
||||
}
|
||||
|
||||
interface CeilIdle extends CommandState {
|
||||
phase: "start";
|
||||
}
|
||||
interface CeilDrawing extends CommandState {
|
||||
phase: "next";
|
||||
points: Vec2[];
|
||||
cursor: Vec2 | null;
|
||||
}
|
||||
type CeilState = CeilIdle | CeilDrawing;
|
||||
|
||||
const CLOSE: CmdOption = { id: "close", labelKey: "cmd.polyline.close" };
|
||||
const UNDO: CmdOption = { id: "undo", labelKey: "cmd.polyline.undo" };
|
||||
|
||||
/** Vorschau: geschlossener Umriss-Ring (ab 3 Punkten) + Gummiband + HUD. */
|
||||
function ceilingDraft(points: Vec2[], cursor: Vec2 | null): ToolDraft {
|
||||
const pts = cursor ? [...points, cursor] : points;
|
||||
const showClosed = pts.length >= 3;
|
||||
const preview: DraftShape[] = [{ kind: "poly", pts, closed: showClosed }];
|
||||
const draft: ToolDraft = { preview, vertices: points };
|
||||
const last = points[points.length - 1];
|
||||
if (cursor && last && segLen(last, cursor) >= EPS) {
|
||||
draft.hud = {
|
||||
at: cursor,
|
||||
text: `${segLen(last, cursor).toFixed(2)} m · ${Math.abs(
|
||||
(segAngleDeg(last, cursor) + 360) % 360,
|
||||
).toFixed(0)}°`,
|
||||
};
|
||||
}
|
||||
return draft;
|
||||
}
|
||||
|
||||
/** Hängt eine Decke ans Projekt (immutabel). Entartete Umrisse werden verworfen. */
|
||||
function appendCeiling(p: Project, pts: Vec2[], ctx: CommandContext): Project {
|
||||
const outline = normalizeOutline(pts);
|
||||
if (!outline || ceilingArea(outline) < 1e-4) return p;
|
||||
const ceiling: Ceiling = {
|
||||
id: uniqueId("C"),
|
||||
type: "ceiling",
|
||||
floorId: ctx.level.id,
|
||||
categoryCode: ceilingCategory(ctx),
|
||||
outline,
|
||||
wallTypeId: activeTypeId(ctx),
|
||||
};
|
||||
const ceilings = p.ceilings ? [...p.ceilings, ceiling] : [ceiling];
|
||||
return { ...p, ceilings };
|
||||
}
|
||||
|
||||
const idle = (): [CommandState, CommandResult] => [
|
||||
{ phase: "start", lastPoint: null } as CeilIdle,
|
||||
{ draft: null, done: true },
|
||||
];
|
||||
|
||||
export const ceilingCommand: Command = {
|
||||
name: "ceiling",
|
||||
labelKey: "cmd.ceiling.label",
|
||||
// Decken leben auf Geschossen — wie die Wand nur dort aktiv.
|
||||
floorOnly: true,
|
||||
prompt: (s) =>
|
||||
(s as CeilState).phase === "next" ? "cmd.ceiling.next" : "cmd.ceiling.start",
|
||||
accepts: (s) =>
|
||||
(s as CeilState).phase === "next"
|
||||
? ["point", "number", "option"]
|
||||
: ["point", "number"],
|
||||
options: (s) => {
|
||||
const ps = s as CeilState;
|
||||
if (ps.phase !== "next") return [];
|
||||
return ps.points.length >= 3 ? [CLOSE, UNDO] : [UNDO];
|
||||
},
|
||||
init: (): CeilIdle => ({ phase: "start", lastPoint: null }),
|
||||
|
||||
onInput: (state, input, ctx): [CommandState, CommandResult] => {
|
||||
const s = state as CeilState;
|
||||
|
||||
if (input.kind === "option") {
|
||||
if (s.phase !== "next") return [s, { draft: null }];
|
||||
if (input.id === "undo") {
|
||||
const pts = s.points.slice(0, -1);
|
||||
if (pts.length === 0)
|
||||
return [{ phase: "start", lastPoint: null } as CeilIdle, { draft: null }];
|
||||
const ns: CeilDrawing = {
|
||||
phase: "next",
|
||||
points: pts,
|
||||
cursor: s.cursor,
|
||||
lastPoint: pts[pts.length - 1],
|
||||
};
|
||||
return [ns, { draft: ceilingDraft(pts, s.cursor) }];
|
||||
}
|
||||
// Sofort schließen: committet die Decke (unabhängig, ≥3 Punkte).
|
||||
if (input.id === "close" && s.points.length >= 3) {
|
||||
const pts = s.points;
|
||||
return [
|
||||
{ phase: "start", lastPoint: null } as CeilIdle,
|
||||
{ draft: null, done: true, commit: (p) => appendCeiling(p, pts, ctx) },
|
||||
];
|
||||
}
|
||||
return [s, { draft: ceilingDraft(s.points, s.cursor) }];
|
||||
}
|
||||
|
||||
if (input.kind !== "point") {
|
||||
return [
|
||||
s,
|
||||
{ draft: s.phase === "next" ? ceilingDraft(s.points, s.cursor) : null },
|
||||
];
|
||||
}
|
||||
const pt = input.point;
|
||||
if (s.phase !== "next") {
|
||||
const ns: CeilDrawing = { phase: "next", points: [pt], cursor: pt, lastPoint: pt };
|
||||
return [ns, { draft: ceilingDraft([pt], pt) }];
|
||||
}
|
||||
// Klick nahe Startpunkt → schließen (committet die Decke).
|
||||
if (s.points.length >= 3 && segLen(s.points[0], pt) < CLOSE_HIT) {
|
||||
const pts = s.points;
|
||||
return [
|
||||
{ phase: "start", lastPoint: null } as CeilIdle,
|
||||
{ draft: null, done: true, commit: (p) => appendCeiling(p, pts, ctx) },
|
||||
];
|
||||
}
|
||||
// Null-Strecke (Klick auf denselben Punkt) ignorieren.
|
||||
const last = s.points[s.points.length - 1];
|
||||
if (segLen(last, pt) < EPS) {
|
||||
return [s, { draft: ceilingDraft(s.points, s.cursor) }];
|
||||
}
|
||||
const points = [...s.points, pt];
|
||||
const ns: CeilDrawing = { phase: "next", points, cursor: pt, lastPoint: pt };
|
||||
return [ns, { draft: ceilingDraft(points, pt) }];
|
||||
},
|
||||
|
||||
onMove: (state, point): [CommandState, CommandResult] => {
|
||||
const s = state as CeilState;
|
||||
if (s.phase !== "next") return [s, { draft: null }];
|
||||
const ns: CeilDrawing = { ...s, cursor: point };
|
||||
return [ns, { draft: ceilingDraft(s.points, point) }];
|
||||
},
|
||||
|
||||
// Enter/Space/Rechtsklick: Umriss schließen + committen (≥3 Punkte).
|
||||
onConfirm: (state, ctx): [CommandState, CommandResult] => {
|
||||
const s = state as CeilState;
|
||||
if (s.phase === "next" && s.points.length >= 3) {
|
||||
const pts = s.points;
|
||||
return [
|
||||
{ phase: "start", lastPoint: null } as CeilIdle,
|
||||
{ draft: null, done: true, commit: (p) => appendCeiling(p, pts, ctx) },
|
||||
];
|
||||
}
|
||||
return idle();
|
||||
},
|
||||
onCancel: (): [CommandState, CommandResult] => idle(),
|
||||
|
||||
// Tab-Feld-Zyklus nur im „next"-Schritt: Länge + Winkel relativ zum letzten Punkt.
|
||||
fields: (state) => ((state as CeilState).phase === "next" ? CEIL_FIELDS : []),
|
||||
pointFromFields: (state, locks, cursor) => {
|
||||
const s = state as CeilState;
|
||||
if (s.phase !== "next" || s.points.length === 0) return null;
|
||||
return nextFromFields(s.points[s.points.length - 1], locks, cursor);
|
||||
},
|
||||
fieldValues: (state, _locks, cursor): Record<string, number> => {
|
||||
const s = state as CeilState;
|
||||
if (s.phase !== "next" || s.points.length === 0 || !cursor) return {};
|
||||
const last = s.points[s.points.length - 1];
|
||||
return {
|
||||
length: segLen(last, cursor),
|
||||
angle: ((segAngleDeg(last, cursor) % 360) + 360) % 360,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -133,6 +133,11 @@ export const circleCommand: Command = {
|
||||
// Tab-Feld-Zyklus nur im „radius"-Schritt: ein Radius-Feld; der gelockte Wert
|
||||
// committet direkt (Punkt liegt auf dem Kreis, dist(center,point)=radius).
|
||||
fields: (state) => ((state as CircleState).phase === "radius" ? CIRCLE_FIELDS : []),
|
||||
fieldValues: (state, _locks, cursor): Record<string, number> => {
|
||||
const s = state as CircleState;
|
||||
if (s.phase !== "radius" || !cursor) return {};
|
||||
return { radius: dist(s.center, cursor) };
|
||||
},
|
||||
pointFromFields: (state, locks, cursor) => {
|
||||
const s = state as CircleState;
|
||||
if (s.phase !== "radius") return null;
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// Join — verbindet die gewählten 2D-Zeichenelemente an koinzidenten Endpunkten zu
|
||||
// Polylinien (geschlossen, wenn sich eine Kette schließt). Sofort-Befehl (kein
|
||||
// Punkt-Picken): läuft direkt auf der bestehenden Mehrfachauswahl. So ist „Ctrl+J"
|
||||
// und getipptes „join" EIN Pfad (§Task C).
|
||||
//
|
||||
// Wände werden nicht verbunden (2D-Editier-Operation); nicht-polyline Formen
|
||||
// (circle/arc/text) reicht `joinDrawings` unverändert durch.
|
||||
|
||||
import { joinDrawings } from "../../editors/splitJoin";
|
||||
import type { Drawing2D } from "../../model/types";
|
||||
import type {
|
||||
Command,
|
||||
CommandContext,
|
||||
CommandResult,
|
||||
CommandSelection,
|
||||
CommandState,
|
||||
Project,
|
||||
} from "../types";
|
||||
|
||||
/** Alle gewählten 2D-Element-IDs (Mehrfachauswahl mit Fallback aufs Einzelelement). */
|
||||
function selectedDrawingIds(sel: CommandSelection): string[] {
|
||||
if (sel.drawingIds && sel.drawingIds.length) return sel.drawingIds;
|
||||
return sel.drawingId ? [sel.drawingId] : [];
|
||||
}
|
||||
|
||||
/** Ersetzt die gewählten Quell-Drawings durch das Join-Ergebnis (immutabel). */
|
||||
function applyJoin(project: Project, ids: string[]): Project {
|
||||
const idSet = new Set(ids);
|
||||
const srcs = project.drawings2d.filter((d) => idSet.has(d.id));
|
||||
if (srcs.length < 2) return project; // <2 → nichts zu verbinden
|
||||
const joined: Drawing2D[] = joinDrawings(srcs);
|
||||
const rest = project.drawings2d.filter((d) => !idSet.has(d.id));
|
||||
return { ...project, drawings2d: [...rest, ...joined] };
|
||||
}
|
||||
|
||||
interface JoinState extends CommandState {
|
||||
phase: "done";
|
||||
}
|
||||
|
||||
const done = (): [CommandState, CommandResult] => [
|
||||
{ phase: "done", lastPoint: null } as JoinState,
|
||||
{ draft: null, done: true },
|
||||
];
|
||||
|
||||
export const joinCommand: Command = {
|
||||
name: "join",
|
||||
labelKey: "edit.join.label",
|
||||
prompt: () => "cmd.join.prompt",
|
||||
accepts: () => [],
|
||||
options: () => [],
|
||||
init: (): JoinState => ({ phase: "done", lastPoint: null }),
|
||||
|
||||
// Sofort auf der Auswahl ausführen (kein Pick nötig).
|
||||
autoRun: (ctx: CommandContext): CommandResult => {
|
||||
const ids = selectedDrawingIds(ctx.selection);
|
||||
if (ids.length < 2) return { draft: null, done: true };
|
||||
return { draft: null, done: true, commit: (p) => applyJoin(p, ids) };
|
||||
},
|
||||
|
||||
onInput: (): [CommandState, CommandResult] => done(),
|
||||
onMove: (state): [CommandState, CommandResult] => [state, { draft: null }],
|
||||
onConfirm: (): [CommandState, CommandResult] => done(),
|
||||
onCancel: (): [CommandState, CommandResult] => done(),
|
||||
};
|
||||
@@ -137,4 +137,12 @@ export const lineCommand: Command = {
|
||||
if (s.phase !== "end") return null;
|
||||
return lineEndFromFields(s.a, locks, cursor);
|
||||
},
|
||||
fieldValues: (state, _locks, cursor): Record<string, number> => {
|
||||
const s = state as LineState;
|
||||
if (s.phase !== "end" || !cursor) return {};
|
||||
return {
|
||||
length: segLen(s.a, cursor),
|
||||
angle: ((segAngleDeg(s.a, cursor) % 360) + 360) % 360,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
// Mirror — spiegelt die AKTUELLE Auswahl (Wände + ein Drawing2D) an einer frei
|
||||
// gewählten Achse (Rhino/Vectorworks-Spiegeln). Schritte:
|
||||
// 1) „Erster Achsenpunkt:" → Punkt
|
||||
// 2) „Zweiter Achsenpunkt:" → Punkt (Live-Vorschau der Spiegelung) → commit
|
||||
//
|
||||
// Die Auswahl kommt vorab über ctx.selection (erst selektieren, dann Befehl,
|
||||
// wie Move/Copy). Ist sie leer, ist nichts zu spiegeln: kurzer No-op mit Hinweis
|
||||
// („Nichts gewählt") und Beenden.
|
||||
//
|
||||
// Der Commit nutzt den bestehenden, getesteten `commitTransform` (op:"mirror",
|
||||
// mode:"copy") — er spiegelt Wände UND alle 2D-Formen immutabel an der Achse und
|
||||
// hängt das Spiegelbild als KOPIE an (das Original bleibt stehen). Das ist das
|
||||
// klassische Mirror „Kopie spiegeln" (Rhino/Vectorworks-Default).
|
||||
|
||||
import { commitTransform } from "../../tools/transform";
|
||||
import type { TransformSelection } from "../../tools/transform";
|
||||
import { transformPreview } from "../../tools/transform";
|
||||
import type {
|
||||
Command,
|
||||
CommandResult,
|
||||
CommandSelection,
|
||||
CommandState,
|
||||
Project,
|
||||
ToolDraft,
|
||||
Vec2,
|
||||
} from "../types";
|
||||
|
||||
const segLen = (a: Vec2, b: Vec2): number => Math.hypot(b.x - a.x, b.y - a.y);
|
||||
const segAngleDeg = (a: Vec2, b: Vec2): number =>
|
||||
(Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI;
|
||||
const EPS = 1e-6;
|
||||
|
||||
/** Hat die Auswahl überhaupt etwas Spiegelbares? */
|
||||
function hasSelection(sel: CommandSelection): boolean {
|
||||
return sel.wallIds.length > 0 || sel.drawingId !== null;
|
||||
}
|
||||
|
||||
/** Auswahl → TransformSelection (gleiche Form). */
|
||||
function toTransformSel(sel: CommandSelection): TransformSelection {
|
||||
return { wallIds: sel.wallIds, drawingId: sel.drawingId };
|
||||
}
|
||||
|
||||
// Zustand: erst ersten Achsenpunkt setzen, dann den zweiten (mit Auswahl-Snapshot).
|
||||
interface MirrorFirst extends CommandState {
|
||||
phase: "first";
|
||||
}
|
||||
interface MirrorSecond extends CommandState {
|
||||
phase: "second";
|
||||
sel: CommandSelection;
|
||||
a: Vec2;
|
||||
cursor: Vec2 | null;
|
||||
}
|
||||
// „done": leere Auswahl → No-op, Befehl beendet.
|
||||
interface MirrorDone extends CommandState {
|
||||
phase: "done";
|
||||
}
|
||||
type MirrorState = MirrorFirst | MirrorSecond | MirrorDone;
|
||||
|
||||
/** Live-Vorschau der an der Achse a→b gespiegelten Auswahl. */
|
||||
function mirrorDraft(
|
||||
project: Project,
|
||||
sel: CommandSelection,
|
||||
a: Vec2,
|
||||
b: Vec2,
|
||||
): ToolDraft {
|
||||
// Ohne echte Achse (Null-Strecke) nur den ersten Punkt markieren.
|
||||
if (segLen(a, b) < EPS) {
|
||||
return { preview: [], vertices: [a] };
|
||||
}
|
||||
const preview = transformPreview(
|
||||
project,
|
||||
toTransformSel(sel),
|
||||
"mirror",
|
||||
[a, b],
|
||||
"copy", // Spiegelbild als Kopie (Original bleibt stehen)
|
||||
1,
|
||||
);
|
||||
return {
|
||||
// Spiegelbild-Vorschau + die Achse selbst als Hilfslinie.
|
||||
preview: [...preview, { kind: "line", a, b }],
|
||||
// Beide Achsenpunkte als Stützpunkte (Marker).
|
||||
vertices: [a, b],
|
||||
hud: {
|
||||
at: b,
|
||||
text: `${((segAngleDeg(a, b) + 360) % 360).toFixed(0)}°`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const idle = (): [CommandState, CommandResult] => [
|
||||
{ phase: "done", lastPoint: null } as MirrorDone,
|
||||
{ draft: null, done: true },
|
||||
];
|
||||
|
||||
export const mirrorCommand: Command = {
|
||||
name: "mirror",
|
||||
labelKey: "cmd.mirror.label",
|
||||
prompt: (s) => {
|
||||
const ms = s as MirrorState;
|
||||
if (ms.phase === "second") return "cmd.mirror.second";
|
||||
if (ms.phase === "done") return "cmd.mirror.empty";
|
||||
return "cmd.mirror.first";
|
||||
},
|
||||
accepts: () => ["point"],
|
||||
options: () => [],
|
||||
|
||||
init: (): MirrorFirst => ({ phase: "first", lastPoint: null }),
|
||||
|
||||
onInput: (state, input, ctx): [CommandState, CommandResult] => {
|
||||
const s = state as MirrorState;
|
||||
if (s.phase === "done") return idle();
|
||||
// Leere Auswahl → nichts zu spiegeln (No-op + Hinweis, dann beenden).
|
||||
if (!hasSelection(ctx.selection)) {
|
||||
return [{ phase: "done", lastPoint: null } as MirrorDone, { draft: null, done: true }];
|
||||
}
|
||||
if (input.kind !== "point") {
|
||||
if (s.phase === "second") {
|
||||
return [s, { draft: s.cursor ? mirrorDraft(ctx.project, s.sel, s.a, s.cursor) : null }];
|
||||
}
|
||||
return [s, { draft: null }];
|
||||
}
|
||||
const pt = input.point;
|
||||
if (s.phase !== "second") {
|
||||
// Erster Achsenpunkt gesetzt → Auswahl-Snapshot mitführen.
|
||||
const next: MirrorSecond = {
|
||||
phase: "second",
|
||||
sel: ctx.selection,
|
||||
a: pt,
|
||||
cursor: pt,
|
||||
lastPoint: pt,
|
||||
};
|
||||
return [next, { draft: mirrorDraft(ctx.project, next.sel, pt, pt) }];
|
||||
}
|
||||
// Zweiter Achsenpunkt → commit (Auswahl an der Achse spiegeln). Null-Achse
|
||||
// verwerfen (kein Spiegeln ohne Richtung).
|
||||
if (segLen(s.a, pt) < EPS) {
|
||||
return [{ phase: "done", lastPoint: null } as MirrorDone, { draft: null, done: true }];
|
||||
}
|
||||
const a = s.a;
|
||||
const sel = s.sel;
|
||||
return [
|
||||
{ phase: "done", lastPoint: null } as MirrorDone,
|
||||
{
|
||||
draft: null,
|
||||
done: true,
|
||||
commit: (p) => commitTransform(p, toTransformSel(sel), "mirror", [a, pt], "copy", 1),
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
onMove: (state, point, _snap, ctx): [CommandState, CommandResult] => {
|
||||
const s = state as MirrorState;
|
||||
if (s.phase !== "second") return [s, { draft: null }];
|
||||
const ns: MirrorSecond = { ...s, cursor: point };
|
||||
return [ns, { draft: mirrorDraft(ctx.project, s.sel, s.a, point) }];
|
||||
},
|
||||
|
||||
onConfirm: (): [CommandState, CommandResult] => idle(),
|
||||
onCancel: (): [CommandState, CommandResult] => idle(),
|
||||
};
|
||||
@@ -0,0 +1,368 @@
|
||||
// Opening — Öffnung (Fenster/Tür) als Engine-Befehl. Sie wird in eine BESTEHENDE
|
||||
// Wand gehostet. Ablauf:
|
||||
// 1) „Wand für die Öffnung wählen:" → Klick auf/nahe eine Wand (Host).
|
||||
// 2) „Position entlang der Wand:" → Klick auf der Wandachse ODER getippter
|
||||
// Abstand vom Wand-Startpunkt (Zahl). Die Position wird auf die Achse
|
||||
// projiziert; der HUD zeigt den Abstand vom Wandanfang.
|
||||
// 3) Commit: hängt eine `Opening` ans Projekt.
|
||||
//
|
||||
// Tab-Felder im Positionsschritt: Breite / Höhe / Brüstung (sillHeight) /
|
||||
// Schwenkwinkel (nur Tür). Optionen: Fenster⇄Tür-Umschalter, Anschlag
|
||||
// (Scharnier start/end), Aufschlagseite (links/rechts), Richtung (innen/außen).
|
||||
//
|
||||
// Bezeichner englisch, sichtbarer Text via t() (CONVENTIONS.md).
|
||||
|
||||
import type { Opening } from "../../model/types";
|
||||
import { uniqueId } from "../../tools/types";
|
||||
import type {
|
||||
Command,
|
||||
CommandContext,
|
||||
CommandField,
|
||||
CommandResult,
|
||||
CommandState,
|
||||
CmdOption,
|
||||
DraftShape,
|
||||
Project,
|
||||
ToolDraft,
|
||||
Vec2,
|
||||
} from "../types";
|
||||
|
||||
/** Default-Ebene (Kategorie) für Öffnungen: „21" Türen/Fenster (Fallback: aktive). */
|
||||
const OPENING_CATEGORY = "21";
|
||||
/** Toleranz (Modell-Meter) für das Picken einer Host-Wand per Klick. */
|
||||
const WALL_PICK_DIST = 0.6;
|
||||
/** Default-Maße (Meter). */
|
||||
const DEF_WINDOW = { width: 1.2, height: 1.2, sill: 0.9 };
|
||||
const DEF_DOOR = { width: 0.9, height: 2.1, sill: 0 };
|
||||
|
||||
const sub = (a: Vec2, b: Vec2): Vec2 => ({ x: a.x - b.x, y: a.y - b.y });
|
||||
const dot = (a: Vec2, b: Vec2): number => a.x * b.x + a.y * b.y;
|
||||
const add = (a: Vec2, b: Vec2): Vec2 => ({ x: a.x + b.x, y: a.y + b.y });
|
||||
const scale = (a: Vec2, s: number): Vec2 => ({ x: a.x * s, y: a.y * s });
|
||||
|
||||
/** Existiert die Öffnungs-Kategorie („21")? Sonst Fallback auf die aktive. */
|
||||
function openingCategory(ctx: CommandContext): string {
|
||||
const flat: { code: string }[] = [];
|
||||
const walk = (list: { code: string; children?: unknown[] }[]) => {
|
||||
for (const c of list) {
|
||||
flat.push({ code: c.code });
|
||||
if (c.children) walk(c.children as { code: string; children?: unknown[] }[]);
|
||||
}
|
||||
};
|
||||
walk(ctx.project.layers as { code: string; children?: unknown[] }[]);
|
||||
return flat.some((c) => c.code === OPENING_CATEGORY)
|
||||
? OPENING_CATEGORY
|
||||
: ctx.defaultCategoryCode;
|
||||
}
|
||||
|
||||
/** Auf die Wandachse projizierter Fußpunkt + Parameter t (0..len) + Distanz. */
|
||||
function projectOntoWall(
|
||||
wall: { start: Vec2; end: Vec2 },
|
||||
p: Vec2,
|
||||
): { t: number; foot: Vec2; dist: number } {
|
||||
const ax = sub(wall.end, wall.start);
|
||||
const len2 = dot(ax, ax) || 1e-9;
|
||||
const raw = dot(sub(p, wall.start), ax) / len2; // 0..1
|
||||
const t = Math.max(0, Math.min(1, raw));
|
||||
const foot = add(wall.start, scale(ax, t));
|
||||
const dist = Math.hypot(p.x - foot.x, p.y - foot.y);
|
||||
return { t: t * Math.sqrt(len2), foot, dist };
|
||||
}
|
||||
|
||||
/** Nächste Wand auf dem aktiven Geschoss zum Klickpunkt (innerhalb Toleranz). */
|
||||
function pickWall(
|
||||
ctx: CommandContext,
|
||||
p: Vec2,
|
||||
): { id: string; start: Vec2; end: Vec2 } | null {
|
||||
let best: { id: string; start: Vec2; end: Vec2; d: number } | null = null;
|
||||
for (const w of ctx.project.walls) {
|
||||
if (w.floorId !== ctx.level.id) continue;
|
||||
const { dist } = projectOntoWall(w, p);
|
||||
if (dist <= WALL_PICK_DIST && (!best || dist < best.d)) {
|
||||
best = { id: w.id, start: w.start, end: w.end, d: dist };
|
||||
}
|
||||
}
|
||||
return best ? { id: best.id, start: best.start, end: best.end } : null;
|
||||
}
|
||||
|
||||
/** Tab-Felder im Positionsschritt: Breite/Höhe/Brüstung (+ Winkel bei Tür). */
|
||||
const BASE_FIELDS: CommandField[] = [
|
||||
{ id: "width", labelKey: "cmd.field.width" },
|
||||
{ id: "height", labelKey: "cmd.field.height" },
|
||||
{ id: "sill", labelKey: "cmd.opening.sillField" },
|
||||
];
|
||||
const SWING_FIELD: CommandField = { id: "swing", labelKey: "cmd.opening.swingField" };
|
||||
|
||||
const KIND: CmdOption = { id: "kind", labelKey: "cmd.opening.kind", value: "window" };
|
||||
const HINGE: CmdOption = { id: "hinge", labelKey: "cmd.opening.hinge", value: "start" };
|
||||
const SIDE: CmdOption = { id: "side", labelKey: "cmd.opening.side", value: "left" };
|
||||
const DIR: CmdOption = { id: "dir", labelKey: "cmd.opening.dir", value: "in" };
|
||||
|
||||
interface OpIdle extends CommandState {
|
||||
phase: "wall";
|
||||
kind: "window" | "door";
|
||||
hinge: "start" | "end";
|
||||
side: "left" | "right";
|
||||
dir: "in" | "out";
|
||||
width: number;
|
||||
height: number;
|
||||
sill: number;
|
||||
swingAngle: number;
|
||||
}
|
||||
interface OpPos extends CommandState {
|
||||
phase: "pos";
|
||||
wallId: string;
|
||||
start: Vec2;
|
||||
end: Vec2;
|
||||
cursor: Vec2 | null;
|
||||
posAlong: number; // Abstand vom Wandanfang (Meter)
|
||||
kind: "window" | "door";
|
||||
hinge: "start" | "end";
|
||||
side: "left" | "right";
|
||||
dir: "in" | "out";
|
||||
width: number;
|
||||
height: number;
|
||||
sill: number;
|
||||
swingAngle: number;
|
||||
}
|
||||
type OpState = OpIdle | OpPos;
|
||||
|
||||
function initState(kind: "window" | "door" = "window"): OpIdle {
|
||||
const def = kind === "door" ? DEF_DOOR : DEF_WINDOW;
|
||||
return {
|
||||
phase: "wall",
|
||||
lastPoint: null,
|
||||
kind,
|
||||
hinge: "start",
|
||||
side: "left",
|
||||
dir: "in",
|
||||
width: def.width,
|
||||
height: def.height,
|
||||
sill: def.sill,
|
||||
swingAngle: 90,
|
||||
};
|
||||
}
|
||||
|
||||
/** Vorschau: Lücken-Balken entlang der Wand + Positionsmarke. */
|
||||
function posDraft(s: OpPos): ToolDraft {
|
||||
const ax = sub(s.end, s.start);
|
||||
const len = Math.hypot(ax.x, ax.y) || 1e-9;
|
||||
const u = { x: ax.x / len, y: ax.y / len };
|
||||
const n = { x: -u.y, y: u.x };
|
||||
const from = Math.max(0, Math.min(s.posAlong, len));
|
||||
const to = Math.max(from, Math.min(from + s.width, len));
|
||||
const a = add(s.start, scale(u, from));
|
||||
const b = add(s.start, scale(u, to));
|
||||
const half = 0.15; // Vorschau-Balken quer zur Wand
|
||||
const quad: Vec2[] = [
|
||||
add(a, scale(n, half)),
|
||||
add(b, scale(n, half)),
|
||||
add(b, scale(n, -half)),
|
||||
add(a, scale(n, -half)),
|
||||
];
|
||||
const preview: DraftShape[] = [{ kind: "poly", pts: quad, closed: true }];
|
||||
// Symbol-Andeutung: Fenster = Mittellinie, Tür = Blatt-Linie in Schwenkrichtung.
|
||||
if (s.kind === "window") {
|
||||
preview.push({ kind: "line", a, b });
|
||||
} else {
|
||||
const swingSign = s.side === "left" ? 1 : -1;
|
||||
const dirSign = s.dir === "in" ? 1 : -1;
|
||||
const hinge = s.hinge === "start" ? a : b;
|
||||
const leafEnd = add(hinge, scale(n, swingSign * dirSign * s.width));
|
||||
preview.push({ kind: "line", a: hinge, b: leafEnd });
|
||||
}
|
||||
const draft: ToolDraft = { preview, vertices: [a, b] };
|
||||
const mid = add(a, scale(u, (to - from) / 2));
|
||||
draft.hud = {
|
||||
at: s.cursor ?? mid,
|
||||
text: `${from.toFixed(2)} m · ${s.kind === "door" ? "Tür" : "Fenster"} ${(
|
||||
s.width * 100
|
||||
).toFixed(0)}×${(s.height * 100).toFixed(0)}`,
|
||||
};
|
||||
return draft;
|
||||
}
|
||||
|
||||
/** Hängt eine Öffnung ans Projekt (immutabel). */
|
||||
function appendOpening(p: Project, s: OpPos, ctx: CommandContext): Project {
|
||||
const len = Math.hypot(s.end.x - s.start.x, s.end.y - s.start.y);
|
||||
const width = Math.max(0.1, Math.min(s.width, len));
|
||||
const position = Math.max(0, Math.min(s.posAlong, Math.max(0, len - width)));
|
||||
const opening: Opening = {
|
||||
id: uniqueId("O"),
|
||||
type: "opening",
|
||||
hostWallId: s.wallId,
|
||||
categoryCode: openingCategory(ctx),
|
||||
kind: s.kind,
|
||||
position,
|
||||
width,
|
||||
height: s.height,
|
||||
sillHeight: s.kind === "door" ? 0 : s.sill,
|
||||
...(s.kind === "door"
|
||||
? { hinge: s.hinge, swing: s.side, openingDir: s.dir, swingAngle: s.swingAngle }
|
||||
: {}),
|
||||
};
|
||||
const openings = p.openings ? [...p.openings, opening] : [opening];
|
||||
return { ...p, openings };
|
||||
}
|
||||
|
||||
const idle = (from?: OpState): [CommandState, CommandResult] => [
|
||||
initState(from?.kind ?? "window"),
|
||||
{ draft: null, done: true },
|
||||
];
|
||||
|
||||
export const openingCommand: Command = {
|
||||
name: "opening",
|
||||
labelKey: "cmd.opening.label",
|
||||
floorOnly: true,
|
||||
prompt: (s) =>
|
||||
(s as OpState).phase === "pos" ? "cmd.opening.pos" : "cmd.opening.wall",
|
||||
accepts: (s) =>
|
||||
(s as OpState).phase === "pos"
|
||||
? ["point", "number", "option"]
|
||||
: ["point", "option"],
|
||||
options: (s): CmdOption[] => {
|
||||
const st = s as OpState;
|
||||
const kind: CmdOption = { ...KIND, value: st.kind };
|
||||
if (st.kind === "door") {
|
||||
return [
|
||||
kind,
|
||||
{ ...HINGE, value: st.hinge },
|
||||
{ ...SIDE, value: st.side },
|
||||
{ ...DIR, value: st.dir },
|
||||
];
|
||||
}
|
||||
return [kind];
|
||||
},
|
||||
init: (): OpIdle => initState("window"),
|
||||
|
||||
onInput: (state, input, ctx): [CommandState, CommandResult] => {
|
||||
const s = state as OpState;
|
||||
|
||||
if (input.kind === "option") {
|
||||
// Umschalter zyklen ihren Wert. Bei kind-Wechsel Default-Maße übernehmen.
|
||||
const next = { ...s } as OpState;
|
||||
if (input.id === "kind") {
|
||||
next.kind = s.kind === "window" ? "door" : "window";
|
||||
const def = next.kind === "door" ? DEF_DOOR : DEF_WINDOW;
|
||||
next.width = def.width;
|
||||
next.height = def.height;
|
||||
next.sill = def.sill;
|
||||
} else if (input.id === "hinge") {
|
||||
next.hinge = s.hinge === "start" ? "end" : "start";
|
||||
} else if (input.id === "side") {
|
||||
next.side = s.side === "left" ? "right" : "left";
|
||||
} else if (input.id === "dir") {
|
||||
next.dir = s.dir === "in" ? "out" : "in";
|
||||
}
|
||||
return [next, { draft: next.phase === "pos" ? posDraft(next as OpPos) : null }];
|
||||
}
|
||||
|
||||
if (input.kind === "number") {
|
||||
// Im Positionsschritt: getippte Zahl = Abstand vom Wandanfang (Meter).
|
||||
if (s.phase === "pos") {
|
||||
const ns: OpPos = { ...s, posAlong: input.value };
|
||||
return [ns, { draft: posDraft(ns) }];
|
||||
}
|
||||
return [s, { draft: null }];
|
||||
}
|
||||
|
||||
if (input.kind !== "point") {
|
||||
return [s, { draft: s.phase === "pos" ? posDraft(s as OpPos) : null }];
|
||||
}
|
||||
|
||||
const pt = input.point;
|
||||
if (s.phase === "wall") {
|
||||
const w = pickWall(ctx, pt);
|
||||
if (!w) return [s, { draft: null }]; // kein Treffer: Schritt wiederholen
|
||||
const proj = projectOntoWall(w, pt);
|
||||
const ns: OpPos = {
|
||||
phase: "pos",
|
||||
lastPoint: pt,
|
||||
wallId: w.id,
|
||||
start: w.start,
|
||||
end: w.end,
|
||||
cursor: pt,
|
||||
posAlong: proj.t,
|
||||
kind: s.kind,
|
||||
hinge: s.hinge,
|
||||
side: s.side,
|
||||
dir: s.dir,
|
||||
width: s.width,
|
||||
height: s.height,
|
||||
sill: s.sill,
|
||||
swingAngle: s.swingAngle,
|
||||
};
|
||||
return [ns, { draft: posDraft(ns) }];
|
||||
}
|
||||
|
||||
// Positionsschritt: Klick projiziert auf die Achse → committen.
|
||||
const proj = projectOntoWall(s, pt);
|
||||
const ns: OpPos = { ...s, posAlong: proj.t, cursor: pt };
|
||||
return [
|
||||
idle(s)[0],
|
||||
{ draft: null, done: true, commit: (p) => appendOpening(p, ns, ctx) },
|
||||
];
|
||||
},
|
||||
|
||||
onMove: (state, point): [CommandState, CommandResult] => {
|
||||
const s = state as OpState;
|
||||
if (s.phase !== "pos") return [s, { draft: null }];
|
||||
const proj = projectOntoWall(s, point);
|
||||
const ns: OpPos = { ...s, posAlong: proj.t, cursor: point };
|
||||
return [ns, { draft: posDraft(ns) }];
|
||||
},
|
||||
|
||||
onConfirm: (state, ctx): [CommandState, CommandResult] => {
|
||||
const s = state as OpState;
|
||||
if (s.phase === "pos") {
|
||||
const ns = s;
|
||||
return [
|
||||
idle(s)[0],
|
||||
{ draft: null, done: true, commit: (p) => appendOpening(p, ns, ctx) },
|
||||
];
|
||||
}
|
||||
return idle(s);
|
||||
},
|
||||
onCancel: (state): [CommandState, CommandResult] => idle(state as OpState),
|
||||
|
||||
fields: (state) => {
|
||||
const s = state as OpState;
|
||||
if (s.phase !== "pos") return [];
|
||||
return s.kind === "door" ? [...BASE_FIELDS, SWING_FIELD] : BASE_FIELDS;
|
||||
},
|
||||
// Getippte Tab-Felder sticky in den State übernehmen; kein Koordinatenpunkt.
|
||||
pointFromFields: (state, locks) => {
|
||||
const s = state as OpState;
|
||||
if ("width" in locks && locks.width > 0) s.width = locks.width;
|
||||
if ("height" in locks && locks.height > 0) s.height = locks.height;
|
||||
if ("sill" in locks && locks.sill >= 0) s.sill = locks.sill;
|
||||
if ("swing" in locks && locks.swing > 0) s.swingAngle = locks.swing;
|
||||
return null;
|
||||
},
|
||||
fieldValues: (state): Record<string, number> => {
|
||||
const s = state as OpState;
|
||||
const out: Record<string, number> = {
|
||||
width: s.width,
|
||||
height: s.height,
|
||||
sill: s.sill,
|
||||
};
|
||||
if (s.kind === "door") out.swing = s.swingAngle;
|
||||
return out;
|
||||
},
|
||||
};
|
||||
|
||||
/** „Fenster"-Variante: identischer Befehl, startet aber im Fenster-Modus. */
|
||||
export const fensterCommand: Command = {
|
||||
...openingCommand,
|
||||
name: "fenster",
|
||||
labelKey: "cmd.opening.windowLabel",
|
||||
init: (): OpIdle => initState("window"),
|
||||
};
|
||||
|
||||
/** „Tür"-Variante: identischer Befehl, startet aber im Tür-Modus. */
|
||||
export const tuerCommand: Command = {
|
||||
...openingCommand,
|
||||
name: "tuer",
|
||||
labelKey: "cmd.opening.doorLabel",
|
||||
init: (): OpIdle => initState("door"),
|
||||
};
|
||||
@@ -44,21 +44,34 @@ function polyNextFromFields(last: Vec2, locks: Record<string, number>, cursor: V
|
||||
|
||||
interface PolyIdle extends CommandState {
|
||||
phase: "start";
|
||||
/** Gewählter Schließ-Modus (bleibt über den Befehl hinweg erhalten). */
|
||||
closed: boolean;
|
||||
}
|
||||
interface PolyDrawing extends CommandState {
|
||||
phase: "next";
|
||||
points: Vec2[];
|
||||
cursor: Vec2 | null;
|
||||
/** Soll der committete Zug als Ring (geschlossen) gelten? Default: offen. */
|
||||
closed: boolean;
|
||||
}
|
||||
type PolyState = PolyIdle | PolyDrawing;
|
||||
|
||||
/** Sofort-schließen-Aktion (nur ≥3 Punkte): committet als Ring und beendet. */
|
||||
const CLOSE: CmdOption = { id: "close", labelKey: "cmd.polyline.close" };
|
||||
const UNDO: CmdOption = { id: "undo", labelKey: "cmd.polyline.undo" };
|
||||
/** Toggle-Option: Ergebnis geschlossen (Ring) oder offen — im Feld-Row sichtbar. */
|
||||
const closedOption = (closed: boolean): CmdOption => ({
|
||||
id: "mode",
|
||||
labelKey: "cmd.polyline.closedOpt",
|
||||
value: closed ? "on" : "off",
|
||||
});
|
||||
|
||||
/** Vorschau (Zug + Gummiband zum Cursor) + HUD (Länge·Winkel des letzten Segments). */
|
||||
function polyDraft(points: Vec2[], cursor: Vec2 | null): ToolDraft {
|
||||
function polyDraft(points: Vec2[], cursor: Vec2 | null, closed = false): ToolDraft {
|
||||
const pts = cursor ? [...points, cursor] : points;
|
||||
const preview: DraftShape[] = [{ kind: "poly", pts, closed: false }];
|
||||
// Ring nur ab 3 Punkten zeigen; darunter bleibt die Vorschau offen.
|
||||
const showClosed = closed && pts.length >= 3;
|
||||
const preview: DraftShape[] = [{ kind: "poly", pts, closed: showClosed }];
|
||||
const draft: ToolDraft = { preview, vertices: points };
|
||||
const last = points[points.length - 1];
|
||||
if (cursor && last && segLen(last, cursor) >= EPS) {
|
||||
@@ -89,8 +102,9 @@ function appendPolyline(
|
||||
return { ...p, drawings2d: [...p.drawings2d, d] };
|
||||
}
|
||||
|
||||
const idle = (): [CommandState, CommandResult] => [
|
||||
{ phase: "start", lastPoint: null },
|
||||
/** Ruhezustand; behält den zuletzt gewählten Schließ-Modus bei. */
|
||||
const idle = (closed = false): [CommandState, CommandResult] => [
|
||||
{ phase: "start", lastPoint: null, closed } as PolyIdle,
|
||||
{ draft: null, done: true },
|
||||
];
|
||||
|
||||
@@ -98,77 +112,95 @@ export const polylineCommand: Command = {
|
||||
name: "polyline",
|
||||
labelKey: "cmd.polyline.label",
|
||||
prompt: (s) => ((s as PolyState).phase === "next" ? "cmd.polyline.next" : "cmd.polyline.start"),
|
||||
accepts: (s) =>
|
||||
(s as PolyState).phase === "next" ? ["point", "number", "option"] : ["point", "number"],
|
||||
// Auch im Startschritt darf der Schließ-Modus gewählt werden.
|
||||
accepts: () => ["point", "number", "option"],
|
||||
options: (s) => {
|
||||
const ps = s as PolyState;
|
||||
if (ps.phase !== "next") return [];
|
||||
return ps.points.length >= 2 ? [CLOSE, UNDO] : [UNDO];
|
||||
const toggle = closedOption(ps.closed);
|
||||
if (ps.phase !== "next") return [toggle];
|
||||
return ps.points.length >= 2 ? [CLOSE, toggle, UNDO] : [toggle, UNDO];
|
||||
},
|
||||
init: (): PolyIdle => ({ phase: "start", lastPoint: null }),
|
||||
init: (): PolyIdle => ({ phase: "start", lastPoint: null, closed: false }),
|
||||
|
||||
onInput: (state, input, ctx): [CommandState, CommandResult] => {
|
||||
const s = state as PolyState;
|
||||
|
||||
if (input.kind === "option") {
|
||||
// Schließ-Modus umschalten (Tastenkürzel „C" bzw. Klick im Feld-Row);
|
||||
// in jedem Schritt verfügbar, ohne den laufenden Zug zu beenden.
|
||||
if (input.id === "mode") {
|
||||
const ns = { ...s, closed: !s.closed } as PolyState;
|
||||
return [ns, { draft: s.phase === "next" ? polyDraft(s.points, s.cursor, ns.closed) : null }];
|
||||
}
|
||||
if (s.phase !== "next") return [s, { draft: null }];
|
||||
if (input.id === "undo") {
|
||||
const pts = s.points.slice(0, -1);
|
||||
if (pts.length === 0) return [{ phase: "start", lastPoint: null }, { draft: null }];
|
||||
const ns: PolyDrawing = { phase: "next", points: pts, cursor: s.cursor, lastPoint: pts[pts.length - 1] };
|
||||
return [ns, { draft: polyDraft(pts, s.cursor) }];
|
||||
if (pts.length === 0)
|
||||
return [{ phase: "start", lastPoint: null, closed: s.closed } as PolyIdle, { draft: null }];
|
||||
const ns: PolyDrawing = {
|
||||
phase: "next",
|
||||
points: pts,
|
||||
cursor: s.cursor,
|
||||
lastPoint: pts[pts.length - 1],
|
||||
closed: s.closed,
|
||||
};
|
||||
return [ns, { draft: polyDraft(pts, s.cursor, s.closed) }];
|
||||
}
|
||||
// Sofort schließen: committet als Ring (unabhängig vom Toggle-Zustand).
|
||||
if (input.id === "close" && s.points.length >= 3) {
|
||||
const pts = s.points;
|
||||
return [
|
||||
{ phase: "start", lastPoint: null },
|
||||
{ phase: "start", lastPoint: null, closed: s.closed } as PolyIdle,
|
||||
{ draft: null, done: true, commit: (p) => appendPolyline(p, pts, true, ctx) },
|
||||
];
|
||||
}
|
||||
return [s, { draft: polyDraft(s.points, s.cursor) }];
|
||||
return [s, { draft: polyDraft(s.points, s.cursor, s.closed) }];
|
||||
}
|
||||
|
||||
if (input.kind !== "point") {
|
||||
return [s, { draft: s.phase === "next" ? polyDraft(s.points, s.cursor) : null }];
|
||||
return [s, { draft: s.phase === "next" ? polyDraft(s.points, s.cursor, s.closed) : null }];
|
||||
}
|
||||
const pt = input.point;
|
||||
if (s.phase !== "next") {
|
||||
const ns: PolyDrawing = { phase: "next", points: [pt], cursor: pt, lastPoint: pt };
|
||||
return [ns, { draft: polyDraft([pt], pt) }];
|
||||
const ns: PolyDrawing = { phase: "next", points: [pt], cursor: pt, lastPoint: pt, closed: s.closed };
|
||||
return [ns, { draft: polyDraft([pt], pt, s.closed) }];
|
||||
}
|
||||
// Klick nahe Startpunkt → schließen.
|
||||
// Klick nahe Startpunkt → schließen (committet als Ring).
|
||||
if (s.points.length >= 3 && segLen(s.points[0], pt) < CLOSE_HIT) {
|
||||
const pts = s.points;
|
||||
return [
|
||||
{ phase: "start", lastPoint: null },
|
||||
{ phase: "start", lastPoint: null, closed: s.closed } as PolyIdle,
|
||||
{ draft: null, done: true, commit: (p) => appendPolyline(p, pts, true, ctx) },
|
||||
];
|
||||
}
|
||||
const points = [...s.points, pt];
|
||||
const ns: PolyDrawing = { phase: "next", points, cursor: pt, lastPoint: pt };
|
||||
return [ns, { draft: polyDraft(points, pt) }];
|
||||
const ns: PolyDrawing = { phase: "next", points, cursor: pt, lastPoint: pt, closed: s.closed };
|
||||
return [ns, { draft: polyDraft(points, pt, s.closed) }];
|
||||
},
|
||||
|
||||
onMove: (state, point): [CommandState, CommandResult] => {
|
||||
const s = state as PolyState;
|
||||
if (s.phase !== "next") return [s, { draft: null }];
|
||||
const ns: PolyDrawing = { ...s, cursor: point };
|
||||
return [ns, { draft: polyDraft(s.points, point) }];
|
||||
return [ns, { draft: polyDraft(s.points, point, s.closed) }];
|
||||
},
|
||||
|
||||
// Enter/Space/Rechtsklick: offenen Zug beenden (≥2 Punkte) und committen.
|
||||
// Enter/Space/Rechtsklick: Zug beenden (≥2 Punkte). Der Schließ-Modus des
|
||||
// Toggles bestimmt, ob als Ring oder offen committet wird. Ein Ring braucht
|
||||
// ≥3 Punkte, sonst fällt er auf „offen" zurück.
|
||||
onConfirm: (state, ctx): [CommandState, CommandResult] => {
|
||||
const s = state as PolyState;
|
||||
if (s.phase === "next" && s.points.length >= 2) {
|
||||
const pts = s.points;
|
||||
const close = s.closed && pts.length >= 3;
|
||||
return [
|
||||
{ phase: "start", lastPoint: null },
|
||||
{ draft: null, done: true, commit: (p) => appendPolyline(p, pts, false, ctx) },
|
||||
{ phase: "start", lastPoint: null, closed: s.closed } as PolyIdle,
|
||||
{ draft: null, done: true, commit: (p) => appendPolyline(p, pts, close, ctx) },
|
||||
];
|
||||
}
|
||||
return idle();
|
||||
return idle(s.closed);
|
||||
},
|
||||
onCancel: (): [CommandState, CommandResult] => idle(),
|
||||
onCancel: (state): [CommandState, CommandResult] => idle((state as PolyState).closed),
|
||||
|
||||
// Tab-Feld-Zyklus nur im „next"-Schritt: Länge + Winkel relativ zum letzten
|
||||
// Punkt. (Erster Punkt = freie Koordinate, kein Feld-Modus.)
|
||||
@@ -178,4 +210,13 @@ export const polylineCommand: Command = {
|
||||
if (s.phase !== "next" || s.points.length === 0) return null;
|
||||
return polyNextFromFields(s.points[s.points.length - 1], locks, cursor);
|
||||
},
|
||||
fieldValues: (state, _locks, cursor): Record<string, number> => {
|
||||
const s = state as PolyState;
|
||||
if (s.phase !== "next" || s.points.length === 0 || !cursor) return {};
|
||||
const last = s.points[s.points.length - 1];
|
||||
return {
|
||||
length: segLen(last, cursor),
|
||||
angle: ((segAngleDeg(last, cursor) % 360) + 360) % 360,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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: [] };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
// Raum (Room / SIA-416-Fläche) als Engine-Befehl. Zwei Modi (togglebar über die
|
||||
// Option „Modus"):
|
||||
//
|
||||
// 1) „innen" (Default) — EIN Klick IN einen von Wänden umschlossenen Bereich.
|
||||
// Der Umriss wird über den reinen Rechenkern (geometry/roomBoundary,
|
||||
// `roomFromPointInside`) aus den Wänden des aktiven Geschosses automatisch
|
||||
// als lichte Innenkontur erkannt. Findet der Klick keine geschlossene Zelle,
|
||||
// passiert nichts (der Nutzer kann in den Manuell-Modus wechseln).
|
||||
//
|
||||
// 2) „manuell" — ein GESCHLOSSENER Umriss wird Punkt für Punkt gezeichnet
|
||||
// (wie eine geschlossene Polylinie); Klick auf den Start / Option „Schliessen"
|
||||
// / Enter (≥3 Punkte) committet den Raum.
|
||||
//
|
||||
// Der committete Raum bekommt SIA-Blatt HNF, einen Default-Namen und die
|
||||
// Kategorie „45 Räume" (Fallback: aktive Kategorie). Fläche/Umfang/Schwerpunkt
|
||||
// werden NICHT gespeichert (bei jedem Rendern aus `boundary` abgeleitet).
|
||||
//
|
||||
// Bezeichner englisch, sichtbarer Text via t() (CONVENTIONS.md). Einheit: METER.
|
||||
|
||||
import type { Room } from "../../model/types";
|
||||
import { wallTypeThickness } from "../../model/types";
|
||||
import { roomFromPointInside, type WallSegment } from "../../geometry/roomBoundary";
|
||||
import { polygonArea, centroid } from "../../geometry/roomArea";
|
||||
import { docFromText } from "../../text/richText";
|
||||
import { uniqueId } from "../../tools/types";
|
||||
import type {
|
||||
Command,
|
||||
CommandContext,
|
||||
CommandField,
|
||||
CommandResult,
|
||||
CommandState,
|
||||
CmdOption,
|
||||
DraftShape,
|
||||
Project,
|
||||
ToolDraft,
|
||||
Vec2,
|
||||
} from "../types";
|
||||
|
||||
const EPS = 1e-6;
|
||||
const DEG = Math.PI / 180;
|
||||
const CLOSE_HIT = 0.08; // Modell-Meter: Klick nahe Startpunkt schließt den Umriss
|
||||
/** Default-Ebene (Kategorie) für Räume: „60" Räume (Fallback: aktive). */
|
||||
const ROOM_CATEGORY = "60";
|
||||
/** Default-Raumfarbe (falls Kategorie nicht auflösbar). */
|
||||
const ROOM_COLOR = "#5a7a9a";
|
||||
|
||||
const segLen = (a: Vec2, b: Vec2): number => Math.hypot(b.x - a.x, b.y - a.y);
|
||||
const segAngleDeg = (a: Vec2, b: Vec2): number =>
|
||||
(Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI;
|
||||
|
||||
/** Existiert die Raum-Kategorie („45")? Sonst Fallback auf die aktive Kategorie. */
|
||||
function roomCategory(ctx: CommandContext): string {
|
||||
const flat: { code: string }[] = [];
|
||||
const walk = (list: { code: string; children?: unknown[] }[]) => {
|
||||
for (const c of list) {
|
||||
flat.push({ code: c.code });
|
||||
if (c.children) walk(c.children as { code: string; children?: unknown[] }[]);
|
||||
}
|
||||
};
|
||||
walk(ctx.project.layers as { code: string; children?: unknown[] }[]);
|
||||
return flat.some((c) => c.code === ROOM_CATEGORY)
|
||||
? ROOM_CATEGORY
|
||||
: ctx.defaultCategoryCode;
|
||||
}
|
||||
|
||||
/** Farbe der Raum-Kategorie (Ebene) — sonst Default. */
|
||||
function roomColor(ctx: CommandContext, code: string): string {
|
||||
const flat: { code: string; color?: string; children?: unknown[] }[] = [];
|
||||
const walk = (list: { code: string; color?: string; children?: unknown[] }[]) => {
|
||||
for (const c of list) {
|
||||
flat.push(c);
|
||||
if (c.children)
|
||||
walk(
|
||||
c.children as {
|
||||
code: string;
|
||||
color?: string;
|
||||
children?: unknown[];
|
||||
}[],
|
||||
);
|
||||
}
|
||||
};
|
||||
walk(
|
||||
ctx.project.layers as {
|
||||
code: string;
|
||||
color?: string;
|
||||
children?: unknown[];
|
||||
}[],
|
||||
);
|
||||
return flat.find((c) => c.code === code)?.color ?? ROOM_COLOR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wände des aktiven Geschosses in die vom Rechenkern erwartete Form bringen:
|
||||
* Mittellinie (start→end) + Gesamtdicke des Wandtyps. Grafik-2D-Geometrie und
|
||||
* andere Geschosse bleiben außen vor.
|
||||
*/
|
||||
function wallsOfFloor(ctx: CommandContext): WallSegment[] {
|
||||
const { project, level } = ctx;
|
||||
const out: WallSegment[] = [];
|
||||
for (const w of project.walls) {
|
||||
if (w.floorId !== level.id) continue;
|
||||
const wt = project.wallTypes.find((t) => t.id === w.wallTypeId);
|
||||
const thickness = wt ? wallTypeThickness(wt) : 0.2;
|
||||
out.push({ a: w.start, b: w.end, thickness });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Baut einen Raum aus einem fertigen Umriss (immutabel; keine abgeleiteten Felder). */
|
||||
function buildRoom(ctx: CommandContext, boundary: Vec2[]): Room {
|
||||
const code = roomCategory(ctx);
|
||||
const name = defaultRoomName(ctx);
|
||||
return {
|
||||
id: uniqueId("RM"),
|
||||
type: "room",
|
||||
floorId: ctx.level.id,
|
||||
categoryCode: code,
|
||||
siaCategory: "HNF",
|
||||
name,
|
||||
boundary,
|
||||
color: roomColor(ctx, code),
|
||||
// Stempel-Anker EINMALIG auf den Zentroid setzen (danach frei verschiebbar,
|
||||
// bleibt beim Ändern der Kontur an seiner Stelle).
|
||||
stampAnchor: centroid(boundary),
|
||||
// Frei editierbarer Stempel-Text: ein zentrierter Absatz mit dem Raumnamen.
|
||||
stampDoc: { ...docFromText(name), paragraphs: docFromText(name).paragraphs.map((p) => ({ ...p, align: "center" as const })) },
|
||||
};
|
||||
}
|
||||
|
||||
/** Fortlaufender Default-Name „Raum N" (N = Anzahl vorhandener Räume + 1). */
|
||||
function defaultRoomName(ctx: CommandContext): string {
|
||||
const n = (ctx.project.rooms ?? []).length + 1;
|
||||
return `Raum ${n}`;
|
||||
}
|
||||
|
||||
/** Hängt einen Raum ans Projekt (immutabel); entartete Umrisse werden verworfen. */
|
||||
function appendRoom(p: Project, boundary: Vec2[], ctx: CommandContext): Project {
|
||||
if (boundary.length < 3 || polygonArea(boundary) < 1e-4) return p;
|
||||
const room = buildRoom(ctx, boundary);
|
||||
const rooms = p.rooms ? [...p.rooms, room] : [room];
|
||||
return { ...p, rooms };
|
||||
}
|
||||
|
||||
// ── Zustand ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type RoomMode = "inside" | "manual";
|
||||
|
||||
interface RoomIdle extends CommandState {
|
||||
phase: "start";
|
||||
mode: RoomMode;
|
||||
}
|
||||
interface RoomDrawing extends CommandState {
|
||||
phase: "draw";
|
||||
mode: RoomMode;
|
||||
points: Vec2[];
|
||||
cursor: Vec2 | null;
|
||||
}
|
||||
type RoomCmdState = RoomIdle | RoomDrawing;
|
||||
|
||||
const MODE: CmdOption = { id: "mode", labelKey: "cmd.room.mode", value: "inside" };
|
||||
const CLOSE: CmdOption = { id: "close", labelKey: "cmd.polyline.close" };
|
||||
const UNDO: CmdOption = { id: "undo", labelKey: "cmd.polyline.undo" };
|
||||
|
||||
/** Tab-Felder je weiteren Punkt (Manuell-Modus): Länge + Winkel relativ zum letzten. */
|
||||
const ROOM_FIELDS: CommandField[] = [
|
||||
{ id: "length", labelKey: "cmd.field.length" },
|
||||
{ id: "angle", labelKey: "cmd.field.angle" },
|
||||
];
|
||||
|
||||
function nextFromFields(
|
||||
last: Vec2,
|
||||
locks: Record<string, number>,
|
||||
cursor: Vec2 | null,
|
||||
): Vec2 {
|
||||
const ref = cursor ?? last;
|
||||
const length = "length" in locks ? locks.length : segLen(last, ref);
|
||||
const angleDeg = "angle" in locks ? locks.angle : segAngleDeg(last, ref);
|
||||
const ang = angleDeg * DEG;
|
||||
return { x: last.x + Math.cos(ang) * length, y: last.y + Math.sin(ang) * length };
|
||||
}
|
||||
|
||||
/** Vorschau des manuellen Umrisses (geschlossener Ring ab 3 Punkten) + HUD. */
|
||||
function manualDraft(points: Vec2[], cursor: Vec2 | null): ToolDraft {
|
||||
const pts = cursor ? [...points, cursor] : points;
|
||||
const showClosed = pts.length >= 3;
|
||||
const preview: DraftShape[] = [{ kind: "poly", pts, closed: showClosed }];
|
||||
const draft: ToolDraft = { preview, vertices: points };
|
||||
const last = points[points.length - 1];
|
||||
if (cursor && last && segLen(last, cursor) >= EPS) {
|
||||
draft.hud = {
|
||||
at: cursor,
|
||||
text: `${segLen(last, cursor).toFixed(2)} m · ${Math.abs(
|
||||
(segAngleDeg(last, cursor) + 360) % 360,
|
||||
).toFixed(0)}°`,
|
||||
};
|
||||
}
|
||||
return draft;
|
||||
}
|
||||
|
||||
const idle = (mode: RoomMode): [CommandState, CommandResult] => [
|
||||
{ phase: "start", lastPoint: null, mode } as RoomIdle,
|
||||
{ draft: null, done: true },
|
||||
];
|
||||
|
||||
export const roomCommand: Command = {
|
||||
name: "room",
|
||||
labelKey: "cmd.room.label",
|
||||
// Räume leben auf Geschossen — wie Wand/Decke nur dort aktiv.
|
||||
floorOnly: true,
|
||||
prompt: (s) => {
|
||||
const st = s as RoomCmdState;
|
||||
if (st.phase === "draw") return "cmd.room.next";
|
||||
return st.mode === "inside" ? "cmd.room.inside" : "cmd.room.first";
|
||||
},
|
||||
accepts: (s) =>
|
||||
(s as RoomCmdState).phase === "draw"
|
||||
? ["point", "number", "option"]
|
||||
: ["point", "option"],
|
||||
options: (s): CmdOption[] => {
|
||||
const st = s as RoomCmdState;
|
||||
const modeOpt: CmdOption = { ...MODE, value: st.mode };
|
||||
if (st.phase !== "draw") return [modeOpt];
|
||||
return st.points.length >= 3 ? [modeOpt, CLOSE, UNDO] : [modeOpt, UNDO];
|
||||
},
|
||||
init: (): RoomIdle => ({ phase: "start", lastPoint: null, mode: "inside" }),
|
||||
|
||||
onInput: (state, input, ctx): [CommandState, CommandResult] => {
|
||||
const s = state as RoomCmdState;
|
||||
|
||||
if (input.kind === "option") {
|
||||
if (input.id === "mode") {
|
||||
// Modus umschalten; ein laufender Manuell-Entwurf wird verworfen.
|
||||
const mode: RoomMode = s.mode === "inside" ? "manual" : "inside";
|
||||
return idle(mode);
|
||||
}
|
||||
if (s.phase !== "draw") return [s, { draft: null }];
|
||||
if (input.id === "undo") {
|
||||
const pts = s.points.slice(0, -1);
|
||||
if (pts.length === 0)
|
||||
return [
|
||||
{ phase: "start", lastPoint: null, mode: s.mode } as RoomIdle,
|
||||
{ draft: null },
|
||||
];
|
||||
const ns: RoomDrawing = {
|
||||
phase: "draw",
|
||||
mode: s.mode,
|
||||
points: pts,
|
||||
cursor: s.cursor,
|
||||
lastPoint: pts[pts.length - 1],
|
||||
};
|
||||
return [ns, { draft: manualDraft(pts, s.cursor) }];
|
||||
}
|
||||
if (input.id === "close" && s.points.length >= 3) {
|
||||
const pts = s.points;
|
||||
return [
|
||||
{ phase: "start", lastPoint: null, mode: s.mode } as RoomIdle,
|
||||
{ draft: null, done: true, commit: (p) => appendRoom(p, pts, ctx) },
|
||||
];
|
||||
}
|
||||
return [s, { draft: s.phase === "draw" ? manualDraft(s.points, s.cursor) : null }];
|
||||
}
|
||||
|
||||
if (input.kind !== "point") {
|
||||
return [
|
||||
s,
|
||||
{ draft: s.phase === "draw" ? manualDraft(s.points, s.cursor) : null },
|
||||
];
|
||||
}
|
||||
|
||||
const pt = input.point;
|
||||
|
||||
// ── Modus „innen": ein Klick → Umriss automatisch erkennen + committen ──
|
||||
if (s.mode === "inside" && s.phase !== "draw") {
|
||||
const boundary = roomFromPointInside(pt, wallsOfFloor(ctx));
|
||||
if (boundary && boundary.length >= 3) {
|
||||
return [
|
||||
{ phase: "start", lastPoint: null, mode: "inside" } as RoomIdle,
|
||||
{ draft: null, done: true, commit: (p) => appendRoom(p, boundary, ctx) },
|
||||
];
|
||||
}
|
||||
// Kein umschlossener Bereich getroffen → im Werkzeug bleiben (kein Commit).
|
||||
return [s, { draft: null }];
|
||||
}
|
||||
|
||||
// ── Modus „manuell": Umriss Punkt für Punkt ──
|
||||
if (s.phase !== "draw") {
|
||||
const ns: RoomDrawing = {
|
||||
phase: "draw",
|
||||
mode: "manual",
|
||||
points: [pt],
|
||||
cursor: pt,
|
||||
lastPoint: pt,
|
||||
};
|
||||
return [ns, { draft: manualDraft([pt], pt) }];
|
||||
}
|
||||
// Klick nahe Startpunkt → schließen + committen.
|
||||
if (s.points.length >= 3 && segLen(s.points[0], pt) < CLOSE_HIT) {
|
||||
const pts = s.points;
|
||||
return [
|
||||
{ phase: "start", lastPoint: null, mode: s.mode } as RoomIdle,
|
||||
{ draft: null, done: true, commit: (p) => appendRoom(p, pts, ctx) },
|
||||
];
|
||||
}
|
||||
const last = s.points[s.points.length - 1];
|
||||
if (segLen(last, pt) < EPS) {
|
||||
return [s, { draft: manualDraft(s.points, s.cursor) }];
|
||||
}
|
||||
const points = [...s.points, pt];
|
||||
const ns: RoomDrawing = {
|
||||
phase: "draw",
|
||||
mode: "manual",
|
||||
points,
|
||||
cursor: pt,
|
||||
lastPoint: pt,
|
||||
};
|
||||
return [ns, { draft: manualDraft(points, pt) }];
|
||||
},
|
||||
|
||||
onMove: (state, point): [CommandState, CommandResult] => {
|
||||
const s = state as RoomCmdState;
|
||||
if (s.phase !== "draw") return [s, { draft: null }];
|
||||
const ns: RoomDrawing = { ...s, cursor: point };
|
||||
return [ns, { draft: manualDraft(s.points, point) }];
|
||||
},
|
||||
|
||||
onConfirm: (state, ctx): [CommandState, CommandResult] => {
|
||||
const s = state as RoomCmdState;
|
||||
if (s.phase === "draw" && s.points.length >= 3) {
|
||||
const pts = s.points;
|
||||
return [
|
||||
{ phase: "start", lastPoint: null, mode: s.mode } as RoomIdle,
|
||||
{ draft: null, done: true, commit: (p) => appendRoom(p, pts, ctx) },
|
||||
];
|
||||
}
|
||||
return idle(s.mode);
|
||||
},
|
||||
onCancel: (state): [CommandState, CommandResult] =>
|
||||
idle((state as RoomCmdState).mode),
|
||||
|
||||
fields: (state) => ((state as RoomCmdState).phase === "draw" ? ROOM_FIELDS : []),
|
||||
pointFromFields: (state, locks, cursor) => {
|
||||
const s = state as RoomCmdState;
|
||||
if (s.phase !== "draw" || s.points.length === 0) return null;
|
||||
return nextFromFields(s.points[s.points.length - 1], locks, cursor);
|
||||
},
|
||||
fieldValues: (state, _locks, cursor): Record<string, number> => {
|
||||
const s = state as RoomCmdState;
|
||||
if (s.phase !== "draw" || s.points.length === 0 || !cursor) return {};
|
||||
const last = s.points[s.points.length - 1];
|
||||
return {
|
||||
length: segLen(last, cursor),
|
||||
angle: ((segAngleDeg(last, cursor) % 360) + 360) % 360,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,268 @@
|
||||
// Treppe (Stair) als Engine-Befehl. Ablauf:
|
||||
// 1) „Startpunkt der Treppe:" → Punkt (Antritt, unterste Stufe).
|
||||
// 2) „Laufrichtung / Ende:" → Punkt: definiert Richtung + Lauflänge des ersten
|
||||
// Laufs. Bei L folgt ein zweiter Lauf (Ende des zweiten Laufs); bei Wendel
|
||||
// ist der zweite Punkt der äußere Rand (Radius). Committet die Treppe.
|
||||
//
|
||||
// Optionen (togglebar): Grundform gerade/L/Wendel (Auf-/Ab-Umschalter). Tab-Felder
|
||||
// im Laufschritt: Breite / Stufenanzahl / Steighöhe (totalRise). Live-Vorschau
|
||||
// zeigt die Tritt-Linien + die Lauflinie.
|
||||
//
|
||||
// Herkunft der Treppen-Felder (CommandContext):
|
||||
// • floorId ← ctx.level.id (aktives Geschoss)
|
||||
// • categoryCode← "40 Treppen" (Fallback ctx.defaultCategoryCode)
|
||||
// • totalRise ← Geschosshöhe (ctx.level.floorHeight), damit die Treppe genau
|
||||
// ins nächste Geschoss steigt (geschossübergreifend).
|
||||
//
|
||||
// Bezeichner englisch, sichtbarer Text via t() (CONVENTIONS.md).
|
||||
|
||||
import type { Stair, StairShape, Vec2 as MVec2 } from "../../model/types";
|
||||
import { stairGeometry, defaultStepCount } from "../../geometry/stair";
|
||||
import { uniqueId } from "../../tools/types";
|
||||
import type {
|
||||
Command,
|
||||
CommandContext,
|
||||
CommandField,
|
||||
CommandResult,
|
||||
CommandState,
|
||||
CmdOption,
|
||||
DraftShape,
|
||||
Project,
|
||||
ToolDraft,
|
||||
Vec2,
|
||||
} from "../types";
|
||||
|
||||
/** Default-Ebene (Kategorie) für Treppen: „40" Treppen (Fallback: aktive). */
|
||||
const STAIR_CATEGORY = "40";
|
||||
/** Default-Laufbreite (Meter). */
|
||||
const DEF_WIDTH = 1.0;
|
||||
const EPS = 1e-6;
|
||||
|
||||
const sub = (a: Vec2, b: Vec2): Vec2 => ({ x: a.x - b.x, y: a.y - b.y });
|
||||
const lenOf = (a: Vec2): number => Math.hypot(a.x, a.y);
|
||||
const normv = (a: Vec2): Vec2 => {
|
||||
const l = lenOf(a) || 1e-9;
|
||||
return { x: a.x / l, y: a.y / l };
|
||||
};
|
||||
|
||||
/** Existiert die Treppen-Kategorie („40")? Sonst Fallback auf die aktive. */
|
||||
function stairCategory(ctx: CommandContext): string {
|
||||
const flat: { code: string }[] = [];
|
||||
const walk = (list: { code: string; children?: unknown[] }[]) => {
|
||||
for (const c of list) {
|
||||
flat.push({ code: c.code });
|
||||
if (c.children) walk(c.children as { code: string; children?: unknown[] }[]);
|
||||
}
|
||||
};
|
||||
walk(ctx.project.layers as { code: string; children?: unknown[] }[]);
|
||||
return flat.some((c) => c.code === STAIR_CATEGORY)
|
||||
? STAIR_CATEGORY
|
||||
: ctx.defaultCategoryCode;
|
||||
}
|
||||
|
||||
/** Geschosshöhe als Default-Steighöhe (Fallback 2.6 m). */
|
||||
function floorRise(ctx: CommandContext): number {
|
||||
return ctx.level.floorHeight && ctx.level.floorHeight > 0 ? ctx.level.floorHeight : 2.6;
|
||||
}
|
||||
|
||||
/** Tab-Felder im Laufschritt: Breite / Stufenanzahl / Steighöhe. */
|
||||
const RUN_FIELDS: CommandField[] = [
|
||||
{ id: "width", labelKey: "cmd.field.width" },
|
||||
{ id: "steps", labelKey: "cmd.stair.stepsField" },
|
||||
{ id: "rise", labelKey: "cmd.stair.riseField" },
|
||||
];
|
||||
|
||||
const SHAPE: CmdOption = { id: "shape", labelKey: "cmd.stair.shape", value: "straight" };
|
||||
const UPDOWN: CmdOption = { id: "updown", labelKey: "cmd.stair.updown", value: "up" };
|
||||
|
||||
interface StairIdle extends CommandState {
|
||||
phase: "start";
|
||||
shape: StairShape;
|
||||
up: boolean;
|
||||
width: number;
|
||||
steps: number | null; // null = automatisch
|
||||
rise: number | null; // null = Geschosshöhe
|
||||
}
|
||||
interface StairRun extends CommandState {
|
||||
phase: "run";
|
||||
shape: StairShape;
|
||||
up: boolean;
|
||||
width: number;
|
||||
steps: number | null;
|
||||
rise: number | null;
|
||||
start: Vec2;
|
||||
cursor: Vec2;
|
||||
}
|
||||
type StairCmdState = StairIdle | StairRun;
|
||||
|
||||
function initState(shape: StairShape = "straight"): StairIdle {
|
||||
return { phase: "start", lastPoint: null, shape, up: true, width: DEF_WIDTH, steps: null, rise: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut aus dem Laufzustand ein konkretes `Stair`-Objekt (aufgelöste Felder). Die
|
||||
* Richtung/Länge folgen aus start→cursor; bei L bricht der zweite Lauf um 90°
|
||||
* (links) ab, bei Wendel wird der Cursorabstand zum Radius.
|
||||
*/
|
||||
function buildStair(s: StairRun, ctx: CommandContext): Stair {
|
||||
const rise = s.rise ?? floorRise(ctx);
|
||||
const d = sub(s.cursor, s.start);
|
||||
const dist = Math.max(0.1, lenOf(d));
|
||||
const dir = dist > EPS ? normv(d) : ({ x: 1, y: 0 } as MVec2);
|
||||
const runLength = s.shape === "spiral" ? Math.max(0.5, dist) : dist;
|
||||
const steps = s.steps ?? defaultStepCount(rise, runLength);
|
||||
const base: Stair = {
|
||||
id: uniqueId("ST"),
|
||||
type: "stair",
|
||||
floorId: ctx.level.id,
|
||||
categoryCode: stairCategory(ctx),
|
||||
shape: s.shape,
|
||||
start: s.start,
|
||||
dir,
|
||||
runLength,
|
||||
width: s.width,
|
||||
totalRise: rise,
|
||||
stepCount: steps,
|
||||
up: s.up,
|
||||
};
|
||||
if (s.shape === "L") {
|
||||
base.run2Length = runLength; // symmetrischer zweiter Lauf
|
||||
base.turn = 1;
|
||||
} else if (s.shape === "spiral") {
|
||||
base.center = s.start;
|
||||
base.radius = dist;
|
||||
base.sweep = 270;
|
||||
// Startpunkt am äußeren Rand: Lauflinie beginnt hier.
|
||||
base.start = s.cursor;
|
||||
base.dir = dir;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
/** Vorschau: Tritt-Linien + Lauflinie + HUD (Länge · Stufenanzahl). */
|
||||
function runDraft(s: StairRun, ctx: CommandContext): ToolDraft {
|
||||
const stair = buildStair(s, ctx);
|
||||
const rise = stair.totalRise ?? floorRise(ctx);
|
||||
const geo = stairGeometry(stair, rise);
|
||||
const preview: DraftShape[] = [];
|
||||
for (const tr of geo.treads) preview.push({ kind: "poly", pts: tr.pts, closed: true });
|
||||
if (geo.landing) preview.push({ kind: "poly", pts: geo.landing, closed: true });
|
||||
if (geo.runLine.length >= 2) preview.push({ kind: "poly", pts: geo.runLine, closed: false });
|
||||
const draft: ToolDraft = { preview, vertices: [s.start] };
|
||||
draft.hud = {
|
||||
at: s.cursor,
|
||||
text: `${lenOf(sub(s.cursor, s.start)).toFixed(2)} m · ${stair.stepCount} STG`,
|
||||
};
|
||||
return draft;
|
||||
}
|
||||
|
||||
/** Hängt eine Treppe ans Projekt (immutabel). */
|
||||
function appendStair(p: Project, s: StairRun, ctx: CommandContext): Project {
|
||||
const stair = buildStair(s, ctx);
|
||||
const stairs = p.stairs ? [...p.stairs, stair] : [stair];
|
||||
return { ...p, stairs };
|
||||
}
|
||||
|
||||
const idle = (from?: StairCmdState): [CommandState, CommandResult] => [
|
||||
initState(from?.shape ?? "straight"),
|
||||
{ draft: null, done: true },
|
||||
];
|
||||
|
||||
export const stairCommand: Command = {
|
||||
name: "stair",
|
||||
labelKey: "cmd.stair.label",
|
||||
floorOnly: true,
|
||||
prompt: (s) => ((s as StairCmdState).phase === "run" ? "cmd.stair.run" : "cmd.stair.start"),
|
||||
accepts: (s) =>
|
||||
(s as StairCmdState).phase === "run"
|
||||
? ["point", "number", "option"]
|
||||
: ["point", "option"],
|
||||
options: (s): CmdOption[] => {
|
||||
const st = s as StairCmdState;
|
||||
return [
|
||||
{ ...SHAPE, value: st.shape },
|
||||
{ ...UPDOWN, value: st.up ? "up" : "down" },
|
||||
];
|
||||
},
|
||||
init: (): StairIdle => initState("straight"),
|
||||
|
||||
onInput: (state, input, ctx): [CommandState, CommandResult] => {
|
||||
const s = state as StairCmdState;
|
||||
|
||||
if (input.kind === "option") {
|
||||
const next = { ...s } as StairCmdState;
|
||||
if (input.id === "shape") {
|
||||
next.shape = s.shape === "straight" ? "L" : s.shape === "L" ? "spiral" : "straight";
|
||||
} else if (input.id === "updown") {
|
||||
next.up = !s.up;
|
||||
}
|
||||
return [next, { draft: next.phase === "run" ? runDraft(next as StairRun, ctx) : null }];
|
||||
}
|
||||
|
||||
if (input.kind !== "point") {
|
||||
return [s, { draft: s.phase === "run" ? runDraft(s as StairRun, ctx) : null }];
|
||||
}
|
||||
|
||||
const pt = input.point;
|
||||
if (s.phase !== "run") {
|
||||
const ns: StairRun = {
|
||||
phase: "run",
|
||||
lastPoint: pt,
|
||||
shape: s.shape,
|
||||
up: s.up,
|
||||
width: s.width,
|
||||
steps: s.steps,
|
||||
rise: s.rise,
|
||||
start: pt,
|
||||
cursor: pt,
|
||||
};
|
||||
return [ns, { draft: null }];
|
||||
}
|
||||
// Zweiter Punkt: Lauf definieren + committen (Null-Strecke ignorieren).
|
||||
if (lenOf(sub(pt, s.start)) < 0.05) {
|
||||
return [{ ...s }, { draft: runDraft(s, ctx) }];
|
||||
}
|
||||
const ns: StairRun = { ...s, cursor: pt };
|
||||
return [
|
||||
idle(s)[0],
|
||||
{ draft: null, done: true, commit: (p) => appendStair(p, ns, ctx) },
|
||||
];
|
||||
},
|
||||
|
||||
onMove: (state, point, _snap, ctx): [CommandState, CommandResult] => {
|
||||
const s = state as StairCmdState;
|
||||
if (s.phase !== "run") return [s, { draft: null }];
|
||||
const ns: StairRun = { ...s, cursor: point };
|
||||
return [ns, { draft: runDraft(ns, ctx) }];
|
||||
},
|
||||
|
||||
onConfirm: (state, ctx): [CommandState, CommandResult] => {
|
||||
const s = state as StairCmdState;
|
||||
if (s.phase === "run" && lenOf(sub(s.cursor, s.start)) >= 0.05) {
|
||||
const ns = s;
|
||||
return [
|
||||
idle(s)[0],
|
||||
{ draft: null, done: true, commit: (p) => appendStair(p, ns, ctx) },
|
||||
];
|
||||
}
|
||||
return idle(s);
|
||||
},
|
||||
onCancel: (state): [CommandState, CommandResult] => idle(state as StairCmdState),
|
||||
|
||||
fields: (state) => ((state as StairCmdState).phase === "run" ? RUN_FIELDS : []),
|
||||
// Getippte Tab-Felder sticky in den State übernehmen; kein Koordinatenpunkt.
|
||||
pointFromFields: (state, locks) => {
|
||||
const s = state as StairCmdState;
|
||||
if ("width" in locks && locks.width > 0) s.width = locks.width;
|
||||
if ("steps" in locks && locks.steps >= 2) s.steps = Math.round(locks.steps);
|
||||
if ("rise" in locks && locks.rise > 0) s.rise = locks.rise;
|
||||
return null;
|
||||
},
|
||||
fieldValues: (state, _locks, _cursor): Record<string, number> => {
|
||||
const s = state as StairCmdState;
|
||||
const out: Record<string, number> = { width: s.width };
|
||||
if (s.steps != null) out.steps = s.steps;
|
||||
if (s.rise != null) out.rise = s.rise;
|
||||
return out;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,242 @@
|
||||
// Trim — Quick-Trim („stutzen") für 2D-Kurven (line/polyline/rect), Rhino/
|
||||
// AutoCAD-artig (docs §2.10/§3.4 Tier-1). Modell:
|
||||
// • Cutters sind IMPLIZIT alle ANDEREN Drawing2D-Elemente der aktiven Ebene
|
||||
// (Quick-Trim — kein separater Cutter-Auswahlschritt; üblich + einfacher).
|
||||
// • Ein Schritt: der Nutzer klickt auf den WEGZUSCHNEIDENDEN Teil einer Kurve.
|
||||
// Wir bestimmen das getroffene Element (Nähe), zerlegen es an seinen
|
||||
// Schnittpunkten mit allen Cuttern und ENTFERNEN den angeklickten Abschnitt
|
||||
// (zwischen den beiden nächstgelegenen Schnittpunkten um den Klick — bzw. bis
|
||||
// zum Element-Ende, wenn nur einseitig ein Schnitt liegt).
|
||||
// • Wiederholend, bis Esc/Enter.
|
||||
//
|
||||
// Linie → wird gekürzt/aufgeteilt; Polylinie → der getroffene Abschnitt fällt
|
||||
// raus (kann die Polylinie in Stücke teilen). Reine Geometrie liegt im Kernel
|
||||
// (`trimPolyline`); hier nur Treffer-Bestimmung, Drawing2D ↔ {pts,closed} und der
|
||||
// Commit. Wände sind ausgenommen (nur Drawing2D).
|
||||
//
|
||||
// Bezeichner englisch, Kommentare/UI deutsch (CONVENTIONS.md).
|
||||
|
||||
import type { Drawing2D, Drawing2DGeom } from "../../model/types";
|
||||
import { uniqueId } from "../../tools/types";
|
||||
import {
|
||||
pointSegmentDistance,
|
||||
polylineEdges,
|
||||
trimPolyline,
|
||||
vecEqual,
|
||||
} from "../../geometry/kernel2d";
|
||||
import type {
|
||||
Command,
|
||||
CommandResult,
|
||||
CommandState,
|
||||
DraftShape,
|
||||
Project,
|
||||
Vec2,
|
||||
} from "../types";
|
||||
|
||||
const HIT_TOL = 0.25; // Modell-Meter: Pick-Toleranz für die Kurvenwahl
|
||||
|
||||
// ── Kurven-Abstraktion: line/polyline/rect → Punktliste + closed ──────────────
|
||||
|
||||
interface Curve {
|
||||
pts: Vec2[];
|
||||
closed: boolean;
|
||||
}
|
||||
|
||||
/** Wandelt eine trim-fähige 2D-Form in eine Punktliste (+closed) oder null. */
|
||||
function geomToCurve(g: Drawing2DGeom): Curve | null {
|
||||
if (g.shape === "line") return { pts: [g.a, g.b], closed: false };
|
||||
if (g.shape === "polyline") return { pts: g.pts, closed: g.closed };
|
||||
if (g.shape === "rect") {
|
||||
return {
|
||||
pts: [
|
||||
g.min,
|
||||
{ x: g.max.x, y: g.min.y },
|
||||
g.max,
|
||||
{ x: g.min.x, y: g.max.y },
|
||||
],
|
||||
closed: true,
|
||||
};
|
||||
}
|
||||
return null; // circle/arc/text: hier nicht trim-fähig (offener Punkt)
|
||||
}
|
||||
|
||||
/** Ist diese Form trim-fähig (line/polyline/rect)? */
|
||||
function isTrimmable(d: Drawing2D): boolean {
|
||||
return (
|
||||
d.geom.shape === "line" ||
|
||||
d.geom.shape === "polyline" ||
|
||||
d.geom.shape === "rect"
|
||||
);
|
||||
}
|
||||
|
||||
/** Nächstes trim-fähiges Drawing2D der Ebene zum Klickpunkt (innerhalb Toleranz). */
|
||||
function pickCurveAt(
|
||||
project: Project,
|
||||
levelId: string,
|
||||
pt: Vec2,
|
||||
): Drawing2D | null {
|
||||
let best: Drawing2D | null = null;
|
||||
let bestD = HIT_TOL;
|
||||
for (const d of project.drawings2d) {
|
||||
if (d.levelId !== levelId) continue;
|
||||
if (!isTrimmable(d)) continue;
|
||||
const curve = geomToCurve(d.geom);
|
||||
if (!curve) continue;
|
||||
for (const [a, b] of polylineEdges(curve.pts, curve.closed)) {
|
||||
const dd = pointSegmentDistance(pt, a, b);
|
||||
if (dd < bestD) {
|
||||
bestD = dd;
|
||||
best = d;
|
||||
}
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Alle anderen trim-fähigen Drawing2D der Ebene als Cutter-Kurven. */
|
||||
function cuttersFor(
|
||||
project: Project,
|
||||
levelId: string,
|
||||
exceptId: string,
|
||||
): Curve[] {
|
||||
const out: Curve[] = [];
|
||||
for (const d of project.drawings2d) {
|
||||
if (d.id === exceptId) continue;
|
||||
if (d.levelId !== levelId) continue;
|
||||
const curve = geomToCurve(d.geom);
|
||||
if (curve) out.push(curve);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Sind zwei Ketten geometrisch identisch (gleiches closed + gleiche Punkte)? */
|
||||
function sameChain(a: { pts: Vec2[]; closed: boolean }, b: Curve): boolean {
|
||||
if (a.closed !== b.closed || a.pts.length !== b.pts.length) return false;
|
||||
for (let i = 0; i < a.pts.length; i++) {
|
||||
if (!vecEqual(a.pts[i], b.pts[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Eine getrimmte Rest-Kette als passende Drawing2D-Geometrie. */
|
||||
function chainToGeom(chain: { pts: Vec2[]; closed: boolean }): Drawing2DGeom {
|
||||
if (chain.pts.length === 2 && !chain.closed) {
|
||||
return { shape: "line", a: chain.pts[0], b: chain.pts[1] };
|
||||
}
|
||||
return { shape: "polyline", pts: chain.pts, closed: chain.closed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Wendet einen Trim auf das Projekt an: ersetzt das getroffene Element durch
|
||||
* seine Rest-Ketten (Attribute geerbt). Bleibt nichts übrig, fällt es weg.
|
||||
*/
|
||||
function applyTrim(
|
||||
p: Project,
|
||||
target: Drawing2D,
|
||||
pick: Vec2,
|
||||
): Project {
|
||||
const curve = geomToCurve(target.geom);
|
||||
if (!curve) return p;
|
||||
const cutters = cuttersFor(p, target.levelId, target.id).map((c) => ({
|
||||
pts: c.pts,
|
||||
closed: c.closed,
|
||||
}));
|
||||
const rest = trimPolyline(curve.pts, curve.closed, cutters, pick);
|
||||
// Echtes No-op: genau eine Kette, identisch (gleiche Punkte) zur Quelle →
|
||||
// nichts wurde weggeschnitten (kein Schnitt mit Cuttern).
|
||||
if (rest.length === 1 && sameChain(rest[0], curve)) return p;
|
||||
const replacements: Drawing2D[] = rest
|
||||
.filter((c) => c.pts.length >= 2)
|
||||
.map((c) => ({ ...target, id: uniqueId("dr2d"), geom: chainToGeom(c) }));
|
||||
const others = p.drawings2d.filter((d) => d.id !== target.id);
|
||||
return { ...p, drawings2d: [...others, ...replacements] };
|
||||
}
|
||||
|
||||
/** Vorschau-Formen der NACH dem Trim verbleibenden Ketten (für den Draft). */
|
||||
function trimPreview(
|
||||
project: Project,
|
||||
target: Drawing2D,
|
||||
pick: Vec2,
|
||||
): DraftShape[] {
|
||||
const curve = geomToCurve(target.geom);
|
||||
if (!curve) return [];
|
||||
const cutters = cuttersFor(project, target.levelId, target.id).map((c) => ({
|
||||
pts: c.pts,
|
||||
closed: c.closed,
|
||||
}));
|
||||
const rest = trimPolyline(curve.pts, curve.closed, cutters, pick);
|
||||
const out: DraftShape[] = [];
|
||||
for (const c of rest) {
|
||||
if (c.pts.length < 2) continue;
|
||||
if (c.pts.length === 2 && !c.closed) {
|
||||
out.push({ kind: "line", a: c.pts[0], b: c.pts[1] });
|
||||
} else {
|
||||
out.push({ kind: "poly", pts: c.pts, closed: c.closed });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Zustand ───────────────────────────────────────────────────────────────────
|
||||
// Einziger Schritt „pick": auf den wegzuschneidenden Abschnitt klicken;
|
||||
// wiederholend, bis Esc/Enter. „done": beendet.
|
||||
interface TrimPick extends CommandState {
|
||||
phase: "pick";
|
||||
}
|
||||
interface TrimDone extends CommandState {
|
||||
phase: "done";
|
||||
}
|
||||
type TrimState = TrimPick | TrimDone;
|
||||
|
||||
const finish = (): [CommandState, CommandResult] => [
|
||||
{ phase: "done", lastPoint: null } as TrimDone,
|
||||
{ draft: null, done: true },
|
||||
];
|
||||
|
||||
export const trimCommand: Command = {
|
||||
name: "trim",
|
||||
labelKey: "cmd.trim.label",
|
||||
prompt: () => "cmd.trim.pick",
|
||||
accepts: () => ["point"],
|
||||
options: () => [],
|
||||
|
||||
init: (): TrimPick => ({ phase: "pick", lastPoint: null }),
|
||||
|
||||
onInput: (state, input, ctx): [CommandState, CommandResult] => {
|
||||
const s = state as TrimState;
|
||||
if (s.phase === "done") return finish();
|
||||
if (input.kind !== "point") return [s, { draft: null }];
|
||||
const target = pickCurveAt(ctx.project, ctx.level.id, input.point);
|
||||
if (!target) {
|
||||
// Daneben geklickt → Befehl bleibt aktiv (weiter trimmen).
|
||||
return [s, { draft: null }];
|
||||
}
|
||||
const pick = input.point;
|
||||
// Wiederholend: NICHT done — nach dem Commit bleibt „pick" aktiv.
|
||||
return [
|
||||
s,
|
||||
{ draft: null, commit: (p) => applyTrim(p, target, pick) },
|
||||
];
|
||||
},
|
||||
|
||||
onMove: (state, point, _snap, ctx): [CommandState, CommandResult] => {
|
||||
const s = state as TrimState;
|
||||
if (s.phase !== "pick") return [s, { draft: null }];
|
||||
const target = pickCurveAt(ctx.project, ctx.level.id, point);
|
||||
if (!target) return [s, { draft: null }];
|
||||
const preview = trimPreview(ctx.project, target, point);
|
||||
return [
|
||||
s,
|
||||
{
|
||||
draft: {
|
||||
preview,
|
||||
vertices: [],
|
||||
hud: { at: point, text: "✂" },
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
onConfirm: (): [CommandState, CommandResult] => finish(),
|
||||
onCancel: (): [CommandState, CommandResult] => finish(),
|
||||
};
|
||||