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",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mlightcad/libredwg-web": "^0.7.7",
|
"@mlightcad/libredwg-web": "^0.7.7",
|
||||||
|
"@tauri-apps/api": "^2.11.1",
|
||||||
"delaunator": "^5.1.0",
|
"delaunator": "^5.1.0",
|
||||||
"dxf-parser": "^1.1.2",
|
"dxf-parser": "^1.1.2",
|
||||||
"jspdf": "^4.2.1",
|
"jspdf": "^4.2.1",
|
||||||
@@ -21,6 +22,7 @@
|
|||||||
"three": "^0.169.0"
|
"three": "^0.169.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tauri-apps/cli": "^2.11.4",
|
||||||
"@types/react": "^18.3.11",
|
"@types/react": "^18.3.11",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@types/three": "^0.169.0",
|
"@types/three": "^0.169.0",
|
||||||
@@ -1539,6 +1541,248 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@tweenjs/tween.js": {
|
||||||
"version": "23.1.3",
|
"version": "23.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
|
||||||
|
|||||||
@@ -8,10 +8,14 @@
|
|||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest"
|
"test:watch": "vitest",
|
||||||
|
"tauri": "tauri",
|
||||||
|
"tauri:dev": "tauri dev",
|
||||||
|
"tauri:build": "tauri build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mlightcad/libredwg-web": "^0.7.7",
|
"@mlightcad/libredwg-web": "^0.7.7",
|
||||||
|
"@tauri-apps/api": "^2.11.1",
|
||||||
"delaunator": "^5.1.0",
|
"delaunator": "^5.1.0",
|
||||||
"dxf-parser": "^1.1.2",
|
"dxf-parser": "^1.1.2",
|
||||||
"jspdf": "^4.2.1",
|
"jspdf": "^4.2.1",
|
||||||
@@ -24,6 +28,7 @@
|
|||||||
"three": "^0.169.0"
|
"three": "^0.169.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tauri-apps/cli": "^2.11.4",
|
||||||
"@types/react": "^18.3.11",
|
"@types/react": "^18.3.11",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@types/three": "^0.169.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).
|
// 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";
|
import puppeteer from "puppeteer";
|
||||||
|
|
||||||
const b = await puppeteer.launch({
|
const b = await puppeteer.launch({
|
||||||
@@ -75,8 +84,8 @@ await endCommand();
|
|||||||
await sleep(150);
|
await sleep(150);
|
||||||
|
|
||||||
const before = await drawings();
|
const before = await drawings();
|
||||||
// Rechteck wählen: auf eine Rechteck-Kante klicken (untere Kante y=0, x=1).
|
// Rechteck wählen: auf die linke Kante klicken (x=0, y=2) — eindeutig auf dem Rand.
|
||||||
let [cx, cy] = await modelToClient(1, 0);
|
let [cx, cy] = await modelToClient(0, 2);
|
||||||
await p.mouse.click(cx, cy);
|
await p.mouse.click(cx, cy);
|
||||||
await sleep(150);
|
await sleep(150);
|
||||||
await ctrlPress("s");
|
await ctrlPress("s");
|
||||||
@@ -91,20 +100,20 @@ await p.reload({ waitUntil: "networkidle0" });
|
|||||||
await sleep(800);
|
await sleep(800);
|
||||||
await focusCmd();
|
await focusCmd();
|
||||||
await typeLine("line");
|
await typeLine("line");
|
||||||
await typeLine("-3,-3");
|
await typeLine("1,1");
|
||||||
await typeLine("0,-3");
|
await typeLine("4,1");
|
||||||
await focusCmd();
|
await focusCmd();
|
||||||
await typeLine("line");
|
await typeLine("line");
|
||||||
await typeLine("0,-3");
|
await typeLine("4,1");
|
||||||
await typeLine("0,0");
|
await typeLine("4,4");
|
||||||
await endCommand();
|
await endCommand();
|
||||||
await sleep(150);
|
await sleep(150);
|
||||||
const beforeJoin = await drawings();
|
const beforeJoin = await drawings();
|
||||||
// Beide wählen: erste klicken, dann Shift+zweite.
|
// Beide wählen: erste klicken (Mitte 2.5,1), dann Shift+zweite (Mitte 4,2.5).
|
||||||
let [ax, ay] = await modelToClient(-1.5, -3);
|
let [ax, ay] = await modelToClient(2.5, 1);
|
||||||
await p.mouse.click(ax, ay);
|
await p.mouse.click(ax, ay);
|
||||||
await sleep(100);
|
await sleep(120);
|
||||||
let [bx2, by2] = await modelToClient(0, -1.5);
|
let [bx2, by2] = await modelToClient(4, 2.5);
|
||||||
await p.keyboard.down("Shift");
|
await p.keyboard.down("Shift");
|
||||||
await p.mouse.click(bx2, by2);
|
await p.mouse.click(bx2, by2);
|
||||||
await p.keyboard.up("Shift");
|
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
|
// Tab-Feld-Zyklus nur im „radius"-Schritt: ein Radius-Feld; der gelockte Wert
|
||||||
// committet direkt (Punkt liegt auf dem Kreis, dist(center,point)=radius).
|
// committet direkt (Punkt liegt auf dem Kreis, dist(center,point)=radius).
|
||||||
fields: (state) => ((state as CircleState).phase === "radius" ? CIRCLE_FIELDS : []),
|
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) => {
|
pointFromFields: (state, locks, cursor) => {
|
||||||
const s = state as CircleState;
|
const s = state as CircleState;
|
||||||
if (s.phase !== "radius") return null;
|
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;
|
if (s.phase !== "end") return null;
|
||||||
return lineEndFromFields(s.a, locks, cursor);
|
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 {
|
interface PolyIdle extends CommandState {
|
||||||
phase: "start";
|
phase: "start";
|
||||||
|
/** Gewählter Schließ-Modus (bleibt über den Befehl hinweg erhalten). */
|
||||||
|
closed: boolean;
|
||||||
}
|
}
|
||||||
interface PolyDrawing extends CommandState {
|
interface PolyDrawing extends CommandState {
|
||||||
phase: "next";
|
phase: "next";
|
||||||
points: Vec2[];
|
points: Vec2[];
|
||||||
cursor: Vec2 | null;
|
cursor: Vec2 | null;
|
||||||
|
/** Soll der committete Zug als Ring (geschlossen) gelten? Default: offen. */
|
||||||
|
closed: boolean;
|
||||||
}
|
}
|
||||||
type PolyState = PolyIdle | PolyDrawing;
|
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 CLOSE: CmdOption = { id: "close", labelKey: "cmd.polyline.close" };
|
||||||
const UNDO: CmdOption = { id: "undo", labelKey: "cmd.polyline.undo" };
|
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). */
|
/** 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 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 draft: ToolDraft = { preview, vertices: points };
|
||||||
const last = points[points.length - 1];
|
const last = points[points.length - 1];
|
||||||
if (cursor && last && segLen(last, cursor) >= EPS) {
|
if (cursor && last && segLen(last, cursor) >= EPS) {
|
||||||
@@ -89,8 +102,9 @@ function appendPolyline(
|
|||||||
return { ...p, drawings2d: [...p.drawings2d, d] };
|
return { ...p, drawings2d: [...p.drawings2d, d] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const idle = (): [CommandState, CommandResult] => [
|
/** Ruhezustand; behält den zuletzt gewählten Schließ-Modus bei. */
|
||||||
{ phase: "start", lastPoint: null },
|
const idle = (closed = false): [CommandState, CommandResult] => [
|
||||||
|
{ phase: "start", lastPoint: null, closed } as PolyIdle,
|
||||||
{ draft: null, done: true },
|
{ draft: null, done: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -98,77 +112,95 @@ export const polylineCommand: Command = {
|
|||||||
name: "polyline",
|
name: "polyline",
|
||||||
labelKey: "cmd.polyline.label",
|
labelKey: "cmd.polyline.label",
|
||||||
prompt: (s) => ((s as PolyState).phase === "next" ? "cmd.polyline.next" : "cmd.polyline.start"),
|
prompt: (s) => ((s as PolyState).phase === "next" ? "cmd.polyline.next" : "cmd.polyline.start"),
|
||||||
accepts: (s) =>
|
// Auch im Startschritt darf der Schließ-Modus gewählt werden.
|
||||||
(s as PolyState).phase === "next" ? ["point", "number", "option"] : ["point", "number"],
|
accepts: () => ["point", "number", "option"],
|
||||||
options: (s) => {
|
options: (s) => {
|
||||||
const ps = s as PolyState;
|
const ps = s as PolyState;
|
||||||
if (ps.phase !== "next") return [];
|
const toggle = closedOption(ps.closed);
|
||||||
return ps.points.length >= 2 ? [CLOSE, UNDO] : [UNDO];
|
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] => {
|
onInput: (state, input, ctx): [CommandState, CommandResult] => {
|
||||||
const s = state as PolyState;
|
const s = state as PolyState;
|
||||||
|
|
||||||
if (input.kind === "option") {
|
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 (s.phase !== "next") return [s, { draft: null }];
|
||||||
if (input.id === "undo") {
|
if (input.id === "undo") {
|
||||||
const pts = s.points.slice(0, -1);
|
const pts = s.points.slice(0, -1);
|
||||||
if (pts.length === 0) return [{ phase: "start", lastPoint: null }, { draft: null }];
|
if (pts.length === 0)
|
||||||
const ns: PolyDrawing = { phase: "next", points: pts, cursor: s.cursor, lastPoint: pts[pts.length - 1] };
|
return [{ phase: "start", lastPoint: null, closed: s.closed } as PolyIdle, { draft: null }];
|
||||||
return [ns, { draft: polyDraft(pts, s.cursor) }];
|
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) {
|
if (input.id === "close" && s.points.length >= 3) {
|
||||||
const pts = s.points;
|
const pts = s.points;
|
||||||
return [
|
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) },
|
{ 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") {
|
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;
|
const pt = input.point;
|
||||||
if (s.phase !== "next") {
|
if (s.phase !== "next") {
|
||||||
const ns: PolyDrawing = { phase: "next", points: [pt], cursor: pt, lastPoint: pt };
|
const ns: PolyDrawing = { phase: "next", points: [pt], cursor: pt, lastPoint: pt, closed: s.closed };
|
||||||
return [ns, { draft: polyDraft([pt], pt) }];
|
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) {
|
if (s.points.length >= 3 && segLen(s.points[0], pt) < CLOSE_HIT) {
|
||||||
const pts = s.points;
|
const pts = s.points;
|
||||||
return [
|
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) },
|
{ draft: null, done: true, commit: (p) => appendPolyline(p, pts, true, ctx) },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
const points = [...s.points, pt];
|
const points = [...s.points, pt];
|
||||||
const ns: PolyDrawing = { phase: "next", points, cursor: pt, lastPoint: pt };
|
const ns: PolyDrawing = { phase: "next", points, cursor: pt, lastPoint: pt, closed: s.closed };
|
||||||
return [ns, { draft: polyDraft(points, pt) }];
|
return [ns, { draft: polyDraft(points, pt, s.closed) }];
|
||||||
},
|
},
|
||||||
|
|
||||||
onMove: (state, point): [CommandState, CommandResult] => {
|
onMove: (state, point): [CommandState, CommandResult] => {
|
||||||
const s = state as PolyState;
|
const s = state as PolyState;
|
||||||
if (s.phase !== "next") return [s, { draft: null }];
|
if (s.phase !== "next") return [s, { draft: null }];
|
||||||
const ns: PolyDrawing = { ...s, cursor: point };
|
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] => {
|
onConfirm: (state, ctx): [CommandState, CommandResult] => {
|
||||||
const s = state as PolyState;
|
const s = state as PolyState;
|
||||||
if (s.phase === "next" && s.points.length >= 2) {
|
if (s.phase === "next" && s.points.length >= 2) {
|
||||||
const pts = s.points;
|
const pts = s.points;
|
||||||
|
const close = s.closed && pts.length >= 3;
|
||||||
return [
|
return [
|
||||||
{ phase: "start", lastPoint: null },
|
{ phase: "start", lastPoint: null, closed: s.closed } as PolyIdle,
|
||||||
{ draft: null, done: true, commit: (p) => appendPolyline(p, pts, false, ctx) },
|
{ 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
|
// Tab-Feld-Zyklus nur im „next"-Schritt: Länge + Winkel relativ zum letzten
|
||||||
// Punkt. (Erster Punkt = freie Koordinate, kein Feld-Modus.)
|
// 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;
|
if (s.phase !== "next" || s.points.length === 0) return null;
|
||||||
return polyNextFromFields(s.points[s.points.length - 1], locks, cursor);
|
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:
|
// Rectangle — mehrere Konstruktionsmethoden (portiert + erweitert rectTool).
|
||||||
// 1) „Erste Ecke:" → Punkt
|
// Wählbar über die Option „Methode" (klickbar im Feld-Row, tippbar als „m",
|
||||||
// 2) „Gegenüberliegende Ecke:" → Punkt → commit Drawing2D rect → done
|
// zyklisch): 2-Punkt · 3-Punkt · Zentrum.
|
||||||
//
|
//
|
||||||
// Getippte Maße: die zweite Ecke kann als `r<breite>,<höhe>` relativ zur ersten
|
// • 2-Punkt (Default): zwei gegenüberliegende Ecken, achsparallel.
|
||||||
// eingegeben werden (die Engine löst `r…` relativ zu lastPoint auf).
|
// 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 type { Drawing2D } from "../../model/types";
|
||||||
import { uniqueId } from "../../tools/types";
|
import { uniqueId } from "../../tools/types";
|
||||||
@@ -13,19 +24,50 @@ import type {
|
|||||||
CommandField,
|
CommandField,
|
||||||
CommandResult,
|
CommandResult,
|
||||||
CommandState,
|
CommandState,
|
||||||
|
CmdOption,
|
||||||
DraftShape,
|
DraftShape,
|
||||||
Project,
|
Project,
|
||||||
ToolDraft,
|
ToolDraft,
|
||||||
Vec2,
|
Vec2,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
import type { TranslationKey } from "../../i18n";
|
||||||
|
|
||||||
const EPS = 1e-6;
|
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[] = [
|
const RECT_FIELDS: CommandField[] = [
|
||||||
{ id: "width", labelKey: "cmd.field.width" },
|
{ id: "width", labelKey: "cmd.field.width" },
|
||||||
{ id: "height", labelKey: "cmd.field.height" },
|
{ 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
|
* 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 };
|
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 {
|
interface RectIdle extends CommandState {
|
||||||
phase: "corner1";
|
phase: "corner1";
|
||||||
|
method: RectMethod;
|
||||||
}
|
}
|
||||||
interface RectDrawing extends CommandState {
|
interface RectCorner2 extends CommandState {
|
||||||
phase: "corner2";
|
phase: "corner2";
|
||||||
|
method: RectMethod;
|
||||||
a: Vec2;
|
a: Vec2;
|
||||||
cursor: Vec2 | null;
|
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 } {
|
function rectGeom(a: Vec2, b: Vec2): { min: Vec2; max: Vec2 } {
|
||||||
return {
|
return {
|
||||||
@@ -60,84 +194,231 @@ function rectGeom(a: Vec2, b: Vec2): { min: Vec2; max: Vec2 } {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Vorschau (geschlossenes Rechteck) + HUD (Breite × Höhe). */
|
// ── Vorschau ─────────────────────────────────────────────────────────────────
|
||||||
function rectDraft(a: Vec2, cursor: Vec2 | null): ToolDraft {
|
|
||||||
if (!cursor) return { preview: [], vertices: [a] };
|
function corners4Draft(pts: Vec2[], hudAt: Vec2 | null, hudText: string): ToolDraft {
|
||||||
const g = rectGeom(a, cursor);
|
|
||||||
const pts: Vec2[] = [
|
|
||||||
g.min,
|
|
||||||
{ x: g.max.x, y: g.min.y },
|
|
||||||
g.max,
|
|
||||||
{ x: g.min.x, y: g.max.y },
|
|
||||||
];
|
|
||||||
const preview: DraftShape[] = [{ kind: "poly", pts, closed: true }];
|
const preview: DraftShape[] = [{ kind: "poly", pts, closed: true }];
|
||||||
const w = g.max.x - g.min.x;
|
const draft: ToolDraft = { preview, vertices: [pts[0]] };
|
||||||
const h = g.max.y - g.min.y;
|
if (hudAt) draft.hud = { at: hudAt, text: hudText };
|
||||||
return {
|
return draft;
|
||||||
preview,
|
|
||||||
vertices: [a],
|
|
||||||
hud: { at: cursor, text: `${w.toFixed(2)} × ${h.toFixed(2)} m` },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendRect(p: Project, a: Vec2, b: Vec2, ctx: CommandContext): Project {
|
/** Vorschau des 2-Punkt-Rechtecks (achsparallel). */
|
||||||
const g = rectGeom(a, b);
|
function cornerDraft(a: Vec2, cursor: Vec2 | null): ToolDraft {
|
||||||
if (g.max.x - g.min.x < EPS || g.max.y - g.min.y < EPS) return p; // Null-Rechteck
|
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 = {
|
const d: Drawing2D = {
|
||||||
id: uniqueId("dr2d"),
|
id: uniqueId("dr2d"),
|
||||||
type: "drawing2d",
|
type: "drawing2d",
|
||||||
levelId: ctx.level.id,
|
levelId: ctx.level.id,
|
||||||
categoryCode: ctx.defaultCategoryCode,
|
categoryCode: ctx.defaultCategoryCode,
|
||||||
geom: { shape: "rect", min: g.min, max: g.max },
|
geom: { shape: "rect", min, max },
|
||||||
};
|
};
|
||||||
return { ...p, drawings2d: [...p.drawings2d, d] };
|
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 },
|
{ 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 = {
|
export const rectCommand: Command = {
|
||||||
name: "rect",
|
name: "rect",
|
||||||
labelKey: "cmd.rect.label",
|
labelKey: "cmd.rect.label",
|
||||||
prompt: (s) => ((s as RectState).phase === "corner2" ? "cmd.rect.second" : "cmd.rect.first"),
|
prompt: (s) => promptKey(s as RectState),
|
||||||
accepts: () => ["point", "number"],
|
accepts: () => ["point", "number", "option"],
|
||||||
options: () => [],
|
options: (s) => [methodOption((s as RectState).method)],
|
||||||
init: (): RectIdle => ({ phase: "corner1", lastPoint: null }),
|
init: (): RectIdle => ({ phase: "corner1", lastPoint: null, method: "corner" }),
|
||||||
|
|
||||||
onInput: (state, input, ctx): [CommandState, CommandResult] => {
|
onInput: (state, input, ctx): [CommandState, CommandResult] => {
|
||||||
const s = state as RectState;
|
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") {
|
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;
|
const pt = input.point;
|
||||||
if (s.phase !== "corner2") {
|
|
||||||
const ns: RectDrawing = { phase: "corner2", a: pt, cursor: pt, lastPoint: pt };
|
if (s.phase === "corner1") {
|
||||||
return [ns, { draft: rectDraft(pt, pt) }];
|
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 a = s.a;
|
||||||
|
const method = s.method;
|
||||||
return [
|
return [
|
||||||
{ phase: "corner1", lastPoint: null },
|
{ phase: "corner1", lastPoint: null, method } as RectIdle,
|
||||||
{ draft: null, done: true, commit: (p) => appendRect(p, a, pt, ctx) },
|
{
|
||||||
|
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] => {
|
onMove: (state, point): [CommandState, CommandResult] => {
|
||||||
const s = state as RectState;
|
const s = state as RectState;
|
||||||
if (s.phase !== "corner2") return [s, { draft: null }];
|
if (s.phase === "corner1") return [s, { draft: null }];
|
||||||
const ns: RectDrawing = { ...s, cursor: point };
|
const ns = { ...s, cursor: point } as RectState;
|
||||||
return [ns, { draft: rectDraft(s.a, point) }];
|
return [ns, { draft: draftFor(ns) }];
|
||||||
},
|
},
|
||||||
|
|
||||||
onConfirm: (): [CommandState, CommandResult] => idle(),
|
onConfirm: (state): [CommandState, CommandResult] => idle((state as RectState).method),
|
||||||
onCancel: (): [CommandState, CommandResult] => idle(),
|
onCancel: (state): [CommandState, CommandResult] => idle((state as RectState).method),
|
||||||
|
|
||||||
// Tab-Feld-Zyklus nur im „corner2"-Schritt: Breite + Höhe (signiert nach Cursor).
|
// Tab-Feld-Zyklus je Schritt/Methode.
|
||||||
fields: (state) => ((state as RectState).phase === "corner2" ? RECT_FIELDS : []),
|
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) => {
|
pointFromFields: (state, locks, cursor) => {
|
||||||
const s = state as RectState;
|
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);
|
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(),
|
||||||
|
};
|
||||||