Compare commits
7 Commits
cf0fe586eb
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e498818a7 | ||
|
|
d53686ed65 | ||
|
|
53999728c4 | ||
|
|
8ac5b3f1f9 | ||
|
|
bc94d41f2b | ||
|
|
4ca495209d | ||
|
|
ceea42ac1d |
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
node_modules/
|
||||||
213
bun.lock
Normal file
213
bun.lock
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "house-design",
|
||||||
|
"devDependencies": {
|
||||||
|
"vitest": "^3.0.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="],
|
||||||
|
|
||||||
|
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="],
|
||||||
|
|
||||||
|
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="],
|
||||||
|
|
||||||
|
"@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="],
|
||||||
|
|
||||||
|
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="],
|
||||||
|
|
||||||
|
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="],
|
||||||
|
|
||||||
|
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="],
|
||||||
|
|
||||||
|
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="],
|
||||||
|
|
||||||
|
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="],
|
||||||
|
|
||||||
|
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="],
|
||||||
|
|
||||||
|
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="],
|
||||||
|
|
||||||
|
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="],
|
||||||
|
|
||||||
|
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="],
|
||||||
|
|
||||||
|
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="],
|
||||||
|
|
||||||
|
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.57.1", "", { "os": "android", "cpu": "arm" }, "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.57.1", "", { "os": "android", "cpu": "arm64" }, "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.57.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.57.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.57.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.57.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.57.1", "", { "os": "linux", "cpu": "arm" }, "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.57.1", "", { "os": "linux", "cpu": "arm" }, "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.57.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.57.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.57.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.57.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.57.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.57.1", "", { "os": "linux", "cpu": "x64" }, "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.57.1", "", { "os": "linux", "cpu": "x64" }, "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.57.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.57.1", "", { "os": "none", "cpu": "arm64" }, "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.57.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.57.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.57.1", "", { "os": "win32", "cpu": "x64" }, "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.57.1", "", { "os": "win32", "cpu": "x64" }, "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA=="],
|
||||||
|
|
||||||
|
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
|
||||||
|
|
||||||
|
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
|
||||||
|
|
||||||
|
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||||
|
|
||||||
|
"@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
|
||||||
|
|
||||||
|
"@vitest/mocker": ["@vitest/mocker@3.2.4", "", { "dependencies": { "@vitest/spy": "3.2.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ=="],
|
||||||
|
|
||||||
|
"@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
|
||||||
|
|
||||||
|
"@vitest/runner": ["@vitest/runner@3.2.4", "", { "dependencies": { "@vitest/utils": "3.2.4", "pathe": "^2.0.3", "strip-literal": "^3.0.0" } }, "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ=="],
|
||||||
|
|
||||||
|
"@vitest/snapshot": ["@vitest/snapshot@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "magic-string": "^0.30.17", "pathe": "^2.0.3" } }, "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ=="],
|
||||||
|
|
||||||
|
"@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="],
|
||||||
|
|
||||||
|
"@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="],
|
||||||
|
|
||||||
|
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
|
||||||
|
|
||||||
|
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
|
||||||
|
|
||||||
|
"chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
|
||||||
|
|
||||||
|
"check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="],
|
||||||
|
|
||||||
|
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||||
|
|
||||||
|
"deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="],
|
||||||
|
|
||||||
|
"es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
|
||||||
|
|
||||||
|
"esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
|
||||||
|
|
||||||
|
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
|
||||||
|
|
||||||
|
"expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
|
||||||
|
|
||||||
|
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||||
|
|
||||||
|
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||||
|
|
||||||
|
"js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="],
|
||||||
|
|
||||||
|
"loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="],
|
||||||
|
|
||||||
|
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||||
|
|
||||||
|
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|
||||||
|
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||||
|
|
||||||
|
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||||
|
|
||||||
|
"pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="],
|
||||||
|
|
||||||
|
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||||
|
|
||||||
|
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||||
|
|
||||||
|
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||||
|
|
||||||
|
"rollup": ["rollup@4.57.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.1", "@rollup/rollup-android-arm64": "4.57.1", "@rollup/rollup-darwin-arm64": "4.57.1", "@rollup/rollup-darwin-x64": "4.57.1", "@rollup/rollup-freebsd-arm64": "4.57.1", "@rollup/rollup-freebsd-x64": "4.57.1", "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", "@rollup/rollup-linux-arm-musleabihf": "4.57.1", "@rollup/rollup-linux-arm64-gnu": "4.57.1", "@rollup/rollup-linux-arm64-musl": "4.57.1", "@rollup/rollup-linux-loong64-gnu": "4.57.1", "@rollup/rollup-linux-loong64-musl": "4.57.1", "@rollup/rollup-linux-ppc64-gnu": "4.57.1", "@rollup/rollup-linux-ppc64-musl": "4.57.1", "@rollup/rollup-linux-riscv64-gnu": "4.57.1", "@rollup/rollup-linux-riscv64-musl": "4.57.1", "@rollup/rollup-linux-s390x-gnu": "4.57.1", "@rollup/rollup-linux-x64-gnu": "4.57.1", "@rollup/rollup-linux-x64-musl": "4.57.1", "@rollup/rollup-openbsd-x64": "4.57.1", "@rollup/rollup-openharmony-arm64": "4.57.1", "@rollup/rollup-win32-arm64-msvc": "4.57.1", "@rollup/rollup-win32-ia32-msvc": "4.57.1", "@rollup/rollup-win32-x64-gnu": "4.57.1", "@rollup/rollup-win32-x64-msvc": "4.57.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A=="],
|
||||||
|
|
||||||
|
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
|
||||||
|
|
||||||
|
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||||
|
|
||||||
|
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
||||||
|
|
||||||
|
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
|
||||||
|
|
||||||
|
"strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="],
|
||||||
|
|
||||||
|
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
|
||||||
|
|
||||||
|
"tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
|
||||||
|
|
||||||
|
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||||
|
|
||||||
|
"tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="],
|
||||||
|
|
||||||
|
"tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="],
|
||||||
|
|
||||||
|
"tinyspy": ["tinyspy@4.0.4", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="],
|
||||||
|
|
||||||
|
"vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
|
||||||
|
|
||||||
|
"vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="],
|
||||||
|
|
||||||
|
"vitest": ["vitest@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", "@vitest/mocker": "3.2.4", "@vitest/pretty-format": "^3.2.4", "@vitest/runner": "3.2.4", "@vitest/snapshot": "3.2.4", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.4", "@vitest/ui": "3.2.4", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A=="],
|
||||||
|
|
||||||
|
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
292
data/apartment-small.json
Normal file
292
data/apartment-small.json
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
{
|
||||||
|
"name": "Stadtwohnung Kompakt",
|
||||||
|
"description": "Small city apartment, 1 floor, ~60sqm living space",
|
||||||
|
"units": "meters",
|
||||||
|
"building": {
|
||||||
|
"footprint": { "width": 9, "depth": 7 },
|
||||||
|
"wallThickness": 0.2,
|
||||||
|
"roofType": "flat"
|
||||||
|
},
|
||||||
|
"floors": [
|
||||||
|
{
|
||||||
|
"id": "eg",
|
||||||
|
"name": "Wohnung",
|
||||||
|
"nameEN": "Apartment",
|
||||||
|
"level": 0,
|
||||||
|
"ceilingHeight": 2.5,
|
||||||
|
"rooms": [
|
||||||
|
{
|
||||||
|
"id": "eg-flur",
|
||||||
|
"name": "Flur",
|
||||||
|
"nameEN": "Hallway",
|
||||||
|
"type": "hallway",
|
||||||
|
"position": { "x": 3.5, "y": 0 },
|
||||||
|
"dimensions": { "width": 1.5, "length": 7.0 },
|
||||||
|
"flooring": "tile",
|
||||||
|
"walls": {
|
||||||
|
"south": {
|
||||||
|
"type": "exterior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "eg-flur-d1",
|
||||||
|
"type": "entry",
|
||||||
|
"position": 0.2,
|
||||||
|
"width": 1.0,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "exterior"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"north": {
|
||||||
|
"type": "exterior"
|
||||||
|
},
|
||||||
|
"west": {
|
||||||
|
"type": "interior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "eg-flur-d2",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 0.5,
|
||||||
|
"width": 0.9,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "eg-kueche"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eg-flur-d3",
|
||||||
|
"type": "open",
|
||||||
|
"position": 3.5,
|
||||||
|
"width": 1.2,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "eg-wohnzimmer"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"east": {
|
||||||
|
"type": "interior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "eg-flur-d4",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 0.5,
|
||||||
|
"width": 0.8,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "eg-badezimmer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eg-flur-d5",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 3.5,
|
||||||
|
"width": 0.9,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "eg-schlafzimmer"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eg-kueche",
|
||||||
|
"name": "Küche",
|
||||||
|
"nameEN": "Kitchen",
|
||||||
|
"type": "kitchen",
|
||||||
|
"position": { "x": 0, "y": 0 },
|
||||||
|
"dimensions": { "width": 3.5, "length": 3.0 },
|
||||||
|
"flooring": "tile",
|
||||||
|
"walls": {
|
||||||
|
"south": {
|
||||||
|
"type": "exterior",
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"id": "eg-ku-w1",
|
||||||
|
"type": "casement",
|
||||||
|
"position": 1.0,
|
||||||
|
"width": 1.2,
|
||||||
|
"height": 1.2,
|
||||||
|
"sillHeight": 0.9
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"north": {
|
||||||
|
"type": "interior"
|
||||||
|
},
|
||||||
|
"west": {
|
||||||
|
"type": "exterior",
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"id": "eg-ku-w2",
|
||||||
|
"type": "casement",
|
||||||
|
"position": 1.0,
|
||||||
|
"width": 1.0,
|
||||||
|
"height": 1.0,
|
||||||
|
"sillHeight": 1.0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"east": {
|
||||||
|
"type": "interior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "eg-ku-d1",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 0.5,
|
||||||
|
"width": 0.9,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "eg-flur"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eg-wohnzimmer",
|
||||||
|
"name": "Wohnzimmer",
|
||||||
|
"nameEN": "Living Room",
|
||||||
|
"type": "living",
|
||||||
|
"position": { "x": 0, "y": 3.0 },
|
||||||
|
"dimensions": { "width": 3.5, "length": 4.0 },
|
||||||
|
"flooring": "hardwood",
|
||||||
|
"walls": {
|
||||||
|
"south": {
|
||||||
|
"type": "interior"
|
||||||
|
},
|
||||||
|
"north": {
|
||||||
|
"type": "exterior",
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"id": "eg-wz-w1",
|
||||||
|
"type": "fixed",
|
||||||
|
"position": 0.5,
|
||||||
|
"width": 1.8,
|
||||||
|
"height": 1.4,
|
||||||
|
"sillHeight": 0.6
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"west": {
|
||||||
|
"type": "exterior",
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"id": "eg-wz-w2",
|
||||||
|
"type": "casement",
|
||||||
|
"position": 1.5,
|
||||||
|
"width": 1.2,
|
||||||
|
"height": 1.4,
|
||||||
|
"sillHeight": 0.6
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"east": {
|
||||||
|
"type": "interior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "eg-wz-d1",
|
||||||
|
"type": "open",
|
||||||
|
"position": 0.0,
|
||||||
|
"width": 1.2,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "eg-flur"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eg-badezimmer",
|
||||||
|
"name": "Badezimmer",
|
||||||
|
"nameEN": "Bathroom",
|
||||||
|
"type": "bathroom",
|
||||||
|
"position": { "x": 5.0, "y": 0 },
|
||||||
|
"dimensions": { "width": 2.5, "length": 2.5 },
|
||||||
|
"flooring": "tile",
|
||||||
|
"walls": {
|
||||||
|
"south": {
|
||||||
|
"type": "exterior",
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"id": "eg-bz-w1",
|
||||||
|
"type": "casement",
|
||||||
|
"position": 0.8,
|
||||||
|
"width": 0.8,
|
||||||
|
"height": 0.8,
|
||||||
|
"sillHeight": 1.3
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"north": {
|
||||||
|
"type": "interior"
|
||||||
|
},
|
||||||
|
"west": {
|
||||||
|
"type": "interior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "eg-bz-d1",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 0.5,
|
||||||
|
"width": 0.8,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "eg-flur"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"east": {
|
||||||
|
"type": "exterior"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eg-schlafzimmer",
|
||||||
|
"name": "Schlafzimmer",
|
||||||
|
"nameEN": "Bedroom",
|
||||||
|
"type": "bedroom",
|
||||||
|
"position": { "x": 5.0, "y": 2.5 },
|
||||||
|
"dimensions": { "width": 4.0, "length": 4.5 },
|
||||||
|
"flooring": "hardwood",
|
||||||
|
"walls": {
|
||||||
|
"south": {
|
||||||
|
"type": "interior"
|
||||||
|
},
|
||||||
|
"north": {
|
||||||
|
"type": "exterior",
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"id": "eg-sz-w1",
|
||||||
|
"type": "casement",
|
||||||
|
"position": 1.0,
|
||||||
|
"width": 1.4,
|
||||||
|
"height": 1.4,
|
||||||
|
"sillHeight": 0.6
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"west": {
|
||||||
|
"type": "interior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "eg-sz-d1",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 0.5,
|
||||||
|
"width": 0.9,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "eg-flur"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"east": {
|
||||||
|
"type": "exterior",
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"id": "eg-sz-w2",
|
||||||
|
"type": "casement",
|
||||||
|
"position": 1.5,
|
||||||
|
"width": 1.2,
|
||||||
|
"height": 1.4,
|
||||||
|
"sillHeight": 0.6
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
769
data/ikea-catalog.json
Normal file
769
data/ikea-catalog.json
Normal file
@@ -0,0 +1,769 @@
|
|||||||
|
{
|
||||||
|
"version": "1.0",
|
||||||
|
"source": "ikea",
|
||||||
|
"units": "meters",
|
||||||
|
"description": "Curated IKEA furniture catalog with verified dimensions",
|
||||||
|
"categories": [
|
||||||
|
"seating",
|
||||||
|
"tables",
|
||||||
|
"storage",
|
||||||
|
"beds",
|
||||||
|
"kitchen",
|
||||||
|
"office"
|
||||||
|
],
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "ikea-kallax-1x4",
|
||||||
|
"name": "KALLAX Shelf 1x4",
|
||||||
|
"ikeaSeries": "KALLAX",
|
||||||
|
"category": "storage",
|
||||||
|
"rooms": ["wohnzimmer", "arbeitszimmer", "kinderzimmer"],
|
||||||
|
"dimensions": { "width": 0.42, "depth": 0.39, "height": 1.47 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "frame", "geometry": "box", "size": [0.42, 1.47, 0.39], "position": [0, 0.735, 0], "color": "#ffffff" },
|
||||||
|
{ "name": "shelf1", "geometry": "box", "size": [0.38, 0.02, 0.37], "position": [0, 0.37, 0], "color": "#f0f0f0" },
|
||||||
|
{ "name": "shelf2", "geometry": "box", "size": [0.38, 0.02, 0.37], "position": [0, 0.735, 0], "color": "#f0f0f0" },
|
||||||
|
{ "name": "shelf3", "geometry": "box", "size": [0.38, 0.02, 0.37], "position": [0, 1.1, 0], "color": "#f0f0f0" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-kallax-2x2",
|
||||||
|
"name": "KALLAX Shelf 2x2",
|
||||||
|
"ikeaSeries": "KALLAX",
|
||||||
|
"category": "storage",
|
||||||
|
"rooms": ["wohnzimmer", "arbeitszimmer", "kinderzimmer"],
|
||||||
|
"dimensions": { "width": 0.77, "depth": 0.39, "height": 0.77 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "frame", "geometry": "box", "size": [0.77, 0.77, 0.39], "position": [0, 0.385, 0], "color": "#ffffff" },
|
||||||
|
{ "name": "divV", "geometry": "box", "size": [0.02, 0.73, 0.37], "position": [0, 0.385, 0], "color": "#f0f0f0" },
|
||||||
|
{ "name": "divH", "geometry": "box", "size": [0.73, 0.02, 0.37], "position": [0, 0.385, 0], "color": "#f0f0f0" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-kallax-2x4",
|
||||||
|
"name": "KALLAX Shelf 2x4",
|
||||||
|
"ikeaSeries": "KALLAX",
|
||||||
|
"category": "storage",
|
||||||
|
"rooms": ["wohnzimmer", "arbeitszimmer", "kinderzimmer"],
|
||||||
|
"dimensions": { "width": 0.77, "depth": 0.39, "height": 1.47 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "frame", "geometry": "box", "size": [0.77, 1.47, 0.39], "position": [0, 0.735, 0], "color": "#ffffff" },
|
||||||
|
{ "name": "divV", "geometry": "box", "size": [0.02, 1.43, 0.37], "position": [0, 0.735, 0], "color": "#f0f0f0" },
|
||||||
|
{ "name": "divH1", "geometry": "box", "size": [0.73, 0.02, 0.37], "position": [0, 0.37, 0], "color": "#f0f0f0" },
|
||||||
|
{ "name": "divH2", "geometry": "box", "size": [0.73, 0.02, 0.37], "position": [0, 0.735, 0], "color": "#f0f0f0" },
|
||||||
|
{ "name": "divH3", "geometry": "box", "size": [0.73, 0.02, 0.37], "position": [0, 1.1, 0], "color": "#f0f0f0" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-kallax-4x4",
|
||||||
|
"name": "KALLAX Shelf 4x4",
|
||||||
|
"ikeaSeries": "KALLAX",
|
||||||
|
"category": "storage",
|
||||||
|
"rooms": ["wohnzimmer", "arbeitszimmer"],
|
||||||
|
"dimensions": { "width": 1.47, "depth": 0.39, "height": 1.47 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "frame", "geometry": "box", "size": [1.47, 1.47, 0.39], "position": [0, 0.735, 0], "color": "#ffffff" },
|
||||||
|
{ "name": "divV1", "geometry": "box", "size": [0.02, 1.43, 0.37], "position": [-0.365, 0.735, 0], "color": "#f0f0f0" },
|
||||||
|
{ "name": "divV2", "geometry": "box", "size": [0.02, 1.43, 0.37], "position": [0, 0.735, 0], "color": "#f0f0f0" },
|
||||||
|
{ "name": "divV3", "geometry": "box", "size": [0.02, 1.43, 0.37], "position": [0.365, 0.735, 0], "color": "#f0f0f0" },
|
||||||
|
{ "name": "divH1", "geometry": "box", "size": [1.43, 0.02, 0.37], "position": [0, 0.37, 0], "color": "#f0f0f0" },
|
||||||
|
{ "name": "divH2", "geometry": "box", "size": [1.43, 0.02, 0.37], "position": [0, 0.735, 0], "color": "#f0f0f0" },
|
||||||
|
{ "name": "divH3", "geometry": "box", "size": [1.43, 0.02, 0.37], "position": [0, 1.1, 0], "color": "#f0f0f0" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-billy-standard",
|
||||||
|
"name": "BILLY Bookcase",
|
||||||
|
"ikeaSeries": "BILLY",
|
||||||
|
"category": "storage",
|
||||||
|
"rooms": ["wohnzimmer", "arbeitszimmer", "kinderzimmer"],
|
||||||
|
"dimensions": { "width": 0.80, "depth": 0.28, "height": 2.02 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "left", "geometry": "box", "size": [0.02, 2.02, 0.28], "position": [-0.39, 1.01, 0], "color": "#ffffff" },
|
||||||
|
{ "name": "right", "geometry": "box", "size": [0.02, 2.02, 0.28], "position": [0.39, 1.01, 0], "color": "#ffffff" },
|
||||||
|
{ "name": "back", "geometry": "box", "size": [0.76, 2.0, 0.01], "position": [0, 1.01, -0.135], "color": "#f8f8f8" },
|
||||||
|
{ "name": "shelf1", "geometry": "box", "size": [0.76, 0.02, 0.26], "position": [0, 0.01, 0], "color": "#f0f0f0" },
|
||||||
|
{ "name": "shelf2", "geometry": "box", "size": [0.76, 0.02, 0.26], "position": [0, 0.4, 0], "color": "#f0f0f0" },
|
||||||
|
{ "name": "shelf3", "geometry": "box", "size": [0.76, 0.02, 0.26], "position": [0, 0.8, 0], "color": "#f0f0f0" },
|
||||||
|
{ "name": "shelf4", "geometry": "box", "size": [0.76, 0.02, 0.26], "position": [0, 1.2, 0], "color": "#f0f0f0" },
|
||||||
|
{ "name": "shelf5", "geometry": "box", "size": [0.76, 0.02, 0.26], "position": [0, 1.6, 0], "color": "#f0f0f0" },
|
||||||
|
{ "name": "top", "geometry": "box", "size": [0.80, 0.02, 0.28], "position": [0, 2.01, 0], "color": "#ffffff" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-billy-narrow",
|
||||||
|
"name": "BILLY Bookcase Narrow",
|
||||||
|
"ikeaSeries": "BILLY",
|
||||||
|
"category": "storage",
|
||||||
|
"rooms": ["wohnzimmer", "arbeitszimmer"],
|
||||||
|
"dimensions": { "width": 0.40, "depth": 0.28, "height": 2.02 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "left", "geometry": "box", "size": [0.02, 2.02, 0.28], "position": [-0.19, 1.01, 0], "color": "#ffffff" },
|
||||||
|
{ "name": "right", "geometry": "box", "size": [0.02, 2.02, 0.28], "position": [0.19, 1.01, 0], "color": "#ffffff" },
|
||||||
|
{ "name": "back", "geometry": "box", "size": [0.36, 2.0, 0.01], "position": [0, 1.01, -0.135], "color": "#f8f8f8" },
|
||||||
|
{ "name": "shelf1", "geometry": "box", "size": [0.36, 0.02, 0.26], "position": [0, 0.01, 0], "color": "#f0f0f0" },
|
||||||
|
{ "name": "shelf2", "geometry": "box", "size": [0.36, 0.02, 0.26], "position": [0, 0.4, 0], "color": "#f0f0f0" },
|
||||||
|
{ "name": "shelf3", "geometry": "box", "size": [0.36, 0.02, 0.26], "position": [0, 0.8, 0], "color": "#f0f0f0" },
|
||||||
|
{ "name": "shelf4", "geometry": "box", "size": [0.36, 0.02, 0.26], "position": [0, 1.2, 0], "color": "#f0f0f0" },
|
||||||
|
{ "name": "shelf5", "geometry": "box", "size": [0.36, 0.02, 0.26], "position": [0, 1.6, 0], "color": "#f0f0f0" },
|
||||||
|
{ "name": "top", "geometry": "box", "size": [0.40, 0.02, 0.28], "position": [0, 2.01, 0], "color": "#ffffff" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-billy-short",
|
||||||
|
"name": "BILLY Bookcase Short",
|
||||||
|
"ikeaSeries": "BILLY",
|
||||||
|
"category": "storage",
|
||||||
|
"rooms": ["wohnzimmer", "arbeitszimmer", "kinderzimmer"],
|
||||||
|
"dimensions": { "width": 0.80, "depth": 0.28, "height": 1.06 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "left", "geometry": "box", "size": [0.02, 1.06, 0.28], "position": [-0.39, 0.53, 0], "color": "#ffffff" },
|
||||||
|
{ "name": "right", "geometry": "box", "size": [0.02, 1.06, 0.28], "position": [0.39, 0.53, 0], "color": "#ffffff" },
|
||||||
|
{ "name": "back", "geometry": "box", "size": [0.76, 1.04, 0.01], "position": [0, 0.53, -0.135], "color": "#f8f8f8" },
|
||||||
|
{ "name": "shelf1", "geometry": "box", "size": [0.76, 0.02, 0.26], "position": [0, 0.01, 0], "color": "#f0f0f0" },
|
||||||
|
{ "name": "shelf2", "geometry": "box", "size": [0.76, 0.02, 0.26], "position": [0, 0.35, 0], "color": "#f0f0f0" },
|
||||||
|
{ "name": "shelf3", "geometry": "box", "size": [0.76, 0.02, 0.26], "position": [0, 0.7, 0], "color": "#f0f0f0" },
|
||||||
|
{ "name": "top", "geometry": "box", "size": [0.80, 0.02, 0.28], "position": [0, 1.05, 0], "color": "#ffffff" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-hemnes-6drawer",
|
||||||
|
"name": "HEMNES 6-Drawer Dresser",
|
||||||
|
"ikeaSeries": "HEMNES",
|
||||||
|
"category": "storage",
|
||||||
|
"rooms": ["schlafzimmer"],
|
||||||
|
"dimensions": { "width": 1.08, "depth": 0.50, "height": 1.31 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "body", "geometry": "box", "size": [1.08, 1.31, 0.50], "position": [0, 0.655, 0], "color": "#f0ece4" },
|
||||||
|
{ "name": "drawer1", "geometry": "box", "size": [0.48, 0.16, 0.02], "position": [-0.27, 0.15, 0.24], "color": "#e8e4dc" },
|
||||||
|
{ "name": "drawer2", "geometry": "box", "size": [0.48, 0.16, 0.02], "position": [0.27, 0.15, 0.24], "color": "#e8e4dc" },
|
||||||
|
{ "name": "drawer3", "geometry": "box", "size": [0.48, 0.16, 0.02], "position": [-0.27, 0.37, 0.24], "color": "#e8e4dc" },
|
||||||
|
{ "name": "drawer4", "geometry": "box", "size": [0.48, 0.16, 0.02], "position": [0.27, 0.37, 0.24], "color": "#e8e4dc" },
|
||||||
|
{ "name": "drawer5", "geometry": "box", "size": [0.48, 0.16, 0.02], "position": [-0.27, 0.59, 0.24], "color": "#e8e4dc" },
|
||||||
|
{ "name": "drawer6", "geometry": "box", "size": [0.48, 0.16, 0.02], "position": [0.27, 0.59, 0.24], "color": "#e8e4dc" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-hemnes-3drawer",
|
||||||
|
"name": "HEMNES 3-Drawer Dresser",
|
||||||
|
"ikeaSeries": "HEMNES",
|
||||||
|
"category": "storage",
|
||||||
|
"rooms": ["schlafzimmer", "flur"],
|
||||||
|
"dimensions": { "width": 1.08, "depth": 0.50, "height": 0.96 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "body", "geometry": "box", "size": [1.08, 0.96, 0.50], "position": [0, 0.48, 0], "color": "#f0ece4" },
|
||||||
|
{ "name": "drawer1", "geometry": "box", "size": [1.0, 0.2, 0.02], "position": [0, 0.18, 0.24], "color": "#e8e4dc" },
|
||||||
|
{ "name": "drawer2", "geometry": "box", "size": [1.0, 0.2, 0.02], "position": [0, 0.44, 0.24], "color": "#e8e4dc" },
|
||||||
|
{ "name": "drawer3", "geometry": "box", "size": [1.0, 0.2, 0.02], "position": [0, 0.70, 0.24], "color": "#e8e4dc" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-hemnes-bookcase",
|
||||||
|
"name": "HEMNES Bookcase",
|
||||||
|
"ikeaSeries": "HEMNES",
|
||||||
|
"category": "storage",
|
||||||
|
"rooms": ["wohnzimmer", "arbeitszimmer"],
|
||||||
|
"dimensions": { "width": 0.90, "depth": 0.37, "height": 1.97 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "left", "geometry": "box", "size": [0.03, 1.97, 0.37], "position": [-0.435, 0.985, 0], "color": "#c4a87d" },
|
||||||
|
{ "name": "right", "geometry": "box", "size": [0.03, 1.97, 0.37], "position": [0.435, 0.985, 0], "color": "#c4a87d" },
|
||||||
|
{ "name": "back", "geometry": "box", "size": [0.84, 1.95, 0.01], "position": [0, 0.985, -0.18], "color": "#d4be97" },
|
||||||
|
{ "name": "shelf1", "geometry": "box", "size": [0.84, 0.02, 0.35], "position": [0, 0.01, 0], "color": "#c4a87d" },
|
||||||
|
{ "name": "shelf2", "geometry": "box", "size": [0.84, 0.02, 0.35], "position": [0, 0.5, 0], "color": "#c4a87d" },
|
||||||
|
{ "name": "shelf3", "geometry": "box", "size": [0.84, 0.02, 0.35], "position": [0, 1.0, 0], "color": "#c4a87d" },
|
||||||
|
{ "name": "shelf4", "geometry": "box", "size": [0.84, 0.02, 0.35], "position": [0, 1.5, 0], "color": "#c4a87d" },
|
||||||
|
{ "name": "top", "geometry": "box", "size": [0.90, 0.02, 0.37], "position": [0, 1.96, 0], "color": "#c4a87d" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-besta-tv",
|
||||||
|
"name": "BESTA TV Bench",
|
||||||
|
"ikeaSeries": "BESTA",
|
||||||
|
"category": "storage",
|
||||||
|
"rooms": ["wohnzimmer"],
|
||||||
|
"dimensions": { "width": 1.80, "depth": 0.42, "height": 0.38 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "body", "geometry": "box", "size": [1.80, 0.38, 0.42], "position": [0, 0.19, 0], "color": "#ffffff" },
|
||||||
|
{ "name": "door1", "geometry": "box", "size": [0.58, 0.34, 0.02], "position": [-0.6, 0.19, 0.2], "color": "#f0f0f0" },
|
||||||
|
{ "name": "door2", "geometry": "box", "size": [0.58, 0.34, 0.02], "position": [0, 0.19, 0.2], "color": "#f0f0f0" },
|
||||||
|
{ "name": "door3", "geometry": "box", "size": [0.58, 0.34, 0.02], "position": [0.6, 0.19, 0.2], "color": "#f0f0f0" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-malm-6drawer",
|
||||||
|
"name": "MALM 6-Drawer Dresser",
|
||||||
|
"ikeaSeries": "MALM",
|
||||||
|
"category": "storage",
|
||||||
|
"rooms": ["schlafzimmer"],
|
||||||
|
"dimensions": { "width": 0.80, "depth": 0.48, "height": 1.23 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "body", "geometry": "box", "size": [0.80, 1.23, 0.48], "position": [0, 0.615, 0], "color": "#ffffff" },
|
||||||
|
{ "name": "drawer1", "geometry": "box", "size": [0.74, 0.15, 0.02], "position": [0, 0.12, 0.23], "color": "#f0f0f0" },
|
||||||
|
{ "name": "drawer2", "geometry": "box", "size": [0.74, 0.15, 0.02], "position": [0, 0.32, 0.23], "color": "#f0f0f0" },
|
||||||
|
{ "name": "drawer3", "geometry": "box", "size": [0.74, 0.15, 0.02], "position": [0, 0.52, 0.23], "color": "#f0f0f0" },
|
||||||
|
{ "name": "drawer4", "geometry": "box", "size": [0.74, 0.15, 0.02], "position": [0, 0.72, 0.23], "color": "#f0f0f0" },
|
||||||
|
{ "name": "drawer5", "geometry": "box", "size": [0.74, 0.15, 0.02], "position": [0, 0.92, 0.23], "color": "#f0f0f0" },
|
||||||
|
{ "name": "drawer6", "geometry": "box", "size": [0.74, 0.15, 0.02], "position": [0, 1.12, 0.23], "color": "#f0f0f0" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-malm-4drawer",
|
||||||
|
"name": "MALM 4-Drawer Dresser",
|
||||||
|
"ikeaSeries": "MALM",
|
||||||
|
"category": "storage",
|
||||||
|
"rooms": ["schlafzimmer"],
|
||||||
|
"dimensions": { "width": 0.80, "depth": 0.48, "height": 1.00 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "body", "geometry": "box", "size": [0.80, 1.00, 0.48], "position": [0, 0.50, 0], "color": "#ffffff" },
|
||||||
|
{ "name": "drawer1", "geometry": "box", "size": [0.74, 0.18, 0.02], "position": [0, 0.14, 0.23], "color": "#f0f0f0" },
|
||||||
|
{ "name": "drawer2", "geometry": "box", "size": [0.74, 0.18, 0.02], "position": [0, 0.38, 0.23], "color": "#f0f0f0" },
|
||||||
|
{ "name": "drawer3", "geometry": "box", "size": [0.74, 0.18, 0.02], "position": [0, 0.62, 0.23], "color": "#f0f0f0" },
|
||||||
|
{ "name": "drawer4", "geometry": "box", "size": [0.74, 0.18, 0.02], "position": [0, 0.86, 0.23], "color": "#f0f0f0" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-pax-wardrobe",
|
||||||
|
"name": "PAX Wardrobe 100cm",
|
||||||
|
"ikeaSeries": "PAX",
|
||||||
|
"category": "storage",
|
||||||
|
"rooms": ["schlafzimmer"],
|
||||||
|
"dimensions": { "width": 1.00, "depth": 0.58, "height": 2.01 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "body", "geometry": "box", "size": [1.00, 2.01, 0.58], "position": [0, 1.005, 0], "color": "#ffffff" },
|
||||||
|
{ "name": "door_l", "geometry": "box", "size": [0.48, 1.95, 0.02], "position": [-0.25, 1.005, 0.28], "color": "#f0f0f0" },
|
||||||
|
{ "name": "door_r", "geometry": "box", "size": [0.48, 1.95, 0.02], "position": [0.25, 1.005, 0.28], "color": "#f0f0f0" },
|
||||||
|
{ "name": "handle_l", "geometry": "box", "size": [0.02, 0.12, 0.03], "position": [-0.02, 1.005, 0.3], "color": "#888888" },
|
||||||
|
{ "name": "handle_r", "geometry": "box", "size": [0.02, 0.12, 0.03], "position": [0.02, 1.005, 0.3], "color": "#888888" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-pax-wardrobe-150",
|
||||||
|
"name": "PAX Wardrobe 150cm",
|
||||||
|
"ikeaSeries": "PAX",
|
||||||
|
"category": "storage",
|
||||||
|
"rooms": ["schlafzimmer"],
|
||||||
|
"dimensions": { "width": 1.50, "depth": 0.58, "height": 2.01 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "body", "geometry": "box", "size": [1.50, 2.01, 0.58], "position": [0, 1.005, 0], "color": "#ffffff" },
|
||||||
|
{ "name": "door_l", "geometry": "box", "size": [0.48, 1.95, 0.02], "position": [-0.5, 1.005, 0.28], "color": "#f0f0f0" },
|
||||||
|
{ "name": "door_m", "geometry": "box", "size": [0.48, 1.95, 0.02], "position": [0, 1.005, 0.28], "color": "#f0f0f0" },
|
||||||
|
{ "name": "door_r", "geometry": "box", "size": [0.48, 1.95, 0.02], "position": [0.5, 1.005, 0.28], "color": "#f0f0f0" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-lack-side",
|
||||||
|
"name": "LACK Side Table",
|
||||||
|
"ikeaSeries": "LACK",
|
||||||
|
"category": "tables",
|
||||||
|
"rooms": ["wohnzimmer"],
|
||||||
|
"dimensions": { "width": 0.55, "depth": 0.55, "height": 0.45 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "top", "geometry": "box", "size": [0.55, 0.05, 0.55], "position": [0, 0.425, 0], "color": "#1a1a1a" },
|
||||||
|
{ "name": "leg1", "geometry": "box", "size": [0.05, 0.4, 0.05], "position": [-0.22, 0.2, -0.22], "color": "#1a1a1a" },
|
||||||
|
{ "name": "leg2", "geometry": "box", "size": [0.05, 0.4, 0.05], "position": [0.22, 0.2, -0.22], "color": "#1a1a1a" },
|
||||||
|
{ "name": "leg3", "geometry": "box", "size": [0.05, 0.4, 0.05], "position": [-0.22, 0.2, 0.22], "color": "#1a1a1a" },
|
||||||
|
{ "name": "leg4", "geometry": "box", "size": [0.05, 0.4, 0.05], "position": [0.22, 0.2, 0.22], "color": "#1a1a1a" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-lack-coffee",
|
||||||
|
"name": "LACK Coffee Table",
|
||||||
|
"ikeaSeries": "LACK",
|
||||||
|
"category": "tables",
|
||||||
|
"rooms": ["wohnzimmer"],
|
||||||
|
"dimensions": { "width": 0.90, "depth": 0.55, "height": 0.45 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "top", "geometry": "box", "size": [0.90, 0.05, 0.55], "position": [0, 0.425, 0], "color": "#1a1a1a" },
|
||||||
|
{ "name": "shelf", "geometry": "box", "size": [0.84, 0.02, 0.49], "position": [0, 0.07, 0], "color": "#1a1a1a" },
|
||||||
|
{ "name": "leg1", "geometry": "box", "size": [0.05, 0.4, 0.05], "position": [-0.40, 0.2, -0.22], "color": "#1a1a1a" },
|
||||||
|
{ "name": "leg2", "geometry": "box", "size": [0.05, 0.4, 0.05], "position": [0.40, 0.2, -0.22], "color": "#1a1a1a" },
|
||||||
|
{ "name": "leg3", "geometry": "box", "size": [0.05, 0.4, 0.05], "position": [-0.40, 0.2, 0.22], "color": "#1a1a1a" },
|
||||||
|
{ "name": "leg4", "geometry": "box", "size": [0.05, 0.4, 0.05], "position": [0.40, 0.2, 0.22], "color": "#1a1a1a" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-lack-tv",
|
||||||
|
"name": "LACK TV Bench",
|
||||||
|
"ikeaSeries": "LACK",
|
||||||
|
"category": "tables",
|
||||||
|
"rooms": ["wohnzimmer"],
|
||||||
|
"dimensions": { "width": 0.90, "depth": 0.26, "height": 0.45 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "top", "geometry": "box", "size": [0.90, 0.04, 0.26], "position": [0, 0.43, 0], "color": "#1a1a1a" },
|
||||||
|
{ "name": "shelf", "geometry": "box", "size": [0.84, 0.02, 0.22], "position": [0, 0.07, 0], "color": "#1a1a1a" },
|
||||||
|
{ "name": "leg1", "geometry": "box", "size": [0.05, 0.4, 0.05], "position": [-0.40, 0.2, -0.08], "color": "#1a1a1a" },
|
||||||
|
{ "name": "leg2", "geometry": "box", "size": [0.05, 0.4, 0.05], "position": [0.40, 0.2, -0.08], "color": "#1a1a1a" },
|
||||||
|
{ "name": "leg3", "geometry": "box", "size": [0.05, 0.4, 0.05], "position": [-0.40, 0.2, 0.08], "color": "#1a1a1a" },
|
||||||
|
{ "name": "leg4", "geometry": "box", "size": [0.05, 0.4, 0.05], "position": [0.40, 0.2, 0.08], "color": "#1a1a1a" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-lisabo-desk",
|
||||||
|
"name": "LISABO Desk",
|
||||||
|
"ikeaSeries": "LISABO",
|
||||||
|
"category": "tables",
|
||||||
|
"rooms": ["arbeitszimmer"],
|
||||||
|
"dimensions": { "width": 1.18, "depth": 0.45, "height": 0.74 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "top", "geometry": "box", "size": [1.18, 0.03, 0.45], "position": [0, 0.725, 0], "color": "#c4a87d" },
|
||||||
|
{ "name": "leg1", "geometry": "box", "size": [0.04, 0.71, 0.04], "position": [-0.54, 0.355, -0.18], "color": "#b09870" },
|
||||||
|
{ "name": "leg2", "geometry": "box", "size": [0.04, 0.71, 0.04], "position": [0.54, 0.355, -0.18], "color": "#b09870" },
|
||||||
|
{ "name": "leg3", "geometry": "box", "size": [0.04, 0.71, 0.04], "position": [-0.54, 0.355, 0.18], "color": "#b09870" },
|
||||||
|
{ "name": "leg4", "geometry": "box", "size": [0.04, 0.71, 0.04], "position": [0.54, 0.355, 0.18], "color": "#b09870" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-bekant-desk",
|
||||||
|
"name": "BEKANT Desk 160x80",
|
||||||
|
"ikeaSeries": "BEKANT",
|
||||||
|
"category": "office",
|
||||||
|
"rooms": ["arbeitszimmer"],
|
||||||
|
"dimensions": { "width": 1.60, "depth": 0.80, "height": 0.75 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "top", "geometry": "box", "size": [1.60, 0.03, 0.80], "position": [0, 0.735, 0], "color": "#f0ece4" },
|
||||||
|
{ "name": "leg1", "geometry": "box", "size": [0.06, 0.72, 0.06], "position": [-0.72, 0.36, -0.32], "color": "#cccccc" },
|
||||||
|
{ "name": "leg2", "geometry": "box", "size": [0.06, 0.72, 0.06], "position": [0.72, 0.36, -0.32], "color": "#cccccc" },
|
||||||
|
{ "name": "leg3", "geometry": "box", "size": [0.06, 0.72, 0.06], "position": [-0.72, 0.36, 0.32], "color": "#cccccc" },
|
||||||
|
{ "name": "leg4", "geometry": "box", "size": [0.06, 0.72, 0.06], "position": [0.72, 0.36, 0.32], "color": "#cccccc" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-melltorp-table",
|
||||||
|
"name": "MELLTORP Dining Table",
|
||||||
|
"ikeaSeries": "MELLTORP",
|
||||||
|
"category": "tables",
|
||||||
|
"rooms": ["esszimmer", "kueche"],
|
||||||
|
"dimensions": { "width": 1.25, "depth": 0.75, "height": 0.74 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "top", "geometry": "box", "size": [1.25, 0.03, 0.75], "position": [0, 0.725, 0], "color": "#ffffff" },
|
||||||
|
{ "name": "leg1", "geometry": "box", "size": [0.05, 0.71, 0.05], "position": [-0.56, 0.355, -0.31], "color": "#e0e0e0" },
|
||||||
|
{ "name": "leg2", "geometry": "box", "size": [0.05, 0.71, 0.05], "position": [0.56, 0.355, -0.31], "color": "#e0e0e0" },
|
||||||
|
{ "name": "leg3", "geometry": "box", "size": [0.05, 0.71, 0.05], "position": [-0.56, 0.355, 0.31], "color": "#e0e0e0" },
|
||||||
|
{ "name": "leg4", "geometry": "box", "size": [0.05, 0.71, 0.05], "position": [0.56, 0.355, 0.31], "color": "#e0e0e0" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-ekedalen-table",
|
||||||
|
"name": "EKEDALEN Dining Table",
|
||||||
|
"ikeaSeries": "EKEDALEN",
|
||||||
|
"category": "tables",
|
||||||
|
"rooms": ["esszimmer"],
|
||||||
|
"dimensions": { "width": 1.20, "depth": 0.80, "height": 0.75 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "top", "geometry": "box", "size": [1.20, 0.04, 0.80], "position": [0, 0.73, 0], "color": "#6b5640" },
|
||||||
|
{ "name": "leg1", "geometry": "box", "size": [0.06, 0.71, 0.06], "position": [-0.52, 0.355, -0.32], "color": "#5a4530" },
|
||||||
|
{ "name": "leg2", "geometry": "box", "size": [0.06, 0.71, 0.06], "position": [0.52, 0.355, -0.32], "color": "#5a4530" },
|
||||||
|
{ "name": "leg3", "geometry": "box", "size": [0.06, 0.71, 0.06], "position": [-0.52, 0.355, 0.32], "color": "#5a4530" },
|
||||||
|
{ "name": "leg4", "geometry": "box", "size": [0.06, 0.71, 0.06], "position": [0.52, 0.355, 0.32], "color": "#5a4530" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-poang-chair",
|
||||||
|
"name": "POANG Armchair",
|
||||||
|
"ikeaSeries": "POANG",
|
||||||
|
"category": "seating",
|
||||||
|
"rooms": ["wohnzimmer", "schlafzimmer"],
|
||||||
|
"dimensions": { "width": 0.68, "depth": 0.82, "height": 1.00 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "seat", "geometry": "box", "size": [0.55, 0.12, 0.55], "position": [0, 0.38, 0.08], "color": "#d4c4a0" },
|
||||||
|
{ "name": "back", "geometry": "box", "size": [0.55, 0.5, 0.08], "position": [0, 0.75, -0.30], "color": "#d4c4a0" },
|
||||||
|
{ "name": "frame_l", "geometry": "box", "size": [0.05, 0.95, 0.75], "position": [-0.30, 0.48, 0], "color": "#a08050" },
|
||||||
|
{ "name": "frame_r", "geometry": "box", "size": [0.05, 0.95, 0.75], "position": [0.30, 0.48, 0], "color": "#a08050" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-strandmon-chair",
|
||||||
|
"name": "STRANDMON Wing Chair",
|
||||||
|
"ikeaSeries": "STRANDMON",
|
||||||
|
"category": "seating",
|
||||||
|
"rooms": ["wohnzimmer"],
|
||||||
|
"dimensions": { "width": 0.82, "depth": 0.96, "height": 1.01 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "seat", "geometry": "box", "size": [0.60, 0.15, 0.55], "position": [0, 0.38, 0.1], "color": "#5a7060" },
|
||||||
|
{ "name": "back", "geometry": "box", "size": [0.65, 0.55, 0.12], "position": [0, 0.73, -0.35], "color": "#5a7060" },
|
||||||
|
{ "name": "wing_l", "geometry": "box", "size": [0.12, 0.45, 0.30], "position": [-0.35, 0.70, -0.15], "color": "#5a7060" },
|
||||||
|
{ "name": "wing_r", "geometry": "box", "size": [0.12, 0.45, 0.30], "position": [0.35, 0.70, -0.15], "color": "#5a7060" },
|
||||||
|
{ "name": "arm_l", "geometry": "box", "size": [0.10, 0.20, 0.55], "position": [-0.36, 0.48, 0.1], "color": "#4a6050" },
|
||||||
|
{ "name": "arm_r", "geometry": "box", "size": [0.10, 0.20, 0.55], "position": [0.36, 0.48, 0.1], "color": "#4a6050" },
|
||||||
|
{ "name": "leg1", "geometry": "box", "size": [0.04, 0.15, 0.04], "position": [-0.30, 0.075, 0.35], "color": "#3a3020" },
|
||||||
|
{ "name": "leg2", "geometry": "box", "size": [0.04, 0.15, 0.04], "position": [0.30, 0.075, 0.35], "color": "#3a3020" },
|
||||||
|
{ "name": "leg3", "geometry": "box", "size": [0.04, 0.15, 0.04], "position": [-0.30, 0.075, -0.35], "color": "#3a3020" },
|
||||||
|
{ "name": "leg4", "geometry": "box", "size": [0.04, 0.15, 0.04], "position": [0.30, 0.075, -0.35], "color": "#3a3020" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-klippan-sofa",
|
||||||
|
"name": "KLIPPAN 2-Seat Sofa",
|
||||||
|
"ikeaSeries": "KLIPPAN",
|
||||||
|
"category": "seating",
|
||||||
|
"rooms": ["wohnzimmer"],
|
||||||
|
"dimensions": { "width": 1.80, "depth": 0.88, "height": 0.66 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "base", "geometry": "box", "size": [1.80, 0.35, 0.88], "position": [0, 0.175, 0], "color": "#3a3a3a" },
|
||||||
|
{ "name": "back", "geometry": "box", "size": [1.80, 0.31, 0.15], "position": [0, 0.505, -0.365], "color": "#3a3a3a" },
|
||||||
|
{ "name": "arm_l", "geometry": "box", "size": [0.15, 0.50, 0.73], "position": [-0.825, 0.25, 0.075], "color": "#333333" },
|
||||||
|
{ "name": "arm_r", "geometry": "box", "size": [0.15, 0.50, 0.73], "position": [0.825, 0.25, 0.075], "color": "#333333" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-ektorp-sofa",
|
||||||
|
"name": "EKTORP 3-Seat Sofa",
|
||||||
|
"ikeaSeries": "EKTORP",
|
||||||
|
"category": "seating",
|
||||||
|
"rooms": ["wohnzimmer"],
|
||||||
|
"dimensions": { "width": 2.18, "depth": 0.88, "height": 0.88 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "base", "geometry": "box", "size": [2.18, 0.42, 0.88], "position": [0, 0.21, 0], "color": "#e8e0d4" },
|
||||||
|
{ "name": "back", "geometry": "box", "size": [2.18, 0.46, 0.15], "position": [0, 0.65, -0.365], "color": "#e8e0d4" },
|
||||||
|
{ "name": "arm_l", "geometry": "box", "size": [0.18, 0.65, 0.88], "position": [-1.0, 0.325, 0], "color": "#ddd8cc" },
|
||||||
|
{ "name": "arm_r", "geometry": "box", "size": [0.18, 0.65, 0.88], "position": [1.0, 0.325, 0], "color": "#ddd8cc" },
|
||||||
|
{ "name": "cushion1", "geometry": "box", "size": [0.55, 0.10, 0.55], "position": [-0.56, 0.47, 0.1], "color": "#ece4d8" },
|
||||||
|
{ "name": "cushion2", "geometry": "box", "size": [0.55, 0.10, 0.55], "position": [0, 0.47, 0.1], "color": "#ece4d8" },
|
||||||
|
{ "name": "cushion3", "geometry": "box", "size": [0.55, 0.10, 0.55], "position": [0.56, 0.47, 0.1], "color": "#ece4d8" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-kivik-sofa",
|
||||||
|
"name": "KIVIK 3-Seat Sofa",
|
||||||
|
"ikeaSeries": "KIVIK",
|
||||||
|
"category": "seating",
|
||||||
|
"rooms": ["wohnzimmer"],
|
||||||
|
"dimensions": { "width": 2.28, "depth": 0.95, "height": 0.83 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "base", "geometry": "box", "size": [2.28, 0.40, 0.95], "position": [0, 0.20, 0], "color": "#8899aa" },
|
||||||
|
{ "name": "back", "geometry": "box", "size": [2.10, 0.43, 0.18], "position": [0, 0.615, -0.385], "color": "#8899aa" },
|
||||||
|
{ "name": "arm_l", "geometry": "box", "size": [0.20, 0.55, 0.95], "position": [-1.04, 0.275, 0], "color": "#7a8a9a" },
|
||||||
|
{ "name": "arm_r", "geometry": "box", "size": [0.20, 0.55, 0.95], "position": [1.04, 0.275, 0], "color": "#7a8a9a" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-markus-chair",
|
||||||
|
"name": "MARKUS Office Chair",
|
||||||
|
"ikeaSeries": "MARKUS",
|
||||||
|
"category": "office",
|
||||||
|
"rooms": ["arbeitszimmer"],
|
||||||
|
"dimensions": { "width": 0.62, "depth": 0.60, "height": 1.35 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "seat", "geometry": "box", "size": [0.50, 0.08, 0.48], "position": [0, 0.48, 0], "color": "#2a2a2a" },
|
||||||
|
{ "name": "back", "geometry": "box", "size": [0.48, 0.65, 0.06], "position": [0, 0.87, -0.24], "color": "#2a2a2a" },
|
||||||
|
{ "name": "headrest", "geometry": "box", "size": [0.30, 0.18, 0.06], "position": [0, 1.28, -0.24], "color": "#2a2a2a" },
|
||||||
|
{ "name": "pedestal", "geometry": "cylinder", "radius": 0.03, "height": 0.44, "position": [0, 0.22, 0], "color": "#666666" },
|
||||||
|
{ "name": "base", "geometry": "cylinder", "radius": 0.28, "height": 0.04, "position": [0, 0.02, 0], "color": "#444444" },
|
||||||
|
{ "name": "arm_l", "geometry": "box", "size": [0.04, 0.04, 0.22], "position": [-0.27, 0.55, 0.05], "color": "#444444" },
|
||||||
|
{ "name": "arm_r", "geometry": "box", "size": [0.04, 0.04, 0.22], "position": [0.27, 0.55, 0.05], "color": "#444444" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-malm-bed-queen",
|
||||||
|
"name": "MALM Bed Queen 160cm",
|
||||||
|
"ikeaSeries": "MALM",
|
||||||
|
"category": "beds",
|
||||||
|
"rooms": ["schlafzimmer"],
|
||||||
|
"dimensions": { "width": 1.60, "depth": 2.09, "height": 0.92 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "frame", "geometry": "box", "size": [1.60, 0.28, 2.09], "position": [0, 0.14, 0], "color": "#ffffff" },
|
||||||
|
{ "name": "mattress", "geometry": "box", "size": [1.50, 0.20, 1.98], "position": [0, 0.38, 0], "color": "#f5f0eb" },
|
||||||
|
{ "name": "headboard", "geometry": "box", "size": [1.60, 0.64, 0.04], "position": [0, 0.60, -1.025], "color": "#f0f0f0" },
|
||||||
|
{ "name": "pillow_l", "geometry": "box", "size": [0.55, 0.08, 0.38], "position": [-0.38, 0.52, -0.72], "color": "#ffffff" },
|
||||||
|
{ "name": "pillow_r", "geometry": "box", "size": [0.55, 0.08, 0.38], "position": [0.38, 0.52, -0.72], "color": "#ffffff" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-malm-bed-king",
|
||||||
|
"name": "MALM Bed King 180cm",
|
||||||
|
"ikeaSeries": "MALM",
|
||||||
|
"category": "beds",
|
||||||
|
"rooms": ["schlafzimmer"],
|
||||||
|
"dimensions": { "width": 1.80, "depth": 2.09, "height": 0.92 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "frame", "geometry": "box", "size": [1.80, 0.28, 2.09], "position": [0, 0.14, 0], "color": "#ffffff" },
|
||||||
|
{ "name": "mattress", "geometry": "box", "size": [1.70, 0.20, 1.98], "position": [0, 0.38, 0], "color": "#f5f0eb" },
|
||||||
|
{ "name": "headboard", "geometry": "box", "size": [1.80, 0.64, 0.04], "position": [0, 0.60, -1.025], "color": "#f0f0f0" },
|
||||||
|
{ "name": "pillow_l", "geometry": "box", "size": [0.55, 0.08, 0.38], "position": [-0.45, 0.52, -0.72], "color": "#ffffff" },
|
||||||
|
{ "name": "pillow_r", "geometry": "box", "size": [0.55, 0.08, 0.38], "position": [0.45, 0.52, -0.72], "color": "#ffffff" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-malm-bed-single",
|
||||||
|
"name": "MALM Bed Single 90cm",
|
||||||
|
"ikeaSeries": "MALM",
|
||||||
|
"category": "beds",
|
||||||
|
"rooms": ["kinderzimmer"],
|
||||||
|
"dimensions": { "width": 0.90, "depth": 2.09, "height": 0.92 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "frame", "geometry": "box", "size": [0.90, 0.28, 2.09], "position": [0, 0.14, 0], "color": "#ffffff" },
|
||||||
|
{ "name": "mattress", "geometry": "box", "size": [0.82, 0.20, 1.98], "position": [0, 0.38, 0], "color": "#f5f0eb" },
|
||||||
|
{ "name": "headboard", "geometry": "box", "size": [0.90, 0.64, 0.04], "position": [0, 0.60, -1.025], "color": "#f0f0f0" },
|
||||||
|
{ "name": "pillow", "geometry": "box", "size": [0.55, 0.08, 0.38], "position": [0, 0.52, -0.72], "color": "#ffffff" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-hemnes-bed-queen",
|
||||||
|
"name": "HEMNES Bed Queen",
|
||||||
|
"ikeaSeries": "HEMNES",
|
||||||
|
"category": "beds",
|
||||||
|
"rooms": ["schlafzimmer"],
|
||||||
|
"dimensions": { "width": 1.63, "depth": 2.11, "height": 1.12 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "frame", "geometry": "box", "size": [1.63, 0.30, 2.11], "position": [0, 0.15, 0], "color": "#c4a87d" },
|
||||||
|
{ "name": "mattress", "geometry": "box", "size": [1.53, 0.20, 2.0], "position": [0, 0.40, 0], "color": "#f5f0eb" },
|
||||||
|
{ "name": "headboard", "geometry": "box", "size": [1.63, 0.82, 0.06], "position": [0, 0.71, -1.025], "color": "#b09870" },
|
||||||
|
{ "name": "footboard", "geometry": "box", "size": [1.63, 0.36, 0.04], "position": [0, 0.48, 1.035], "color": "#b09870" },
|
||||||
|
{ "name": "pillow_l", "geometry": "box", "size": [0.55, 0.08, 0.38], "position": [-0.38, 0.54, -0.72], "color": "#ffffff" },
|
||||||
|
{ "name": "pillow_r", "geometry": "box", "size": [0.55, 0.08, 0.38], "position": [0.38, 0.54, -0.72], "color": "#ffffff" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-kura-bed",
|
||||||
|
"name": "KURA Reversible Bed",
|
||||||
|
"ikeaSeries": "KURA",
|
||||||
|
"category": "beds",
|
||||||
|
"rooms": ["kinderzimmer"],
|
||||||
|
"dimensions": { "width": 0.99, "depth": 2.09, "height": 1.16 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "frame", "geometry": "box", "size": [0.99, 0.28, 2.09], "position": [0, 0.14, 0], "color": "#ffffff" },
|
||||||
|
{ "name": "mattress", "geometry": "box", "size": [0.90, 0.12, 1.98], "position": [0, 0.34, 0], "color": "#f5f0eb" },
|
||||||
|
{ "name": "rail_l", "geometry": "box", "size": [0.03, 0.76, 2.09], "position": [-0.48, 0.66, 0], "color": "#b4a48c" },
|
||||||
|
{ "name": "rail_r", "geometry": "box", "size": [0.03, 0.76, 2.09], "position": [0.48, 0.66, 0], "color": "#b4a48c" },
|
||||||
|
{ "name": "top_frame", "geometry": "box", "size": [0.99, 0.04, 2.09], "position": [0, 1.14, 0], "color": "#b4a48c" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-metod-base",
|
||||||
|
"name": "METOD Base Cabinet 60cm",
|
||||||
|
"ikeaSeries": "METOD",
|
||||||
|
"category": "kitchen",
|
||||||
|
"rooms": ["kueche"],
|
||||||
|
"dimensions": { "width": 0.60, "depth": 0.60, "height": 0.80 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "body", "geometry": "box", "size": [0.60, 0.80, 0.60], "position": [0, 0.40, 0], "color": "#f0ece4" },
|
||||||
|
{ "name": "door", "geometry": "box", "size": [0.56, 0.70, 0.02], "position": [0, 0.40, 0.29], "color": "#e0dcd4" },
|
||||||
|
{ "name": "counter", "geometry": "box", "size": [0.60, 0.04, 0.62], "position": [0, 0.82, 0], "color": "#888888" },
|
||||||
|
{ "name": "handle", "geometry": "box", "size": [0.10, 0.02, 0.03], "position": [0, 0.62, 0.31], "color": "#aaaaaa" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-metod-base-80",
|
||||||
|
"name": "METOD Base Cabinet 80cm",
|
||||||
|
"ikeaSeries": "METOD",
|
||||||
|
"category": "kitchen",
|
||||||
|
"rooms": ["kueche"],
|
||||||
|
"dimensions": { "width": 0.80, "depth": 0.60, "height": 0.80 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "body", "geometry": "box", "size": [0.80, 0.80, 0.60], "position": [0, 0.40, 0], "color": "#f0ece4" },
|
||||||
|
{ "name": "door_l", "geometry": "box", "size": [0.37, 0.70, 0.02], "position": [-0.19, 0.40, 0.29], "color": "#e0dcd4" },
|
||||||
|
{ "name": "door_r", "geometry": "box", "size": [0.37, 0.70, 0.02], "position": [0.19, 0.40, 0.29], "color": "#e0dcd4" },
|
||||||
|
{ "name": "counter", "geometry": "box", "size": [0.80, 0.04, 0.62], "position": [0, 0.82, 0], "color": "#888888" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-metod-wall",
|
||||||
|
"name": "METOD Wall Cabinet 60cm",
|
||||||
|
"ikeaSeries": "METOD",
|
||||||
|
"category": "kitchen",
|
||||||
|
"rooms": ["kueche"],
|
||||||
|
"dimensions": { "width": 0.60, "depth": 0.37, "height": 0.80 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "body", "geometry": "box", "size": [0.60, 0.80, 0.37], "position": [0, 1.70, 0], "color": "#f0ece4" },
|
||||||
|
{ "name": "door", "geometry": "box", "size": [0.56, 0.76, 0.02], "position": [0, 1.70, 0.175], "color": "#e0dcd4" },
|
||||||
|
{ "name": "handle", "geometry": "box", "size": [0.10, 0.02, 0.03], "position": [0, 1.40, 0.20], "color": "#aaaaaa" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-metod-tall",
|
||||||
|
"name": "METOD Tall Cabinet 60cm",
|
||||||
|
"ikeaSeries": "METOD",
|
||||||
|
"category": "kitchen",
|
||||||
|
"rooms": ["kueche"],
|
||||||
|
"dimensions": { "width": 0.60, "depth": 0.60, "height": 2.00 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "body", "geometry": "box", "size": [0.60, 2.00, 0.60], "position": [0, 1.00, 0], "color": "#f0ece4" },
|
||||||
|
{ "name": "door_top", "geometry": "box", "size": [0.56, 0.90, 0.02], "position": [0, 1.50, 0.29], "color": "#e0dcd4" },
|
||||||
|
{ "name": "door_bot", "geometry": "box", "size": [0.56, 0.90, 0.02], "position": [0, 0.50, 0.29], "color": "#e0dcd4" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-vadholma-island",
|
||||||
|
"name": "VADHOLMA Kitchen Island",
|
||||||
|
"ikeaSeries": "VADHOLMA",
|
||||||
|
"category": "kitchen",
|
||||||
|
"rooms": ["kueche"],
|
||||||
|
"dimensions": { "width": 1.26, "depth": 0.79, "height": 0.90 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "body", "geometry": "box", "size": [1.26, 0.85, 0.79], "position": [0, 0.425, 0], "color": "#f0ece4" },
|
||||||
|
{ "name": "counter", "geometry": "box", "size": [1.30, 0.04, 0.83], "position": [0, 0.87, 0], "color": "#888888" },
|
||||||
|
{ "name": "shelf", "geometry": "box", "size": [1.16, 0.02, 0.69], "position": [0, 0.15, 0], "color": "#c4a87d" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-knoxhult-base",
|
||||||
|
"name": "KNOXHULT Base Cabinet 120cm",
|
||||||
|
"ikeaSeries": "KNOXHULT",
|
||||||
|
"category": "kitchen",
|
||||||
|
"rooms": ["kueche"],
|
||||||
|
"dimensions": { "width": 1.20, "depth": 0.61, "height": 0.85 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "body", "geometry": "box", "size": [1.20, 0.85, 0.61], "position": [0, 0.425, 0], "color": "#f0ece4" },
|
||||||
|
{ "name": "door_l", "geometry": "box", "size": [0.38, 0.70, 0.02], "position": [-0.38, 0.40, 0.295], "color": "#e0dcd4" },
|
||||||
|
{ "name": "door_r", "geometry": "box", "size": [0.38, 0.70, 0.02], "position": [0.38, 0.40, 0.295], "color": "#e0dcd4" },
|
||||||
|
{ "name": "drawer", "geometry": "box", "size": [0.38, 0.18, 0.02], "position": [0, 0.70, 0.295], "color": "#e0dcd4" },
|
||||||
|
{ "name": "counter", "geometry": "box", "size": [1.20, 0.04, 0.63], "position": [0, 0.83, 0], "color": "#888888" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-linnmon-alex-desk",
|
||||||
|
"name": "LINNMON/ALEX Desk",
|
||||||
|
"ikeaSeries": "LINNMON",
|
||||||
|
"category": "office",
|
||||||
|
"rooms": ["arbeitszimmer", "kinderzimmer"],
|
||||||
|
"dimensions": { "width": 1.50, "depth": 0.75, "height": 0.73 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "top", "geometry": "box", "size": [1.50, 0.04, 0.75], "position": [0, 0.71, 0], "color": "#f0ece4" },
|
||||||
|
{ "name": "drawer_unit", "geometry": "box", "size": [0.36, 0.58, 0.70], "position": [0.53, 0.29, -0.02], "color": "#ffffff" },
|
||||||
|
{ "name": "drawer1", "geometry": "box", "size": [0.32, 0.12, 0.02], "position": [0.53, 0.12, 0.33], "color": "#f0f0f0" },
|
||||||
|
{ "name": "drawer2", "geometry": "box", "size": [0.32, 0.12, 0.02], "position": [0.53, 0.28, 0.33], "color": "#f0f0f0" },
|
||||||
|
{ "name": "drawer3", "geometry": "box", "size": [0.32, 0.12, 0.02], "position": [0.53, 0.44, 0.33], "color": "#f0f0f0" },
|
||||||
|
{ "name": "leg1", "geometry": "box", "size": [0.04, 0.69, 0.04], "position": [-0.70, 0.345, -0.33], "color": "#cccccc" },
|
||||||
|
{ "name": "leg2", "geometry": "box", "size": [0.04, 0.69, 0.04], "position": [-0.70, 0.345, 0.33], "color": "#cccccc" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ikea-sundvik-bed",
|
||||||
|
"name": "SUNDVIK Child Bed",
|
||||||
|
"ikeaSeries": "SUNDVIK",
|
||||||
|
"category": "beds",
|
||||||
|
"rooms": ["kinderzimmer"],
|
||||||
|
"dimensions": { "width": 0.80, "depth": 1.67, "height": 0.83 },
|
||||||
|
"mesh": {
|
||||||
|
"type": "group",
|
||||||
|
"parts": [
|
||||||
|
{ "name": "frame", "geometry": "box", "size": [0.80, 0.25, 1.67], "position": [0, 0.125, 0], "color": "#f0ece4" },
|
||||||
|
{ "name": "mattress", "geometry": "box", "size": [0.70, 0.12, 1.56], "position": [0, 0.31, 0], "color": "#f5f0eb" },
|
||||||
|
{ "name": "headboard", "geometry": "box", "size": [0.80, 0.58, 0.04], "position": [0, 0.54, -0.815], "color": "#e8e4dc" },
|
||||||
|
{ "name": "footboard", "geometry": "box", "size": [0.80, 0.40, 0.04], "position": [0, 0.45, 0.815], "color": "#e8e4dc" },
|
||||||
|
{ "name": "pillow", "geometry": "box", "size": [0.40, 0.06, 0.28], "position": [0, 0.40, -0.56], "color": "#ffffff" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
323
data/loft-modern.json
Normal file
323
data/loft-modern.json
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
{
|
||||||
|
"name": "Modernes Loft",
|
||||||
|
"description": "Modern open-plan loft apartment, 1 floor, ~80sqm with high ceilings",
|
||||||
|
"units": "meters",
|
||||||
|
"building": {
|
||||||
|
"footprint": { "width": 10, "depth": 8 },
|
||||||
|
"wallThickness": 0.2,
|
||||||
|
"roofType": "flat"
|
||||||
|
},
|
||||||
|
"floors": [
|
||||||
|
{
|
||||||
|
"id": "eg",
|
||||||
|
"name": "Loft",
|
||||||
|
"nameEN": "Loft",
|
||||||
|
"level": 0,
|
||||||
|
"ceilingHeight": 3.2,
|
||||||
|
"rooms": [
|
||||||
|
{
|
||||||
|
"id": "eg-eingang",
|
||||||
|
"name": "Eingang",
|
||||||
|
"nameEN": "Entry",
|
||||||
|
"type": "hallway",
|
||||||
|
"position": { "x": 0, "y": 0 },
|
||||||
|
"dimensions": { "width": 2.0, "length": 2.5 },
|
||||||
|
"flooring": "tile",
|
||||||
|
"walls": {
|
||||||
|
"south": {
|
||||||
|
"type": "exterior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "eg-ein-d1",
|
||||||
|
"type": "entry",
|
||||||
|
"position": 0.5,
|
||||||
|
"width": 1.0,
|
||||||
|
"height": 2.2,
|
||||||
|
"connectsTo": "exterior"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"north": {
|
||||||
|
"type": "interior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "eg-ein-d2",
|
||||||
|
"type": "open",
|
||||||
|
"position": 0.3,
|
||||||
|
"width": 1.2,
|
||||||
|
"height": 2.4,
|
||||||
|
"connectsTo": "eg-wohnbereich"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"west": {
|
||||||
|
"type": "exterior"
|
||||||
|
},
|
||||||
|
"east": {
|
||||||
|
"type": "interior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "eg-ein-d3",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 0.5,
|
||||||
|
"width": 0.9,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "eg-buero"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eg-buero",
|
||||||
|
"name": "Home Office",
|
||||||
|
"nameEN": "Home Office",
|
||||||
|
"type": "office",
|
||||||
|
"position": { "x": 2.0, "y": 0 },
|
||||||
|
"dimensions": { "width": 3.0, "length": 2.5 },
|
||||||
|
"flooring": "hardwood",
|
||||||
|
"walls": {
|
||||||
|
"south": {
|
||||||
|
"type": "exterior",
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"id": "eg-bu-w1",
|
||||||
|
"type": "casement",
|
||||||
|
"position": 0.8,
|
||||||
|
"width": 1.2,
|
||||||
|
"height": 1.6,
|
||||||
|
"sillHeight": 0.6
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"north": {
|
||||||
|
"type": "interior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "eg-bu-d1",
|
||||||
|
"type": "open",
|
||||||
|
"position": 0.5,
|
||||||
|
"width": 1.0,
|
||||||
|
"height": 2.4,
|
||||||
|
"connectsTo": "eg-wohnbereich"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"west": {
|
||||||
|
"type": "interior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "eg-bu-d2",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 0.5,
|
||||||
|
"width": 0.9,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "eg-eingang"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"east": {
|
||||||
|
"type": "interior"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eg-badezimmer",
|
||||||
|
"name": "Badezimmer",
|
||||||
|
"nameEN": "Bathroom",
|
||||||
|
"type": "bathroom",
|
||||||
|
"position": { "x": 5.0, "y": 0 },
|
||||||
|
"dimensions": { "width": 2.5, "length": 2.5 },
|
||||||
|
"flooring": "tile",
|
||||||
|
"walls": {
|
||||||
|
"south": {
|
||||||
|
"type": "exterior",
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"id": "eg-bz-w1",
|
||||||
|
"type": "casement",
|
||||||
|
"position": 0.8,
|
||||||
|
"width": 0.8,
|
||||||
|
"height": 0.8,
|
||||||
|
"sillHeight": 1.4
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"north": {
|
||||||
|
"type": "interior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "eg-bz-d1",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 0.5,
|
||||||
|
"width": 0.8,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "eg-schlafzimmer"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"west": {
|
||||||
|
"type": "interior"
|
||||||
|
},
|
||||||
|
"east": {
|
||||||
|
"type": "interior"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eg-ankleide",
|
||||||
|
"name": "Ankleide",
|
||||||
|
"nameEN": "Walk-in Closet",
|
||||||
|
"type": "storage",
|
||||||
|
"position": { "x": 7.5, "y": 0 },
|
||||||
|
"dimensions": { "width": 2.5, "length": 2.5 },
|
||||||
|
"flooring": "hardwood",
|
||||||
|
"walls": {
|
||||||
|
"south": {
|
||||||
|
"type": "exterior"
|
||||||
|
},
|
||||||
|
"north": {
|
||||||
|
"type": "interior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "eg-ank-d1",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 0.5,
|
||||||
|
"width": 0.9,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "eg-schlafzimmer"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"west": {
|
||||||
|
"type": "interior"
|
||||||
|
},
|
||||||
|
"east": {
|
||||||
|
"type": "exterior"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eg-wohnbereich",
|
||||||
|
"name": "Wohn-/Essbereich mit Küche",
|
||||||
|
"nameEN": "Open Living/Dining/Kitchen",
|
||||||
|
"type": "living",
|
||||||
|
"position": { "x": 0, "y": 2.5 },
|
||||||
|
"dimensions": { "width": 5.0, "length": 5.5 },
|
||||||
|
"flooring": "hardwood",
|
||||||
|
"walls": {
|
||||||
|
"south": {
|
||||||
|
"type": "interior"
|
||||||
|
},
|
||||||
|
"north": {
|
||||||
|
"type": "exterior",
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"id": "eg-wb-w1",
|
||||||
|
"type": "fixed",
|
||||||
|
"position": 0.3,
|
||||||
|
"width": 2.0,
|
||||||
|
"height": 2.2,
|
||||||
|
"sillHeight": 0.3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eg-wb-w2",
|
||||||
|
"type": "fixed",
|
||||||
|
"position": 2.8,
|
||||||
|
"width": 2.0,
|
||||||
|
"height": 2.2,
|
||||||
|
"sillHeight": 0.3
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"west": {
|
||||||
|
"type": "exterior",
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"id": "eg-wb-w3",
|
||||||
|
"type": "fixed",
|
||||||
|
"position": 1.0,
|
||||||
|
"width": 1.8,
|
||||||
|
"height": 2.0,
|
||||||
|
"sillHeight": 0.4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eg-wb-w4",
|
||||||
|
"type": "fixed",
|
||||||
|
"position": 3.5,
|
||||||
|
"width": 1.8,
|
||||||
|
"height": 2.0,
|
||||||
|
"sillHeight": 0.4
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"east": {
|
||||||
|
"type": "interior"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eg-schlafzimmer",
|
||||||
|
"name": "Schlafzimmer",
|
||||||
|
"nameEN": "Master Bedroom",
|
||||||
|
"type": "bedroom",
|
||||||
|
"position": { "x": 5.0, "y": 2.5 },
|
||||||
|
"dimensions": { "width": 5.0, "length": 5.5 },
|
||||||
|
"flooring": "hardwood",
|
||||||
|
"walls": {
|
||||||
|
"south": {
|
||||||
|
"type": "interior"
|
||||||
|
},
|
||||||
|
"north": {
|
||||||
|
"type": "exterior",
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"id": "eg-sz-w1",
|
||||||
|
"type": "fixed",
|
||||||
|
"position": 1.0,
|
||||||
|
"width": 2.0,
|
||||||
|
"height": 2.0,
|
||||||
|
"sillHeight": 0.4
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"west": {
|
||||||
|
"type": "interior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "eg-sz-d1",
|
||||||
|
"type": "open",
|
||||||
|
"position": 3.0,
|
||||||
|
"width": 1.2,
|
||||||
|
"height": 2.4,
|
||||||
|
"connectsTo": "eg-wohnbereich"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"east": {
|
||||||
|
"type": "exterior",
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"id": "eg-sz-w2",
|
||||||
|
"type": "casement",
|
||||||
|
"position": 1.5,
|
||||||
|
"width": 1.4,
|
||||||
|
"height": 1.8,
|
||||||
|
"sillHeight": 0.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eg-sz-w3",
|
||||||
|
"type": "casement",
|
||||||
|
"position": 3.5,
|
||||||
|
"width": 1.4,
|
||||||
|
"height": 1.8,
|
||||||
|
"sillHeight": 0.5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
885
data/villa-large.json
Normal file
885
data/villa-large.json
Normal file
@@ -0,0 +1,885 @@
|
|||||||
|
{
|
||||||
|
"name": "Villa Sonnenhügel",
|
||||||
|
"description": "Large luxury villa, 2 floors, ~300sqm living space with generous rooms",
|
||||||
|
"units": "meters",
|
||||||
|
"building": {
|
||||||
|
"footprint": { "width": 15, "depth": 11 },
|
||||||
|
"wallThickness": 0.24,
|
||||||
|
"roofType": "hip"
|
||||||
|
},
|
||||||
|
"floors": [
|
||||||
|
{
|
||||||
|
"id": "eg",
|
||||||
|
"name": "Erdgeschoss",
|
||||||
|
"nameEN": "Ground Floor",
|
||||||
|
"level": 0,
|
||||||
|
"ceilingHeight": 2.8,
|
||||||
|
"rooms": [
|
||||||
|
{
|
||||||
|
"id": "eg-foyer",
|
||||||
|
"name": "Foyer",
|
||||||
|
"nameEN": "Grand Foyer",
|
||||||
|
"type": "hallway",
|
||||||
|
"position": { "x": 5.5, "y": 0 },
|
||||||
|
"dimensions": { "width": 2.5, "length": 11.0 },
|
||||||
|
"flooring": "tile",
|
||||||
|
"walls": {
|
||||||
|
"south": {
|
||||||
|
"type": "exterior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "eg-fo-d1",
|
||||||
|
"type": "entry",
|
||||||
|
"position": 0.5,
|
||||||
|
"width": 1.4,
|
||||||
|
"height": 2.4,
|
||||||
|
"connectsTo": "exterior"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"north": {
|
||||||
|
"type": "exterior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "eg-fo-d2",
|
||||||
|
"type": "patio",
|
||||||
|
"position": 0.5,
|
||||||
|
"width": 1.5,
|
||||||
|
"height": 2.2,
|
||||||
|
"connectsTo": "exterior"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"west": {
|
||||||
|
"type": "interior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "eg-fo-d3",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 1.0,
|
||||||
|
"width": 0.9,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "eg-kueche"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eg-fo-d4",
|
||||||
|
"type": "open",
|
||||||
|
"position": 5.0,
|
||||||
|
"width": 1.6,
|
||||||
|
"height": 2.4,
|
||||||
|
"connectsTo": "eg-wohnzimmer"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"east": {
|
||||||
|
"type": "interior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "eg-fo-d5",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 0.5,
|
||||||
|
"width": 0.8,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "eg-gaeste-wc"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eg-fo-d6",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 2.5,
|
||||||
|
"width": 0.9,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "eg-arbeitszimmer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eg-fo-d7",
|
||||||
|
"type": "open",
|
||||||
|
"position": 6.0,
|
||||||
|
"width": 1.4,
|
||||||
|
"height": 2.4,
|
||||||
|
"connectsTo": "eg-esszimmer"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eg-kueche",
|
||||||
|
"name": "Küche",
|
||||||
|
"nameEN": "Kitchen",
|
||||||
|
"type": "kitchen",
|
||||||
|
"position": { "x": 0, "y": 0 },
|
||||||
|
"dimensions": { "width": 5.5, "length": 5.0 },
|
||||||
|
"flooring": "tile",
|
||||||
|
"walls": {
|
||||||
|
"south": {
|
||||||
|
"type": "exterior",
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"id": "eg-ku-w1",
|
||||||
|
"type": "casement",
|
||||||
|
"position": 1.5,
|
||||||
|
"width": 1.4,
|
||||||
|
"height": 1.2,
|
||||||
|
"sillHeight": 0.9
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eg-ku-w2",
|
||||||
|
"type": "casement",
|
||||||
|
"position": 3.5,
|
||||||
|
"width": 1.4,
|
||||||
|
"height": 1.2,
|
||||||
|
"sillHeight": 0.9
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"north": {
|
||||||
|
"type": "interior"
|
||||||
|
},
|
||||||
|
"west": {
|
||||||
|
"type": "exterior",
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"id": "eg-ku-w3",
|
||||||
|
"type": "casement",
|
||||||
|
"position": 2.0,
|
||||||
|
"width": 1.2,
|
||||||
|
"height": 1.2,
|
||||||
|
"sillHeight": 0.9
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"east": {
|
||||||
|
"type": "interior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "eg-ku-d1",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 1.0,
|
||||||
|
"width": 0.9,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "eg-foyer"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eg-wohnzimmer",
|
||||||
|
"name": "Wohnzimmer",
|
||||||
|
"nameEN": "Living Room",
|
||||||
|
"type": "living",
|
||||||
|
"position": { "x": 0, "y": 5.0 },
|
||||||
|
"dimensions": { "width": 5.5, "length": 6.0 },
|
||||||
|
"flooring": "hardwood",
|
||||||
|
"walls": {
|
||||||
|
"south": {
|
||||||
|
"type": "interior"
|
||||||
|
},
|
||||||
|
"north": {
|
||||||
|
"type": "exterior",
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"id": "eg-wz-w1",
|
||||||
|
"type": "fixed",
|
||||||
|
"position": 0.5,
|
||||||
|
"width": 2.0,
|
||||||
|
"height": 1.8,
|
||||||
|
"sillHeight": 0.4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eg-wz-w2",
|
||||||
|
"type": "fixed",
|
||||||
|
"position": 3.0,
|
||||||
|
"width": 2.0,
|
||||||
|
"height": 1.8,
|
||||||
|
"sillHeight": 0.4
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"west": {
|
||||||
|
"type": "exterior",
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"id": "eg-wz-w3",
|
||||||
|
"type": "casement",
|
||||||
|
"position": 1.5,
|
||||||
|
"width": 1.4,
|
||||||
|
"height": 1.6,
|
||||||
|
"sillHeight": 0.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eg-wz-w4",
|
||||||
|
"type": "casement",
|
||||||
|
"position": 3.8,
|
||||||
|
"width": 1.4,
|
||||||
|
"height": 1.6,
|
||||||
|
"sillHeight": 0.5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"east": {
|
||||||
|
"type": "interior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "eg-wz-d1",
|
||||||
|
"type": "open",
|
||||||
|
"position": 0.0,
|
||||||
|
"width": 1.6,
|
||||||
|
"height": 2.4,
|
||||||
|
"connectsTo": "eg-foyer"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eg-gaeste-wc",
|
||||||
|
"name": "Gäste-WC",
|
||||||
|
"nameEN": "Guest WC",
|
||||||
|
"type": "bathroom",
|
||||||
|
"position": { "x": 8.0, "y": 0 },
|
||||||
|
"dimensions": { "width": 2.5, "length": 2.0 },
|
||||||
|
"flooring": "tile",
|
||||||
|
"walls": {
|
||||||
|
"south": {
|
||||||
|
"type": "exterior",
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"id": "eg-gwc-w1",
|
||||||
|
"type": "casement",
|
||||||
|
"position": 0.8,
|
||||||
|
"width": 0.6,
|
||||||
|
"height": 0.8,
|
||||||
|
"sillHeight": 1.3
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"north": {
|
||||||
|
"type": "interior"
|
||||||
|
},
|
||||||
|
"west": {
|
||||||
|
"type": "interior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "eg-gwc-d1",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 0.3,
|
||||||
|
"width": 0.8,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "eg-foyer"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"east": {
|
||||||
|
"type": "interior"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eg-hwr",
|
||||||
|
"name": "Hauswirtschaftsraum",
|
||||||
|
"nameEN": "Utility Room",
|
||||||
|
"type": "utility",
|
||||||
|
"position": { "x": 10.5, "y": 0 },
|
||||||
|
"dimensions": { "width": 2.5, "length": 2.0 },
|
||||||
|
"flooring": "tile",
|
||||||
|
"walls": {
|
||||||
|
"south": {
|
||||||
|
"type": "exterior"
|
||||||
|
},
|
||||||
|
"north": {
|
||||||
|
"type": "interior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "eg-hwr-d1",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 0.5,
|
||||||
|
"width": 0.8,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "eg-arbeitszimmer"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"west": {
|
||||||
|
"type": "interior"
|
||||||
|
},
|
||||||
|
"east": {
|
||||||
|
"type": "exterior"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eg-arbeitszimmer",
|
||||||
|
"name": "Arbeitszimmer",
|
||||||
|
"nameEN": "Home Office / Library",
|
||||||
|
"type": "office",
|
||||||
|
"position": { "x": 8.0, "y": 2.0 },
|
||||||
|
"dimensions": { "width": 5.0, "length": 4.0 },
|
||||||
|
"flooring": "hardwood",
|
||||||
|
"walls": {
|
||||||
|
"south": {
|
||||||
|
"type": "interior"
|
||||||
|
},
|
||||||
|
"north": {
|
||||||
|
"type": "interior"
|
||||||
|
},
|
||||||
|
"west": {
|
||||||
|
"type": "interior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "eg-az-d1",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 0.5,
|
||||||
|
"width": 0.9,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "eg-foyer"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"east": {
|
||||||
|
"type": "exterior",
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"id": "eg-az-w1",
|
||||||
|
"type": "casement",
|
||||||
|
"position": 1.0,
|
||||||
|
"width": 1.4,
|
||||||
|
"height": 1.4,
|
||||||
|
"sillHeight": 0.7
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eg-esszimmer",
|
||||||
|
"name": "Esszimmer",
|
||||||
|
"nameEN": "Dining Room",
|
||||||
|
"type": "dining",
|
||||||
|
"position": { "x": 8.0, "y": 6.0 },
|
||||||
|
"dimensions": { "width": 5.0, "length": 5.0 },
|
||||||
|
"flooring": "hardwood",
|
||||||
|
"walls": {
|
||||||
|
"south": {
|
||||||
|
"type": "interior"
|
||||||
|
},
|
||||||
|
"north": {
|
||||||
|
"type": "exterior",
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"id": "eg-ez-w1",
|
||||||
|
"type": "casement",
|
||||||
|
"position": 0.5,
|
||||||
|
"width": 1.6,
|
||||||
|
"height": 1.6,
|
||||||
|
"sillHeight": 0.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eg-ez-w2",
|
||||||
|
"type": "casement",
|
||||||
|
"position": 2.8,
|
||||||
|
"width": 1.6,
|
||||||
|
"height": 1.6,
|
||||||
|
"sillHeight": 0.5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"west": {
|
||||||
|
"type": "interior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "eg-ez-d1",
|
||||||
|
"type": "open",
|
||||||
|
"position": 0.0,
|
||||||
|
"width": 1.4,
|
||||||
|
"height": 2.4,
|
||||||
|
"connectsTo": "eg-foyer"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"east": {
|
||||||
|
"type": "exterior",
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"id": "eg-ez-w3",
|
||||||
|
"type": "casement",
|
||||||
|
"position": 2.0,
|
||||||
|
"width": 1.4,
|
||||||
|
"height": 1.6,
|
||||||
|
"sillHeight": 0.5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "og",
|
||||||
|
"name": "Obergeschoss",
|
||||||
|
"nameEN": "Upper Floor",
|
||||||
|
"level": 1,
|
||||||
|
"ceilingHeight": 2.6,
|
||||||
|
"rooms": [
|
||||||
|
{
|
||||||
|
"id": "og-flur",
|
||||||
|
"name": "Flur",
|
||||||
|
"nameEN": "Upper Hallway",
|
||||||
|
"type": "hallway",
|
||||||
|
"position": { "x": 5.5, "y": 0 },
|
||||||
|
"dimensions": { "width": 2.5, "length": 11.0 },
|
||||||
|
"flooring": "hardwood",
|
||||||
|
"walls": {
|
||||||
|
"south": {
|
||||||
|
"type": "exterior"
|
||||||
|
},
|
||||||
|
"north": {
|
||||||
|
"type": "exterior"
|
||||||
|
},
|
||||||
|
"west": {
|
||||||
|
"type": "interior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "og-fl-d1",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 0.5,
|
||||||
|
"width": 0.9,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "og-kinderzimmer1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "og-fl-d2",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 3.5,
|
||||||
|
"width": 0.8,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "og-elternbad"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "og-fl-d3",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 6.5,
|
||||||
|
"width": 0.9,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "og-schlafzimmer"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"east": {
|
||||||
|
"type": "interior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "og-fl-d4",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 0.5,
|
||||||
|
"width": 0.9,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "og-kinderzimmer2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "og-fl-d5",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 3.5,
|
||||||
|
"width": 0.8,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "og-badezimmer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "og-fl-d6",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 7.5,
|
||||||
|
"width": 0.9,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "og-gaestezimmer"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "og-schlafzimmer",
|
||||||
|
"name": "Schlafzimmer",
|
||||||
|
"nameEN": "Master Suite",
|
||||||
|
"type": "bedroom",
|
||||||
|
"position": { "x": 0, "y": 6.0 },
|
||||||
|
"dimensions": { "width": 5.5, "length": 5.0 },
|
||||||
|
"flooring": "hardwood",
|
||||||
|
"walls": {
|
||||||
|
"south": {
|
||||||
|
"type": "interior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "og-sz-d1",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 0.5,
|
||||||
|
"width": 0.8,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "og-elternbad"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "og-sz-d2",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 3.0,
|
||||||
|
"width": 0.9,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "og-ankleide"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"north": {
|
||||||
|
"type": "exterior",
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"id": "og-sz-w1",
|
||||||
|
"type": "fixed",
|
||||||
|
"position": 0.5,
|
||||||
|
"width": 1.8,
|
||||||
|
"height": 1.6,
|
||||||
|
"sillHeight": 0.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "og-sz-w2",
|
||||||
|
"type": "fixed",
|
||||||
|
"position": 3.0,
|
||||||
|
"width": 1.8,
|
||||||
|
"height": 1.6,
|
||||||
|
"sillHeight": 0.5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"west": {
|
||||||
|
"type": "exterior",
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"id": "og-sz-w3",
|
||||||
|
"type": "casement",
|
||||||
|
"position": 2.0,
|
||||||
|
"width": 1.4,
|
||||||
|
"height": 1.4,
|
||||||
|
"sillHeight": 0.6
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"east": {
|
||||||
|
"type": "interior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "og-sz-d3",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 1.0,
|
||||||
|
"width": 0.9,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "og-flur"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "og-elternbad",
|
||||||
|
"name": "Elternbad",
|
||||||
|
"nameEN": "En-suite Bathroom",
|
||||||
|
"type": "bathroom",
|
||||||
|
"position": { "x": 0, "y": 3.0 },
|
||||||
|
"dimensions": { "width": 3.0, "length": 3.0 },
|
||||||
|
"flooring": "tile",
|
||||||
|
"walls": {
|
||||||
|
"south": {
|
||||||
|
"type": "interior"
|
||||||
|
},
|
||||||
|
"north": {
|
||||||
|
"type": "interior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "og-eb-d1",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 0.5,
|
||||||
|
"width": 0.8,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "og-schlafzimmer"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"west": {
|
||||||
|
"type": "exterior",
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"id": "og-eb-w1",
|
||||||
|
"type": "casement",
|
||||||
|
"position": 1.0,
|
||||||
|
"width": 0.8,
|
||||||
|
"height": 0.8,
|
||||||
|
"sillHeight": 1.3
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"east": {
|
||||||
|
"type": "interior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "og-eb-d2",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 0.5,
|
||||||
|
"width": 0.8,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "og-flur"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "og-ankleide",
|
||||||
|
"name": "Ankleide",
|
||||||
|
"nameEN": "Walk-in Closet",
|
||||||
|
"type": "storage",
|
||||||
|
"position": { "x": 3.0, "y": 3.5 },
|
||||||
|
"dimensions": { "width": 2.5, "length": 2.5 },
|
||||||
|
"flooring": "hardwood",
|
||||||
|
"walls": {
|
||||||
|
"south": {
|
||||||
|
"type": "interior"
|
||||||
|
},
|
||||||
|
"north": {
|
||||||
|
"type": "interior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "og-ak-d1",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 0.5,
|
||||||
|
"width": 0.9,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "og-schlafzimmer"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"west": {
|
||||||
|
"type": "interior"
|
||||||
|
},
|
||||||
|
"east": {
|
||||||
|
"type": "interior"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "og-kinderzimmer1",
|
||||||
|
"name": "Kinderzimmer 1",
|
||||||
|
"nameEN": "Child's Room 1",
|
||||||
|
"type": "bedroom",
|
||||||
|
"position": { "x": 0, "y": 0 },
|
||||||
|
"dimensions": { "width": 5.5, "length": 3.0 },
|
||||||
|
"flooring": "hardwood",
|
||||||
|
"walls": {
|
||||||
|
"south": {
|
||||||
|
"type": "exterior",
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"id": "og-kz1-w1",
|
||||||
|
"type": "casement",
|
||||||
|
"position": 1.5,
|
||||||
|
"width": 1.4,
|
||||||
|
"height": 1.4,
|
||||||
|
"sillHeight": 0.6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "og-kz1-w2",
|
||||||
|
"type": "casement",
|
||||||
|
"position": 3.5,
|
||||||
|
"width": 1.2,
|
||||||
|
"height": 1.4,
|
||||||
|
"sillHeight": 0.6
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"north": {
|
||||||
|
"type": "interior"
|
||||||
|
},
|
||||||
|
"west": {
|
||||||
|
"type": "exterior",
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"id": "og-kz1-w3",
|
||||||
|
"type": "casement",
|
||||||
|
"position": 1.0,
|
||||||
|
"width": 1.2,
|
||||||
|
"height": 1.4,
|
||||||
|
"sillHeight": 0.6
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"east": {
|
||||||
|
"type": "interior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "og-kz1-d1",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 0.5,
|
||||||
|
"width": 0.9,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "og-flur"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "og-kinderzimmer2",
|
||||||
|
"name": "Kinderzimmer 2",
|
||||||
|
"nameEN": "Child's Room 2",
|
||||||
|
"type": "bedroom",
|
||||||
|
"position": { "x": 8.0, "y": 0 },
|
||||||
|
"dimensions": { "width": 5.0, "length": 3.5 },
|
||||||
|
"flooring": "hardwood",
|
||||||
|
"walls": {
|
||||||
|
"south": {
|
||||||
|
"type": "exterior",
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"id": "og-kz2-w1",
|
||||||
|
"type": "casement",
|
||||||
|
"position": 2.0,
|
||||||
|
"width": 1.4,
|
||||||
|
"height": 1.4,
|
||||||
|
"sillHeight": 0.6
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"north": {
|
||||||
|
"type": "interior"
|
||||||
|
},
|
||||||
|
"west": {
|
||||||
|
"type": "interior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "og-kz2-d1",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 0.5,
|
||||||
|
"width": 0.9,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "og-flur"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"east": {
|
||||||
|
"type": "exterior",
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"id": "og-kz2-w2",
|
||||||
|
"type": "casement",
|
||||||
|
"position": 1.0,
|
||||||
|
"width": 1.2,
|
||||||
|
"height": 1.4,
|
||||||
|
"sillHeight": 0.6
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "og-badezimmer",
|
||||||
|
"name": "Badezimmer",
|
||||||
|
"nameEN": "Family Bathroom",
|
||||||
|
"type": "bathroom",
|
||||||
|
"position": { "x": 8.0, "y": 3.5 },
|
||||||
|
"dimensions": { "width": 3.5, "length": 3.0 },
|
||||||
|
"flooring": "tile",
|
||||||
|
"walls": {
|
||||||
|
"south": {
|
||||||
|
"type": "interior"
|
||||||
|
},
|
||||||
|
"north": {
|
||||||
|
"type": "interior"
|
||||||
|
},
|
||||||
|
"west": {
|
||||||
|
"type": "interior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "og-bz-d1",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 0.5,
|
||||||
|
"width": 0.8,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "og-flur"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"east": {
|
||||||
|
"type": "exterior",
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"id": "og-bz-w1",
|
||||||
|
"type": "casement",
|
||||||
|
"position": 1.0,
|
||||||
|
"width": 0.8,
|
||||||
|
"height": 0.8,
|
||||||
|
"sillHeight": 1.3
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "og-gaestezimmer",
|
||||||
|
"name": "Gästezimmer",
|
||||||
|
"nameEN": "Guest Suite",
|
||||||
|
"type": "bedroom",
|
||||||
|
"position": { "x": 8.0, "y": 7.5 },
|
||||||
|
"dimensions": { "width": 7.0, "length": 3.5 },
|
||||||
|
"flooring": "hardwood",
|
||||||
|
"walls": {
|
||||||
|
"south": {
|
||||||
|
"type": "interior"
|
||||||
|
},
|
||||||
|
"north": {
|
||||||
|
"type": "exterior",
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"id": "og-gz-w1",
|
||||||
|
"type": "casement",
|
||||||
|
"position": 1.0,
|
||||||
|
"width": 1.4,
|
||||||
|
"height": 1.4,
|
||||||
|
"sillHeight": 0.6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "og-gz-w2",
|
||||||
|
"type": "casement",
|
||||||
|
"position": 4.0,
|
||||||
|
"width": 1.4,
|
||||||
|
"height": 1.4,
|
||||||
|
"sillHeight": 0.6
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"west": {
|
||||||
|
"type": "interior",
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"id": "og-gz-d1",
|
||||||
|
"type": "interior",
|
||||||
|
"position": 0.5,
|
||||||
|
"width": 0.9,
|
||||||
|
"height": 2.1,
|
||||||
|
"connectsTo": "og-flur"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"east": {
|
||||||
|
"type": "exterior",
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"id": "og-gz-w3",
|
||||||
|
"type": "casement",
|
||||||
|
"position": 1.0,
|
||||||
|
"width": 1.2,
|
||||||
|
"height": 1.4,
|
||||||
|
"sillHeight": 0.6
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
236
designs/apartment-small-design.json
Normal file
236
designs/apartment-small-design.json
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
{
|
||||||
|
"name": "Stadtwohnung Kompakt Einrichtung",
|
||||||
|
"description": "Furnished small city apartment, efficient use of space",
|
||||||
|
"houseFile": "data/apartment-small.json",
|
||||||
|
"catalogFile": "data/furniture-catalog.json",
|
||||||
|
"coordinateSystem": {
|
||||||
|
"description": "Positions are in room-local coordinates",
|
||||||
|
"x": "Along room width: 0 = west wall, max = east wall",
|
||||||
|
"z": "Along room length: 0 = south wall, max = north wall",
|
||||||
|
"rotation": "Degrees around Y axis. 0 = front faces north, 90 = east, 180 = south, 270 = west"
|
||||||
|
},
|
||||||
|
"rooms": [
|
||||||
|
{
|
||||||
|
"roomId": "eg-flur",
|
||||||
|
"name": "Flur (Hallway)",
|
||||||
|
"dimensions": { "width": 1.5, "length": 7.0 },
|
||||||
|
"furniture": [
|
||||||
|
{
|
||||||
|
"catalogId": "shoe-cabinet",
|
||||||
|
"position": { "x": 1.325, "z": 1.5 },
|
||||||
|
"rotation": 270,
|
||||||
|
"note": "Against east wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "mirror-hall",
|
||||||
|
"position": { "x": 0.02, "y": 1.5, "z": 2.5 },
|
||||||
|
"rotation": 90,
|
||||||
|
"wallMounted": true,
|
||||||
|
"note": "On west wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "rug-small",
|
||||||
|
"position": { "x": 0.75, "z": 0.9 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Entry area rug"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"roomId": "eg-kueche",
|
||||||
|
"name": "Küche (Kitchen)",
|
||||||
|
"dimensions": { "width": 3.5, "length": 3.0 },
|
||||||
|
"furniture": [
|
||||||
|
{
|
||||||
|
"catalogId": "kitchen-counter",
|
||||||
|
"position": { "x": 1.2, "z": 0.3 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Along south wall under window"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "kitchen-wall-cabinet",
|
||||||
|
"position": { "x": 1.2, "y": 1.65, "z": 0.175 },
|
||||||
|
"rotation": 0,
|
||||||
|
"wallMounted": true,
|
||||||
|
"note": "Above counter on south wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "fridge",
|
||||||
|
"position": { "x": 0.325, "z": 2.675 },
|
||||||
|
"rotation": 180,
|
||||||
|
"note": "Against north wall, west corner"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "dining-table",
|
||||||
|
"position": { "x": 2.0, "z": 1.8 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Small dining area in kitchen"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "dining-chair",
|
||||||
|
"instanceId": "chair-1",
|
||||||
|
"position": { "x": 1.4, "z": 1.8 },
|
||||||
|
"rotation": 90,
|
||||||
|
"note": "West side of table"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "dining-chair",
|
||||||
|
"instanceId": "chair-2",
|
||||||
|
"position": { "x": 2.6, "z": 1.8 },
|
||||||
|
"rotation": 270,
|
||||||
|
"note": "East side of table"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"roomId": "eg-wohnzimmer",
|
||||||
|
"name": "Wohnzimmer (Living Room)",
|
||||||
|
"dimensions": { "width": 3.5, "length": 4.0 },
|
||||||
|
"furniture": [
|
||||||
|
{
|
||||||
|
"catalogId": "sofa-2seat",
|
||||||
|
"position": { "x": 1.75, "z": 2.5 },
|
||||||
|
"rotation": 180,
|
||||||
|
"note": "Facing south toward TV"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "tv-stand",
|
||||||
|
"position": { "x": 1.75, "z": 0.225 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Against south wall, centered"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "tv",
|
||||||
|
"position": { "x": 1.75, "z": 0.225 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "On TV stand"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "coffee-table",
|
||||||
|
"position": { "x": 1.75, "z": 1.5 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Between sofa and TV"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "bookshelf",
|
||||||
|
"position": { "x": 0.15, "z": 2.0 },
|
||||||
|
"rotation": 90,
|
||||||
|
"note": "Against west wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "floor-lamp",
|
||||||
|
"position": { "x": 3.2, "z": 2.8 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Corner next to sofa"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "rug-small",
|
||||||
|
"position": { "x": 1.75, "z": 1.7 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Under coffee table area"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "plant-small",
|
||||||
|
"position": { "x": 0.3, "z": 3.7 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Northwest corner near window"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"roomId": "eg-badezimmer",
|
||||||
|
"name": "Badezimmer (Bathroom)",
|
||||||
|
"dimensions": { "width": 2.5, "length": 2.5 },
|
||||||
|
"furniture": [
|
||||||
|
{
|
||||||
|
"catalogId": "shower",
|
||||||
|
"position": { "x": 2.05, "z": 0.45 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Southeast corner"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "toilet",
|
||||||
|
"position": { "x": 2.175, "z": 2.15 },
|
||||||
|
"rotation": 270,
|
||||||
|
"note": "Against east wall, north"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "sink-bathroom",
|
||||||
|
"position": { "x": 0.8, "z": 2.275 },
|
||||||
|
"rotation": 180,
|
||||||
|
"note": "Against north wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "bathroom-cabinet",
|
||||||
|
"position": { "x": 0.8, "y": 1.5, "z": 2.4 },
|
||||||
|
"rotation": 180,
|
||||||
|
"wallMounted": true,
|
||||||
|
"note": "Above sink on north wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "washing-machine",
|
||||||
|
"position": { "x": 0.3, "z": 0.3 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Southwest corner"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"roomId": "eg-schlafzimmer",
|
||||||
|
"name": "Schlafzimmer (Bedroom)",
|
||||||
|
"dimensions": { "width": 4.0, "length": 4.5 },
|
||||||
|
"furniture": [
|
||||||
|
{
|
||||||
|
"catalogId": "double-bed",
|
||||||
|
"position": { "x": 2.0, "z": 1.15 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Centered, headboard against south wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "nightstand",
|
||||||
|
"instanceId": "nightstand-left",
|
||||||
|
"position": { "x": 0.875, "z": 0.4 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Left side of bed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "nightstand",
|
||||||
|
"instanceId": "nightstand-right",
|
||||||
|
"position": { "x": 3.125, "z": 0.4 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Right side of bed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "wardrobe",
|
||||||
|
"position": { "x": 0.3, "z": 3.0 },
|
||||||
|
"rotation": 90,
|
||||||
|
"note": "Against west wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "desk",
|
||||||
|
"position": { "x": 3.0, "z": 4.15 },
|
||||||
|
"rotation": 180,
|
||||||
|
"note": "Against north wall, near window"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "desk-lamp",
|
||||||
|
"position": { "x": 3.5, "z": 4.15 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "On desk"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "rug-large",
|
||||||
|
"position": { "x": 2.0, "z": 2.0 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Beside bed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "floor-lamp",
|
||||||
|
"position": { "x": 0.5, "z": 0.3 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Left of bed"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
504
designs/floorplan-import-design.md
Normal file
504
designs/floorplan-import-design.md
Normal file
@@ -0,0 +1,504 @@
|
|||||||
|
# Floor Plan Image Recognition — Feature Design
|
||||||
|
|
||||||
|
**Task:** t-c2921
|
||||||
|
**Author:** inventor
|
||||||
|
**Status:** Design proposal
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Users want to import an existing floor plan image (architect drawing, realtor photo, hand sketch) and have it automatically converted into the project's house JSON format so they can immediately view it in 3D, furnish rooms, and iterate on the design.
|
||||||
|
|
||||||
|
## Approach: LLM Vision API
|
||||||
|
|
||||||
|
After evaluating four approaches, the recommended solution uses **multimodal LLM vision** (Claude or OpenAI) to analyze floor plan images and output structured house JSON.
|
||||||
|
|
||||||
|
### Why LLM Vision over alternatives
|
||||||
|
|
||||||
|
| Approach | Pros | Cons | Verdict |
|
||||||
|
|----------|------|------|---------|
|
||||||
|
| **Classical CV** (OpenCV.js edge detection) | No API needed, offline | Can't identify room types, fails on varied styles, needs heavy heuristics | Too fragile |
|
||||||
|
| **LLM Vision** (Claude/GPT-4V) | Understands semantics, handles variety, outputs JSON directly | Needs API key + network | **Best fit** |
|
||||||
|
| **Dedicated ML** (YOLO/CubiCasa models) | High accuracy for specific styles | Heavy model files (~100MB+), complex setup, breaks vanilla JS philosophy | Too heavy |
|
||||||
|
| **Hybrid CV + LLM** | Best of both worlds | More complexity for marginal gain | Overengineered for v1 |
|
||||||
|
|
||||||
|
**Key reasons:**
|
||||||
|
1. Project is vanilla JS with no build system — adding ML runtimes is architecturally wrong
|
||||||
|
2. Floor plans are inherently semantic — you need to know "this is a kitchen" not just "this is a rectangle"
|
||||||
|
3. LLMs can output the exact house JSON format in a single call
|
||||||
|
4. LLMs handle architectural drawings, realtor floor plans, and hand sketches equally well
|
||||||
|
5. Standard door widths (~0.9m) give LLMs reliable dimensional anchors
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### New module: `src/floorplan-import.js`
|
||||||
|
|
||||||
|
```
|
||||||
|
FloorplanImporter
|
||||||
|
├── constructor(renderer, options)
|
||||||
|
├── open() // Shows the import modal
|
||||||
|
├── _buildModal() // Creates DOM for the modal overlay
|
||||||
|
├── _handleImageUpload(file) // Processes uploaded image
|
||||||
|
├── _preprocessImage(imageData) // Canvas preprocessing (contrast, resize)
|
||||||
|
├── _analyzeWithLLM(base64Image) // Sends to vision API, gets house JSON
|
||||||
|
├── _buildPrompt() // Constructs the system+user prompt
|
||||||
|
├── _validateHouseJSON(json) // Validates output matches schema
|
||||||
|
├── _applyToRenderer(houseData) // Loads result into the 3D viewer
|
||||||
|
├── _showPreview(houseData) // Shows result for user review
|
||||||
|
└── close() // Closes modal, cleans up
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration point: `src/index.html`
|
||||||
|
|
||||||
|
New button in the sidebar File section:
|
||||||
|
```html
|
||||||
|
<button class="export-btn" id="btn-import-floorplan">Import Floor Plan</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
Wired in the `wireExportButtons()` function.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## User Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
1. User clicks "Import Floor Plan" in sidebar
|
||||||
|
│
|
||||||
|
2. Modal overlay appears with:
|
||||||
|
┌──────────────────────────────────┐
|
||||||
|
│ Import Floor Plan │
|
||||||
|
│ │
|
||||||
|
│ ┌────────────────────────────┐ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ Drop image here or │ │
|
||||||
|
│ │ click to browse │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ PNG, JPG, WebP │ │
|
||||||
|
│ └────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ Building name: [___________] │
|
||||||
|
│ Floors shown: [1 ▼] │
|
||||||
|
│ │
|
||||||
|
│ API: [Claude ▼] Key: [••••••] │
|
||||||
|
│ │
|
||||||
|
│ [Analyze Floor Plan] │
|
||||||
|
└──────────────────────────────────┘
|
||||||
|
│
|
||||||
|
3. Image uploaded → shown in preview area
|
||||||
|
│
|
||||||
|
4. User clicks "Analyze" → spinner + progress text
|
||||||
|
│
|
||||||
|
5. LLM returns house JSON
|
||||||
|
│
|
||||||
|
6. Preview mode shows:
|
||||||
|
┌──────────────────────────────────┐
|
||||||
|
│ Result Preview │
|
||||||
|
│ │
|
||||||
|
│ Found: 6 rooms, 8 doors, │
|
||||||
|
│ 12 windows │
|
||||||
|
│ │
|
||||||
|
│ Rooms: │
|
||||||
|
│ ☑ Living Room 4.5 × 5.5m │
|
||||||
|
│ ☑ Kitchen 4.0 × 3.5m │
|
||||||
|
│ ☑ Hallway 2.0 × 9.0m │
|
||||||
|
│ ☑ Bathroom 2.5 × 3.0m │
|
||||||
|
│ ☑ Bedroom 4.5 × 4.0m │
|
||||||
|
│ ☑ Office 3.5 × 3.0m │
|
||||||
|
│ │
|
||||||
|
│ [Accept & Load] [Edit JSON] │
|
||||||
|
│ [Re-analyze] [Cancel] │
|
||||||
|
└──────────────────────────────────┘
|
||||||
|
│
|
||||||
|
7a. "Accept" → loads house JSON into renderer,
|
||||||
|
rebuilds floor buttons, room list, 3D view
|
||||||
|
│
|
||||||
|
7b. "Edit JSON" → opens raw JSON in textarea
|
||||||
|
for manual corrections before loading
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Prompt (Core of the Feature)
|
||||||
|
|
||||||
|
The prompt engineering is the most critical part. It must produce valid house JSON from any floor plan style.
|
||||||
|
|
||||||
|
### System prompt
|
||||||
|
|
||||||
|
```
|
||||||
|
You are a floor plan analyzer. Given an image of a floor plan or floor layout,
|
||||||
|
extract the room structure and output valid JSON matching the exact schema below.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- All dimensions in meters. Use standard architectural conventions if no scale bar
|
||||||
|
is visible (standard interior door = 0.9m wide, entry door = 1.0-1.1m)
|
||||||
|
- Rooms are axis-aligned rectangles positioned on a coordinate grid
|
||||||
|
- Position {x, y} is the room's bottom-left corner (x = west-east, y = south-north)
|
||||||
|
- Each room has walls on 4 cardinal directions (north, south, east, west)
|
||||||
|
- Walls are "exterior" if they face outside the building, "interior" otherwise
|
||||||
|
- Doors have: id, type (entry|interior|patio|open), position (meters from wall start),
|
||||||
|
width, height, connectsTo (adjacent room id or "exterior")
|
||||||
|
- Windows have: id, type (casement|fixed), position, width, height, sillHeight
|
||||||
|
- Generate unique IDs: "{floorId}-{roomSlug}" for rooms, "{roomId}-d{n}" for doors,
|
||||||
|
"{roomId}-w{n}" for windows
|
||||||
|
- Room types: living, kitchen, dining, bedroom, bathroom, hallway, office, utility,
|
||||||
|
storage, laundry, garage
|
||||||
|
- Flooring: "tile" for kitchen/bathroom/utility/hallway, "hardwood" for others
|
||||||
|
|
||||||
|
Output ONLY valid JSON, no markdown fences, no explanation.
|
||||||
|
```
|
||||||
|
|
||||||
|
### User prompt template
|
||||||
|
|
||||||
|
```
|
||||||
|
Analyze this floor plan image. The building is named "{name}".
|
||||||
|
{scaleHint ? "Scale reference: " + scaleHint : "Estimate dimensions from standard door widths."}
|
||||||
|
This image shows {floorCount} floor(s).
|
||||||
|
|
||||||
|
Output the house JSON with this structure:
|
||||||
|
{
|
||||||
|
"name": "...",
|
||||||
|
"description": "...",
|
||||||
|
"units": "meters",
|
||||||
|
"building": {
|
||||||
|
"footprint": { "width": <number>, "depth": <number> },
|
||||||
|
"wallThickness": 0.24,
|
||||||
|
"roofType": "gable"
|
||||||
|
},
|
||||||
|
"floors": [
|
||||||
|
{
|
||||||
|
"id": "eg",
|
||||||
|
"name": "...",
|
||||||
|
"nameEN": "...",
|
||||||
|
"level": 0,
|
||||||
|
"ceilingHeight": 2.6,
|
||||||
|
"rooms": [
|
||||||
|
{
|
||||||
|
"id": "eg-room-slug",
|
||||||
|
"name": "...",
|
||||||
|
"nameEN": "...",
|
||||||
|
"type": "living|kitchen|...",
|
||||||
|
"position": { "x": <meters>, "y": <meters> },
|
||||||
|
"dimensions": { "width": <meters>, "length": <meters> },
|
||||||
|
"flooring": "tile|hardwood",
|
||||||
|
"walls": {
|
||||||
|
"south": { "type": "exterior|interior", "doors": [...], "windows": [...] },
|
||||||
|
"north": { ... },
|
||||||
|
"east": { ... },
|
||||||
|
"west": { ... }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Integration
|
||||||
|
|
||||||
|
### Multi-provider support
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const API_PROVIDERS = {
|
||||||
|
claude: {
|
||||||
|
name: 'Claude (Anthropic)',
|
||||||
|
endpoint: 'https://api.anthropic.com/v1/messages',
|
||||||
|
model: 'claude-sonnet-4-5-20250929',
|
||||||
|
buildRequest(base64Image, mediaType, systemPrompt, userPrompt) {
|
||||||
|
return {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-api-key': apiKey,
|
||||||
|
'anthropic-version': '2023-06-01',
|
||||||
|
'anthropic-dangerous-direct-browser-access': 'true'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: this.model,
|
||||||
|
max_tokens: 8192,
|
||||||
|
system: systemPrompt,
|
||||||
|
messages: [{
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{ type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Image } },
|
||||||
|
{ type: 'text', text: userPrompt }
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
};
|
||||||
|
},
|
||||||
|
extractJSON(response) {
|
||||||
|
return response.content[0].text;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
openai: {
|
||||||
|
name: 'OpenAI (GPT-4o)',
|
||||||
|
endpoint: 'https://api.openai.com/v1/chat/completions',
|
||||||
|
model: 'gpt-4o',
|
||||||
|
buildRequest(base64Image, mediaType, systemPrompt, userPrompt) {
|
||||||
|
return {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${apiKey}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: this.model,
|
||||||
|
max_tokens: 8192,
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: systemPrompt },
|
||||||
|
{ role: 'user', content: [
|
||||||
|
{ type: 'image_url', image_url: { url: `data:${mediaType};base64,${base64Image}` } },
|
||||||
|
{ type: 'text', text: userPrompt }
|
||||||
|
]}
|
||||||
|
],
|
||||||
|
response_format: { type: 'json_object' }
|
||||||
|
})
|
||||||
|
};
|
||||||
|
},
|
||||||
|
extractJSON(response) {
|
||||||
|
return response.choices[0].message.content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### API key management
|
||||||
|
|
||||||
|
- Stored in `localStorage` under `floorplan-api-key-{provider}`
|
||||||
|
- Entered once per session via the import modal
|
||||||
|
- Never sent to any server except the chosen API provider
|
||||||
|
- Key input field uses `type="password"` and shows masked value
|
||||||
|
- "Clear key" button to remove from localStorage
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Image Preprocessing
|
||||||
|
|
||||||
|
Before sending to the LLM, apply lightweight canvas preprocessing:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
_preprocessImage(file) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
|
||||||
|
// Resize if larger than 2048px on any side (API limits + cost reduction)
|
||||||
|
const maxDim = 2048;
|
||||||
|
let { width, height } = img;
|
||||||
|
if (width > maxDim || height > maxDim) {
|
||||||
|
const scale = maxDim / Math.max(width, height);
|
||||||
|
width = Math.round(width * scale);
|
||||||
|
height = Math.round(height * scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
// Draw and optionally enhance contrast for faded plans
|
||||||
|
ctx.drawImage(img, 0, 0, width, height);
|
||||||
|
|
||||||
|
// Convert to base64 (JPEG for photos, PNG for drawings)
|
||||||
|
const mediaType = file.type === 'image/png' ? 'image/png' : 'image/jpeg';
|
||||||
|
const quality = mediaType === 'image/jpeg' ? 0.9 : undefined;
|
||||||
|
const base64 = canvas.toDataURL(mediaType, quality).split(',')[1];
|
||||||
|
|
||||||
|
resolve({ base64, mediaType, width, height });
|
||||||
|
};
|
||||||
|
img.src = URL.createObjectURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
After receiving LLM output, validate before loading:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
_validateHouseJSON(data) {
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
if (!data.name) errors.push('Missing building name');
|
||||||
|
if (!data.building?.footprint) errors.push('Missing building footprint');
|
||||||
|
if (!data.floors?.length) errors.push('No floors found');
|
||||||
|
|
||||||
|
for (const floor of (data.floors || [])) {
|
||||||
|
if (!floor.rooms?.length) {
|
||||||
|
errors.push(`Floor "${floor.name}" has no rooms`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const room of floor.rooms) {
|
||||||
|
if (!room.id) errors.push(`Room missing id`);
|
||||||
|
if (!room.position) errors.push(`Room "${room.id}" missing position`);
|
||||||
|
if (!room.dimensions) errors.push(`Room "${room.id}" missing dimensions`);
|
||||||
|
if (!room.walls) errors.push(`Room "${room.id}" missing walls`);
|
||||||
|
|
||||||
|
// Validate wall references
|
||||||
|
for (const dir of ['north', 'south', 'east', 'west']) {
|
||||||
|
const wall = room.walls?.[dir];
|
||||||
|
if (!wall) errors.push(`Room "${room.id}" missing ${dir} wall`);
|
||||||
|
if (wall && !['exterior', 'interior'].includes(wall.type)) {
|
||||||
|
errors.push(`Room "${room.id}" ${dir} wall has invalid type "${wall.type}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: errors.length === 0, errors };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Auto-repair
|
||||||
|
|
||||||
|
Common LLM output issues and fixes:
|
||||||
|
- Missing wall entries → default to `{ "type": "interior" }`
|
||||||
|
- String numbers → parse to float
|
||||||
|
- Missing IDs → auto-generate from room name
|
||||||
|
- Missing flooring → infer from room type
|
||||||
|
- Rooms without walls object → generate empty walls
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scale Detection Strategy
|
||||||
|
|
||||||
|
Dimensions are the hardest part. The LLM handles this through:
|
||||||
|
|
||||||
|
1. **Standard references** — Interior doors are ~0.9m, entry doors ~1.0-1.1m, windows ~1.2m. The LLM uses these as implicit scale anchors.
|
||||||
|
|
||||||
|
2. **User-provided scale** — Optional input: "The living room is approximately 5m wide" or "Scale: 1cm = 0.5m". Passed as a hint in the prompt.
|
||||||
|
|
||||||
|
3. **Scale bar detection** — If the floor plan has a scale bar, the LLM reads it directly.
|
||||||
|
|
||||||
|
4. **Post-import adjustment** — After loading, user can use the existing House Editor to manually adjust any room dimensions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Loading into Renderer
|
||||||
|
|
||||||
|
After validation, the house JSON replaces the current house:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
_applyToRenderer(houseData) {
|
||||||
|
// Replace house data in renderer
|
||||||
|
this.renderer.houseData = houseData;
|
||||||
|
this.renderer.currentFloor = 0;
|
||||||
|
|
||||||
|
// Clear and re-render
|
||||||
|
this.renderer._clearFloor();
|
||||||
|
const floor = houseData.floors[0];
|
||||||
|
for (const room of floor.rooms) {
|
||||||
|
this.renderer._renderRoom(room, floor.ceilingHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch event for UI to rebuild floor buttons, room list, etc.
|
||||||
|
this.renderer.container.dispatchEvent(new CustomEvent('houseloaded', {
|
||||||
|
detail: { name: houseData.name, floors: houseData.floors.length }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `index.html` would listen for `houseloaded` and rebuild:
|
||||||
|
- Floor buttons
|
||||||
|
- Room list
|
||||||
|
- House editor state
|
||||||
|
- Reset camera position
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
floorplan-import.js # New module — FloorplanImporter class
|
||||||
|
index.html # Modified — add button + wire up + houseloaded event
|
||||||
|
```
|
||||||
|
|
||||||
|
No new dependencies. No build changes. Pure vanilla JS using:
|
||||||
|
- `fetch()` for API calls
|
||||||
|
- `Canvas API` for image preprocessing
|
||||||
|
- `FileReader` / `Blob` for image handling
|
||||||
|
- `localStorage` for API key persistence
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CSS (inline in modal, consistent with project style)
|
||||||
|
|
||||||
|
The modal uses the same design language as existing UI:
|
||||||
|
- `rgba(255, 255, 255, 0.95)` backgrounds
|
||||||
|
- `#4a90d9` accent color
|
||||||
|
- `-apple-system, BlinkMacSystemFont` font stack
|
||||||
|
- `border-radius: 4-6px` on elements
|
||||||
|
- Same button styles as `.export-btn`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Edge Cases
|
||||||
|
|
||||||
|
| Case | Handling |
|
||||||
|
|------|----------|
|
||||||
|
| Multi-floor image (side by side) | Prompt asks LLM to detect multiple floors |
|
||||||
|
| Hand-drawn sketch | LLM handles well; dimensions will be approximate |
|
||||||
|
| Photo of printed plan | Canvas preprocessing helps; LLM reads spatial layout |
|
||||||
|
| Non-English labels | LLM translates; output uses both original + English names |
|
||||||
|
| Very large image (>10MB) | Canvas resizes to max 2048px before base64 encoding |
|
||||||
|
| LLM returns invalid JSON | Parse error → show raw text → let user "Edit JSON" |
|
||||||
|
| LLM returns partial data | Validation finds gaps → auto-repair what's possible, flag rest |
|
||||||
|
| API rate limit | Show error, suggest retry after delay |
|
||||||
|
| No API key | Modal won't allow "Analyze" without key entered |
|
||||||
|
| Curved walls / non-rectangular rooms | Approximate as rectangles (project constraint) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cost Estimate
|
||||||
|
|
||||||
|
Per floor plan analysis:
|
||||||
|
- **Claude Sonnet**: ~$0.01-0.03 per image (vision + ~2K output tokens)
|
||||||
|
- **GPT-4o**: ~$0.01-0.05 per image
|
||||||
|
- Negligible for individual use
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Recommendations
|
||||||
|
|
||||||
|
### For the coder:
|
||||||
|
|
||||||
|
1. **Start with the prompt** — get `_buildPrompt()` right first, test with various floor plan images manually via the API before building the UI.
|
||||||
|
|
||||||
|
2. **Build the modal** — follow the existing modal-free overlay pattern (the project uses no modal library; use a simple overlay div).
|
||||||
|
|
||||||
|
3. **Wire up the API** — start with Claude support, add OpenAI second. The provider abstraction makes this easy.
|
||||||
|
|
||||||
|
4. **Add validation + auto-repair** — defensive parsing of LLM output is essential.
|
||||||
|
|
||||||
|
5. **Handle the `houseloaded` event** in index.html — rebuild all sidebar UI.
|
||||||
|
|
||||||
|
6. **Test with varied floor plans:**
|
||||||
|
- Clean architectural drawing (should work great)
|
||||||
|
- Realtor-style colored floor plan (should work well)
|
||||||
|
- Hand sketch on paper (should work, approximate dimensions)
|
||||||
|
- Photo of a floor plan on screen (should work with preprocessing)
|
||||||
|
|
||||||
|
### Testing approach:
|
||||||
|
- Save example floor plan images in `data/test-floorplans/`
|
||||||
|
- Compare LLM output against manually created house JSON
|
||||||
|
- Check that output loads in 3D viewer without errors
|
||||||
|
- Verify rooms don't overlap and walls connect properly
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Future Enhancements (out of scope for v1)
|
||||||
|
|
||||||
|
- **Local model support** — Run a local vision model (via Ollama) for offline use
|
||||||
|
- **PDF import** — Extract floor plan pages from architectural PDFs
|
||||||
|
- **Multi-floor stitching** — Upload separate images per floor, align them
|
||||||
|
- **Overlay comparison** — Show original image as ground texture under 3D rooms
|
||||||
|
- **Iterative refinement** — "The kitchen should be wider" → re-prompt with corrections
|
||||||
|
- **Scale calibration tool** — Click two points on image, enter real distance
|
||||||
317
designs/loft-modern-design.json
Normal file
317
designs/loft-modern-design.json
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
{
|
||||||
|
"name": "Modernes Loft Einrichtung",
|
||||||
|
"description": "Contemporary furnished loft with open-plan living and industrial-modern style",
|
||||||
|
"houseFile": "data/loft-modern.json",
|
||||||
|
"catalogFile": "data/furniture-catalog.json",
|
||||||
|
"coordinateSystem": {
|
||||||
|
"description": "Positions are in room-local coordinates",
|
||||||
|
"x": "Along room width: 0 = west wall, max = east wall",
|
||||||
|
"z": "Along room length: 0 = south wall, max = north wall",
|
||||||
|
"rotation": "Degrees around Y axis. 0 = front faces north, 90 = east, 180 = south, 270 = west"
|
||||||
|
},
|
||||||
|
"rooms": [
|
||||||
|
{
|
||||||
|
"roomId": "eg-eingang",
|
||||||
|
"name": "Eingang (Entry)",
|
||||||
|
"dimensions": { "width": 2.0, "length": 2.5 },
|
||||||
|
"furniture": [
|
||||||
|
{
|
||||||
|
"catalogId": "shoe-cabinet",
|
||||||
|
"position": { "x": 0.175, "z": 1.5 },
|
||||||
|
"rotation": 90,
|
||||||
|
"note": "Against west wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "coat-rack",
|
||||||
|
"position": { "x": 1.85, "z": 1.5 },
|
||||||
|
"rotation": 270,
|
||||||
|
"note": "Against east wall"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"roomId": "eg-buero",
|
||||||
|
"name": "Home Office",
|
||||||
|
"dimensions": { "width": 3.0, "length": 2.5 },
|
||||||
|
"furniture": [
|
||||||
|
{
|
||||||
|
"catalogId": "desk",
|
||||||
|
"position": { "x": 1.5, "z": 0.35 },
|
||||||
|
"rotation": 180,
|
||||||
|
"note": "Facing south window"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "office-chair",
|
||||||
|
"position": { "x": 1.5, "z": 1.1 },
|
||||||
|
"rotation": 180,
|
||||||
|
"note": "At desk"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "bookshelf",
|
||||||
|
"position": { "x": 2.85, "z": 1.5 },
|
||||||
|
"rotation": 270,
|
||||||
|
"note": "Against east wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "desk-lamp",
|
||||||
|
"position": { "x": 2.0, "z": 0.35 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "On desk"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "plant-small",
|
||||||
|
"position": { "x": 0.3, "z": 0.3 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Southwest corner near window"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"roomId": "eg-badezimmer",
|
||||||
|
"name": "Badezimmer (Bathroom)",
|
||||||
|
"dimensions": { "width": 2.5, "length": 2.5 },
|
||||||
|
"furniture": [
|
||||||
|
{
|
||||||
|
"catalogId": "shower",
|
||||||
|
"position": { "x": 0.45, "z": 0.45 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Southwest corner"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "sink-bathroom",
|
||||||
|
"position": { "x": 2.275, "z": 1.2 },
|
||||||
|
"rotation": 270,
|
||||||
|
"note": "Against east wall, central"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "bathroom-cabinet",
|
||||||
|
"position": { "x": 2.4, "y": 1.5, "z": 1.2 },
|
||||||
|
"rotation": 270,
|
||||||
|
"wallMounted": true,
|
||||||
|
"note": "Above sink on east wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "toilet",
|
||||||
|
"position": { "x": 1.5, "z": 2.175 },
|
||||||
|
"rotation": 180,
|
||||||
|
"note": "Against north wall"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"roomId": "eg-ankleide",
|
||||||
|
"name": "Ankleide (Walk-in Closet)",
|
||||||
|
"dimensions": { "width": 2.5, "length": 2.5 },
|
||||||
|
"furniture": [
|
||||||
|
{
|
||||||
|
"catalogId": "wardrobe",
|
||||||
|
"position": { "x": 0.3, "z": 1.25 },
|
||||||
|
"rotation": 90,
|
||||||
|
"note": "Against west wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "kids-shelf",
|
||||||
|
"position": { "x": 2.35, "z": 1.5 },
|
||||||
|
"rotation": 270,
|
||||||
|
"note": "Against east wall, for accessories"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "mirror-hall",
|
||||||
|
"position": { "x": 1.25, "y": 1.3, "z": 0.02 },
|
||||||
|
"rotation": 0,
|
||||||
|
"wallMounted": true,
|
||||||
|
"note": "Full-length mirror on south wall"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"roomId": "eg-wohnbereich",
|
||||||
|
"name": "Wohn-/Essbereich mit Küche (Open Living/Dining/Kitchen)",
|
||||||
|
"dimensions": { "width": 5.0, "length": 5.5 },
|
||||||
|
"furniture": [
|
||||||
|
{
|
||||||
|
"catalogId": "sofa-3seat",
|
||||||
|
"position": { "x": 2.0, "z": 3.8 },
|
||||||
|
"rotation": 180,
|
||||||
|
"note": "Facing south toward center"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "coffee-table",
|
||||||
|
"position": { "x": 2.0, "z": 2.8 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "In front of sofa"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "armchair",
|
||||||
|
"position": { "x": 0.5, "z": 2.5 },
|
||||||
|
"rotation": 90,
|
||||||
|
"note": "Side chair facing east"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "tv-stand",
|
||||||
|
"position": { "x": 4.775, "z": 3.0 },
|
||||||
|
"rotation": 270,
|
||||||
|
"note": "Against east wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "tv",
|
||||||
|
"position": { "x": 4.775, "z": 3.0 },
|
||||||
|
"rotation": 270,
|
||||||
|
"note": "On TV stand, against east wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "rug-large",
|
||||||
|
"position": { "x": 2.0, "z": 3.0 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Under living area"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "floor-lamp",
|
||||||
|
"position": { "x": 0.3, "z": 4.2 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Behind armchair area"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "kitchen-counter",
|
||||||
|
"position": { "x": 1.2, "z": 0.3 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Kitchen counter along south wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "kitchen-wall-cabinet",
|
||||||
|
"position": { "x": 1.2, "y": 1.65, "z": 0.175 },
|
||||||
|
"rotation": 0,
|
||||||
|
"wallMounted": true,
|
||||||
|
"note": "Above kitchen counter"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "fridge",
|
||||||
|
"position": { "x": 0.325, "z": 0.325 },
|
||||||
|
"rotation": 90,
|
||||||
|
"note": "Against west wall, kitchen area"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "kitchen-island",
|
||||||
|
"position": { "x": 2.5, "z": 1.5 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Kitchen island / breakfast bar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "dining-table",
|
||||||
|
"position": { "x": 4.0, "z": 1.2 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Dining area, east side"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "dining-chair",
|
||||||
|
"instanceId": "dc-1",
|
||||||
|
"position": { "x": 3.4, "z": 1.2 },
|
||||||
|
"rotation": 90,
|
||||||
|
"note": "West of table"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "dining-chair",
|
||||||
|
"instanceId": "dc-2",
|
||||||
|
"position": { "x": 4.0, "z": 1.75 },
|
||||||
|
"rotation": 180,
|
||||||
|
"note": "North of table"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "dining-chair",
|
||||||
|
"instanceId": "dc-3",
|
||||||
|
"position": { "x": 4.0, "z": 0.65 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "South of table"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "plant-large",
|
||||||
|
"position": { "x": 0.3, "z": 5.2 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Northwest corner near window"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "plant-large",
|
||||||
|
"instanceId": "plant-2",
|
||||||
|
"position": { "x": 4.7, "z": 5.2 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Northeast corner"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "sideboard",
|
||||||
|
"position": { "x": 4.8, "z": 4.5 },
|
||||||
|
"rotation": 270,
|
||||||
|
"note": "Against east wall, between living and dining"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"roomId": "eg-schlafzimmer",
|
||||||
|
"name": "Schlafzimmer (Master Bedroom)",
|
||||||
|
"dimensions": { "width": 5.0, "length": 5.5 },
|
||||||
|
"furniture": [
|
||||||
|
{
|
||||||
|
"catalogId": "double-bed",
|
||||||
|
"position": { "x": 2.5, "z": 1.15 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Centered, headboard against south wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "nightstand",
|
||||||
|
"instanceId": "ns-left",
|
||||||
|
"position": { "x": 1.375, "z": 0.4 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Left side of bed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "nightstand",
|
||||||
|
"instanceId": "ns-right",
|
||||||
|
"position": { "x": 3.625, "z": 0.4 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Right side of bed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "desk",
|
||||||
|
"position": { "x": 1.5, "z": 5.15 },
|
||||||
|
"rotation": 180,
|
||||||
|
"note": "Against north wall, near window"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "desk-lamp",
|
||||||
|
"position": { "x": 2.0, "z": 5.15 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "On desk"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "armchair",
|
||||||
|
"position": { "x": 4.2, "z": 4.0 },
|
||||||
|
"rotation": 270,
|
||||||
|
"note": "Reading corner, facing west"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "floor-lamp",
|
||||||
|
"position": { "x": 4.7, "z": 4.5 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Next to reading chair"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "rug-large",
|
||||||
|
"position": { "x": 2.5, "z": 2.5 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Beside bed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "plant-large",
|
||||||
|
"position": { "x": 0.3, "z": 5.2 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Northwest corner"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "bookshelf",
|
||||||
|
"position": { "x": 0.15, "z": 3.0 },
|
||||||
|
"rotation": 90,
|
||||||
|
"note": "Against west wall"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
761
designs/villa-large-design.json
Normal file
761
designs/villa-large-design.json
Normal file
@@ -0,0 +1,761 @@
|
|||||||
|
{
|
||||||
|
"name": "Villa Sonnenhügel Einrichtung",
|
||||||
|
"description": "Luxurious furnished villa with premium furnishings throughout",
|
||||||
|
"houseFile": "data/villa-large.json",
|
||||||
|
"catalogFile": "data/furniture-catalog.json",
|
||||||
|
"coordinateSystem": {
|
||||||
|
"description": "Positions are in room-local coordinates",
|
||||||
|
"x": "Along room width: 0 = west wall, max = east wall",
|
||||||
|
"z": "Along room length: 0 = south wall, max = north wall",
|
||||||
|
"rotation": "Degrees around Y axis. 0 = front faces north, 90 = east, 180 = south, 270 = west"
|
||||||
|
},
|
||||||
|
"rooms": [
|
||||||
|
{
|
||||||
|
"roomId": "eg-foyer",
|
||||||
|
"name": "Foyer (Grand Foyer)",
|
||||||
|
"dimensions": { "width": 2.5, "length": 11.0 },
|
||||||
|
"furniture": [
|
||||||
|
{
|
||||||
|
"catalogId": "console-table",
|
||||||
|
"position": { "x": 0.15, "z": 2.0 },
|
||||||
|
"rotation": 90,
|
||||||
|
"note": "Against west wall, near entrance"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "mirror-hall",
|
||||||
|
"position": { "x": 0.02, "y": 1.5, "z": 2.0 },
|
||||||
|
"rotation": 90,
|
||||||
|
"wallMounted": true,
|
||||||
|
"note": "Above console table on west wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "shoe-cabinet",
|
||||||
|
"position": { "x": 2.325, "z": 1.5 },
|
||||||
|
"rotation": 270,
|
||||||
|
"note": "Against east wall near entry"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "coat-rack",
|
||||||
|
"position": { "x": 2.35, "z": 8.5 },
|
||||||
|
"rotation": 270,
|
||||||
|
"note": "Against east wall, north section"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "plant-large",
|
||||||
|
"position": { "x": 0.3, "z": 9.0 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Northwest corner near garden door"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "rug-large",
|
||||||
|
"position": { "x": 1.25, "z": 1.5 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Entry area rug"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"roomId": "eg-kueche",
|
||||||
|
"name": "Küche (Kitchen)",
|
||||||
|
"dimensions": { "width": 5.5, "length": 5.0 },
|
||||||
|
"furniture": [
|
||||||
|
{
|
||||||
|
"catalogId": "kitchen-counter",
|
||||||
|
"position": { "x": 1.5, "z": 0.3 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Along south wall under windows"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "kitchen-wall-cabinet",
|
||||||
|
"position": { "x": 1.5, "y": 1.65, "z": 0.175 },
|
||||||
|
"rotation": 0,
|
||||||
|
"wallMounted": true,
|
||||||
|
"note": "Above counter on south wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "kitchen-counter",
|
||||||
|
"instanceId": "counter-west",
|
||||||
|
"position": { "x": 0.3, "z": 2.5 },
|
||||||
|
"rotation": 90,
|
||||||
|
"note": "Along west wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "fridge",
|
||||||
|
"position": { "x": 0.325, "z": 4.675 },
|
||||||
|
"rotation": 180,
|
||||||
|
"note": "Against north wall, west corner"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "kitchen-island",
|
||||||
|
"position": { "x": 3.0, "z": 2.5 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Large central island"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "dining-chair",
|
||||||
|
"instanceId": "bar-1",
|
||||||
|
"position": { "x": 2.4, "z": 2.5 },
|
||||||
|
"rotation": 90,
|
||||||
|
"note": "Bar stool at island"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "dining-chair",
|
||||||
|
"instanceId": "bar-2",
|
||||||
|
"position": { "x": 3.6, "z": 2.5 },
|
||||||
|
"rotation": 270,
|
||||||
|
"note": "Bar stool at island"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "plant-small",
|
||||||
|
"position": { "x": 5.2, "z": 4.7 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Northeast corner"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"roomId": "eg-wohnzimmer",
|
||||||
|
"name": "Wohnzimmer (Living Room)",
|
||||||
|
"dimensions": { "width": 5.5, "length": 6.0 },
|
||||||
|
"furniture": [
|
||||||
|
{
|
||||||
|
"catalogId": "sofa-3seat",
|
||||||
|
"position": { "x": 2.75, "z": 3.5 },
|
||||||
|
"rotation": 180,
|
||||||
|
"note": "Centered, facing south"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "sofa-2seat",
|
||||||
|
"position": { "x": 0.5, "z": 2.0 },
|
||||||
|
"rotation": 90,
|
||||||
|
"note": "Perpendicular to main sofa, facing east"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "armchair",
|
||||||
|
"position": { "x": 5.0, "z": 2.0 },
|
||||||
|
"rotation": 270,
|
||||||
|
"note": "Facing west, opposite loveseat"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "coffee-table",
|
||||||
|
"position": { "x": 2.75, "z": 2.2 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Center of seating area"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "tv-stand",
|
||||||
|
"position": { "x": 2.75, "z": 0.225 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Against south wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "tv",
|
||||||
|
"position": { "x": 2.75, "z": 0.225 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "On TV stand"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "bookshelf",
|
||||||
|
"instanceId": "shelf-1",
|
||||||
|
"position": { "x": 5.35, "z": 3.5 },
|
||||||
|
"rotation": 270,
|
||||||
|
"note": "Against east wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "bookshelf",
|
||||||
|
"instanceId": "shelf-2",
|
||||||
|
"position": { "x": 5.35, "z": 4.5 },
|
||||||
|
"rotation": 270,
|
||||||
|
"note": "Against east wall, next to first shelf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "sideboard",
|
||||||
|
"position": { "x": 0.2, "z": 4.0 },
|
||||||
|
"rotation": 90,
|
||||||
|
"note": "Against west wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "rug-large",
|
||||||
|
"position": { "x": 2.75, "z": 2.5 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Under seating area"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "floor-lamp",
|
||||||
|
"position": { "x": 0.3, "z": 3.8 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Next to loveseat"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "floor-lamp",
|
||||||
|
"instanceId": "lamp-2",
|
||||||
|
"position": { "x": 5.2, "z": 3.8 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Next to armchair"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "plant-large",
|
||||||
|
"position": { "x": 0.3, "z": 5.7 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Northwest corner near window"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "plant-large",
|
||||||
|
"instanceId": "plant-2",
|
||||||
|
"position": { "x": 5.2, "z": 5.7 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Northeast corner near window"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"roomId": "eg-gaeste-wc",
|
||||||
|
"name": "Gäste-WC (Guest WC)",
|
||||||
|
"dimensions": { "width": 2.5, "length": 2.0 },
|
||||||
|
"furniture": [
|
||||||
|
{
|
||||||
|
"catalogId": "toilet",
|
||||||
|
"position": { "x": 2.175, "z": 1.0 },
|
||||||
|
"rotation": 270,
|
||||||
|
"note": "Against east wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "sink-bathroom",
|
||||||
|
"position": { "x": 1.0, "z": 1.775 },
|
||||||
|
"rotation": 180,
|
||||||
|
"note": "Against north wall"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"roomId": "eg-hwr",
|
||||||
|
"name": "Hauswirtschaftsraum (Utility Room)",
|
||||||
|
"dimensions": { "width": 2.5, "length": 2.0 },
|
||||||
|
"furniture": [
|
||||||
|
{
|
||||||
|
"catalogId": "washing-machine",
|
||||||
|
"position": { "x": 0.3, "z": 0.3 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Against south wall"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"roomId": "eg-arbeitszimmer",
|
||||||
|
"name": "Arbeitszimmer (Home Office / Library)",
|
||||||
|
"dimensions": { "width": 5.0, "length": 4.0 },
|
||||||
|
"furniture": [
|
||||||
|
{
|
||||||
|
"catalogId": "desk",
|
||||||
|
"position": { "x": 3.5, "z": 0.35 },
|
||||||
|
"rotation": 180,
|
||||||
|
"note": "Facing south toward east wall window area"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "office-chair",
|
||||||
|
"position": { "x": 3.5, "z": 1.1 },
|
||||||
|
"rotation": 180,
|
||||||
|
"note": "At desk"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "desk-lamp",
|
||||||
|
"position": { "x": 4.0, "z": 0.35 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "On desk"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "bookshelf",
|
||||||
|
"instanceId": "lib-shelf-1",
|
||||||
|
"position": { "x": 0.15, "z": 1.0 },
|
||||||
|
"rotation": 90,
|
||||||
|
"note": "Against west wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "bookshelf",
|
||||||
|
"instanceId": "lib-shelf-2",
|
||||||
|
"position": { "x": 0.15, "z": 2.0 },
|
||||||
|
"rotation": 90,
|
||||||
|
"note": "Against west wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "bookshelf",
|
||||||
|
"instanceId": "lib-shelf-3",
|
||||||
|
"position": { "x": 0.15, "z": 3.0 },
|
||||||
|
"rotation": 90,
|
||||||
|
"note": "Against west wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "sofa-2seat",
|
||||||
|
"position": { "x": 2.5, "z": 3.575 },
|
||||||
|
"rotation": 180,
|
||||||
|
"note": "Against north wall for reading"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "floor-lamp",
|
||||||
|
"position": { "x": 1.5, "z": 3.5 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Next to sofa for reading"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "coffee-table",
|
||||||
|
"position": { "x": 2.5, "z": 2.5 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "In front of sofa"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "plant-large",
|
||||||
|
"position": { "x": 4.7, "z": 3.7 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Northeast corner"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"roomId": "eg-esszimmer",
|
||||||
|
"name": "Esszimmer (Dining Room)",
|
||||||
|
"dimensions": { "width": 5.0, "length": 5.0 },
|
||||||
|
"furniture": [
|
||||||
|
{
|
||||||
|
"catalogId": "dining-table",
|
||||||
|
"position": { "x": 2.5, "z": 2.5 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Centered in room"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "dining-chair",
|
||||||
|
"instanceId": "dc-n1",
|
||||||
|
"position": { "x": 1.9, "z": 3.25 },
|
||||||
|
"rotation": 180,
|
||||||
|
"note": "North side, left"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "dining-chair",
|
||||||
|
"instanceId": "dc-n2",
|
||||||
|
"position": { "x": 3.1, "z": 3.25 },
|
||||||
|
"rotation": 180,
|
||||||
|
"note": "North side, right"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "dining-chair",
|
||||||
|
"instanceId": "dc-s1",
|
||||||
|
"position": { "x": 1.9, "z": 1.75 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "South side, left"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "dining-chair",
|
||||||
|
"instanceId": "dc-s2",
|
||||||
|
"position": { "x": 3.1, "z": 1.75 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "South side, right"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "dining-chair",
|
||||||
|
"instanceId": "dc-w",
|
||||||
|
"position": { "x": 1.2, "z": 2.5 },
|
||||||
|
"rotation": 90,
|
||||||
|
"note": "West end"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "dining-chair",
|
||||||
|
"instanceId": "dc-e",
|
||||||
|
"position": { "x": 3.8, "z": 2.5 },
|
||||||
|
"rotation": 270,
|
||||||
|
"note": "East end"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "dining-chair",
|
||||||
|
"instanceId": "dc-n3",
|
||||||
|
"position": { "x": 2.5, "z": 3.25 },
|
||||||
|
"rotation": 180,
|
||||||
|
"note": "North side, center"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "dining-chair",
|
||||||
|
"instanceId": "dc-s3",
|
||||||
|
"position": { "x": 2.5, "z": 1.75 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "South side, center"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "sideboard",
|
||||||
|
"position": { "x": 0.2, "z": 3.5 },
|
||||||
|
"rotation": 90,
|
||||||
|
"note": "Against west wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "sideboard",
|
||||||
|
"instanceId": "sideboard-2",
|
||||||
|
"position": { "x": 4.8, "z": 3.5 },
|
||||||
|
"rotation": 270,
|
||||||
|
"note": "Against east wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "rug-large",
|
||||||
|
"position": { "x": 2.5, "z": 2.5 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Under dining table"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "plant-large",
|
||||||
|
"position": { "x": 0.3, "z": 4.7 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Northwest corner near window"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"roomId": "og-flur",
|
||||||
|
"name": "Flur OG (Upper Hallway)",
|
||||||
|
"dimensions": { "width": 2.5, "length": 11.0 },
|
||||||
|
"furniture": [
|
||||||
|
{
|
||||||
|
"catalogId": "console-table",
|
||||||
|
"position": { "x": 0.15, "z": 5.5 },
|
||||||
|
"rotation": 90,
|
||||||
|
"note": "Against west wall, center"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "rug-small",
|
||||||
|
"position": { "x": 1.25, "z": 3.0 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Hallway runner"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "plant-small",
|
||||||
|
"position": { "x": 2.2, "z": 5.5 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "On console table area"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"roomId": "og-schlafzimmer",
|
||||||
|
"name": "Schlafzimmer (Master Suite)",
|
||||||
|
"dimensions": { "width": 5.5, "length": 5.0 },
|
||||||
|
"furniture": [
|
||||||
|
{
|
||||||
|
"catalogId": "double-bed",
|
||||||
|
"position": { "x": 2.75, "z": 3.85 },
|
||||||
|
"rotation": 180,
|
||||||
|
"note": "Headboard against north wall, centered"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "nightstand",
|
||||||
|
"instanceId": "ns-left",
|
||||||
|
"position": { "x": 1.625, "z": 4.6 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Left side of bed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "nightstand",
|
||||||
|
"instanceId": "ns-right",
|
||||||
|
"position": { "x": 3.875, "z": 4.6 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Right side of bed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "armchair",
|
||||||
|
"position": { "x": 0.5, "z": 1.5 },
|
||||||
|
"rotation": 90,
|
||||||
|
"note": "Reading corner"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "floor-lamp",
|
||||||
|
"position": { "x": 0.3, "z": 2.2 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Next to armchair"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "rug-large",
|
||||||
|
"position": { "x": 2.75, "z": 2.5 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Under and beside bed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "desk-lamp",
|
||||||
|
"instanceId": "bedside-1",
|
||||||
|
"position": { "x": 1.625, "z": 4.6 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "On left nightstand"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "desk-lamp",
|
||||||
|
"instanceId": "bedside-2",
|
||||||
|
"position": { "x": 3.875, "z": 4.6 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "On right nightstand"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "plant-large",
|
||||||
|
"position": { "x": 5.2, "z": 4.7 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Northeast corner near window"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"roomId": "og-elternbad",
|
||||||
|
"name": "Elternbad (En-suite Bathroom)",
|
||||||
|
"dimensions": { "width": 3.0, "length": 3.0 },
|
||||||
|
"furniture": [
|
||||||
|
{
|
||||||
|
"catalogId": "bathtub",
|
||||||
|
"position": { "x": 0.375, "z": 1.5 },
|
||||||
|
"rotation": 90,
|
||||||
|
"note": "Against west wall, under window"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "shower",
|
||||||
|
"position": { "x": 2.55, "z": 0.45 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Southeast corner"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "sink-bathroom",
|
||||||
|
"position": { "x": 1.5, "z": 2.775 },
|
||||||
|
"rotation": 180,
|
||||||
|
"note": "Against north wall, centered"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "bathroom-cabinet",
|
||||||
|
"position": { "x": 1.5, "y": 1.5, "z": 2.9 },
|
||||||
|
"rotation": 180,
|
||||||
|
"wallMounted": true,
|
||||||
|
"note": "Above sink on north wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "toilet",
|
||||||
|
"position": { "x": 2.675, "z": 2.5 },
|
||||||
|
"rotation": 270,
|
||||||
|
"note": "Against east wall, north"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"roomId": "og-ankleide",
|
||||||
|
"name": "Ankleide (Walk-in Closet)",
|
||||||
|
"dimensions": { "width": 2.5, "length": 2.5 },
|
||||||
|
"furniture": [
|
||||||
|
{
|
||||||
|
"catalogId": "wardrobe",
|
||||||
|
"position": { "x": 0.3, "z": 1.25 },
|
||||||
|
"rotation": 90,
|
||||||
|
"note": "Against west wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "wardrobe",
|
||||||
|
"instanceId": "wardrobe-2",
|
||||||
|
"position": { "x": 2.2, "z": 1.25 },
|
||||||
|
"rotation": 270,
|
||||||
|
"note": "Against east wall"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"roomId": "og-kinderzimmer1",
|
||||||
|
"name": "Kinderzimmer 1 (Child's Room 1)",
|
||||||
|
"dimensions": { "width": 5.5, "length": 3.0 },
|
||||||
|
"furniture": [
|
||||||
|
{
|
||||||
|
"catalogId": "single-bed",
|
||||||
|
"position": { "x": 0.8, "z": 2.0 },
|
||||||
|
"rotation": 180,
|
||||||
|
"note": "Headboard against north wall, west side"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "nightstand",
|
||||||
|
"position": { "x": 1.55, "z": 2.7 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Right side of bed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "desk",
|
||||||
|
"position": { "x": 2.5, "z": 0.35 },
|
||||||
|
"rotation": 180,
|
||||||
|
"note": "Against south wall facing window"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "desk-lamp",
|
||||||
|
"position": { "x": 3.0, "z": 0.35 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "On desk"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "kids-wardrobe",
|
||||||
|
"position": { "x": 5.225, "z": 2.0 },
|
||||||
|
"rotation": 270,
|
||||||
|
"note": "Against east wall, north of door"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "kids-shelf",
|
||||||
|
"position": { "x": 4.0, "z": 2.85 },
|
||||||
|
"rotation": 180,
|
||||||
|
"note": "Against north wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "bookshelf",
|
||||||
|
"position": { "x": 5.35, "z": 0.5 },
|
||||||
|
"rotation": 270,
|
||||||
|
"note": "Against east wall, south of wardrobe"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "rug-small",
|
||||||
|
"position": { "x": 2.75, "z": 1.5 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Center play area"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"roomId": "og-kinderzimmer2",
|
||||||
|
"name": "Kinderzimmer 2 (Child's Room 2)",
|
||||||
|
"dimensions": { "width": 5.0, "length": 3.5 },
|
||||||
|
"furniture": [
|
||||||
|
{
|
||||||
|
"catalogId": "single-bed",
|
||||||
|
"position": { "x": 4.0, "z": 2.5 },
|
||||||
|
"rotation": 180,
|
||||||
|
"note": "Headboard against north wall, east side"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "nightstand",
|
||||||
|
"position": { "x": 3.275, "z": 3.2 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Left side of bed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "desk",
|
||||||
|
"position": { "x": 1.5, "z": 0.35 },
|
||||||
|
"rotation": 180,
|
||||||
|
"note": "Against south wall facing window"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "desk-lamp",
|
||||||
|
"position": { "x": 1.0, "z": 0.35 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "On desk"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "kids-wardrobe",
|
||||||
|
"position": { "x": 0.275, "z": 2.0 },
|
||||||
|
"rotation": 90,
|
||||||
|
"note": "Against west wall, north of door"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "kids-shelf",
|
||||||
|
"position": { "x": 0.15, "z": 0.5 },
|
||||||
|
"rotation": 90,
|
||||||
|
"note": "Against west wall, south of wardrobe"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "rug-small",
|
||||||
|
"position": { "x": 2.5, "z": 1.75 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Center play area"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"roomId": "og-badezimmer",
|
||||||
|
"name": "Badezimmer (Family Bathroom)",
|
||||||
|
"dimensions": { "width": 3.5, "length": 3.0 },
|
||||||
|
"furniture": [
|
||||||
|
{
|
||||||
|
"catalogId": "bathtub",
|
||||||
|
"position": { "x": 1.5, "z": 0.375 },
|
||||||
|
"rotation": 90,
|
||||||
|
"note": "Along south wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "sink-bathroom",
|
||||||
|
"position": { "x": 1.5, "z": 2.775 },
|
||||||
|
"rotation": 180,
|
||||||
|
"note": "Against north wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "bathroom-cabinet",
|
||||||
|
"position": { "x": 1.5, "y": 1.5, "z": 2.9 },
|
||||||
|
"rotation": 180,
|
||||||
|
"wallMounted": true,
|
||||||
|
"note": "Above sink on north wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "toilet",
|
||||||
|
"position": { "x": 3.175, "z": 2.0 },
|
||||||
|
"rotation": 270,
|
||||||
|
"note": "Against east wall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "washing-machine",
|
||||||
|
"position": { "x": 3.2, "z": 0.3 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Against south wall, east side"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"roomId": "og-gaestezimmer",
|
||||||
|
"name": "Gästezimmer (Guest Suite)",
|
||||||
|
"dimensions": { "width": 7.0, "length": 3.5 },
|
||||||
|
"furniture": [
|
||||||
|
{
|
||||||
|
"catalogId": "double-bed",
|
||||||
|
"position": { "x": 2.0, "z": 2.35 },
|
||||||
|
"rotation": 180,
|
||||||
|
"note": "Headboard against north wall, west portion"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "nightstand",
|
||||||
|
"instanceId": "guest-ns-l",
|
||||||
|
"position": { "x": 0.875, "z": 3.1 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Left side of bed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "nightstand",
|
||||||
|
"instanceId": "guest-ns-r",
|
||||||
|
"position": { "x": 3.125, "z": 3.1 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Right side of bed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "wardrobe",
|
||||||
|
"position": { "x": 5.5, "z": 0.3 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Against south wall, east portion"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "desk",
|
||||||
|
"position": { "x": 5.5, "z": 3.15 },
|
||||||
|
"rotation": 180,
|
||||||
|
"note": "Against north wall, east portion near window"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "desk-lamp",
|
||||||
|
"position": { "x": 6.0, "z": 3.15 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "On desk"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "armchair",
|
||||||
|
"position": { "x": 4.5, "z": 1.5 },
|
||||||
|
"rotation": 270,
|
||||||
|
"note": "Reading area"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "floor-lamp",
|
||||||
|
"position": { "x": 4.2, "z": 2.0 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Next to armchair"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"catalogId": "rug-large",
|
||||||
|
"position": { "x": 2.0, "z": 1.75 },
|
||||||
|
"rotation": 0,
|
||||||
|
"note": "Under bed area"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
12
package.json
Normal file
12
package.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "house-design",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"vitest": "^3.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
screenshot-ikea.png
Normal file
BIN
screenshot-ikea.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 275 KiB |
361
scripts/import-ikea-hf.js
Normal file
361
scripts/import-ikea-hf.js
Normal file
@@ -0,0 +1,361 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* IKEA HuggingFace Dataset Importer
|
||||||
|
*
|
||||||
|
* Fetches product data from the tsazan/ikea-us-commercetxt dataset on HuggingFace
|
||||||
|
* and converts items with valid dimensions into our catalog JSON format.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node scripts/import-ikea-hf.js [--limit N] [--output path]
|
||||||
|
*
|
||||||
|
* The HuggingFace dataset stores products in CommerceTXT format where each row
|
||||||
|
* is a line of text. Products are spread across multiple rows with sections like
|
||||||
|
* @PRODUCT, @SPECS, @IMAGES. This script streams through rows, reassembles
|
||||||
|
* product records, extracts dimensions, and generates procedural box meshes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DATASET = 'tsazan/ikea-us-commercetxt';
|
||||||
|
const API_BASE = 'https://datasets-server.huggingface.co';
|
||||||
|
const BATCH_SIZE = 100;
|
||||||
|
|
||||||
|
// Category mapping from IKEA categories to our catalog categories
|
||||||
|
const CATEGORY_MAP = {
|
||||||
|
'sofas': 'seating',
|
||||||
|
'armchairs': 'seating',
|
||||||
|
'chairs': 'seating',
|
||||||
|
'dining chairs': 'seating',
|
||||||
|
'office chairs': 'office',
|
||||||
|
'desk chairs': 'office',
|
||||||
|
'desks': 'tables',
|
||||||
|
'dining tables': 'tables',
|
||||||
|
'coffee tables': 'tables',
|
||||||
|
'side tables': 'tables',
|
||||||
|
'console tables': 'tables',
|
||||||
|
'nightstands': 'tables',
|
||||||
|
'bedside tables': 'tables',
|
||||||
|
'bookcases': 'storage',
|
||||||
|
'shelving units': 'storage',
|
||||||
|
'shelf units': 'storage',
|
||||||
|
'dressers': 'storage',
|
||||||
|
'chests of drawers': 'storage',
|
||||||
|
'wardrobes': 'storage',
|
||||||
|
'tv stands': 'storage',
|
||||||
|
'tv benches': 'storage',
|
||||||
|
'sideboards': 'storage',
|
||||||
|
'cabinets': 'storage',
|
||||||
|
'beds': 'beds',
|
||||||
|
'bed frames': 'beds',
|
||||||
|
'kitchen cabinets': 'kitchen',
|
||||||
|
'kitchen islands': 'kitchen',
|
||||||
|
'base cabinets': 'kitchen',
|
||||||
|
'wall cabinets': 'kitchen',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Room mapping based on category
|
||||||
|
const ROOM_MAP = {
|
||||||
|
'seating': ['wohnzimmer'],
|
||||||
|
'tables': ['wohnzimmer', 'esszimmer'],
|
||||||
|
'storage': ['wohnzimmer', 'arbeitszimmer'],
|
||||||
|
'beds': ['schlafzimmer'],
|
||||||
|
'kitchen': ['kueche'],
|
||||||
|
'office': ['arbeitszimmer'],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse dimension string like '23⅝"' or '50¾"' to meters
|
||||||
|
function parseInchDim(str) {
|
||||||
|
if (!str) return null;
|
||||||
|
str = str.trim().replace(/"/g, '').replace(/'/g, '');
|
||||||
|
|
||||||
|
// Handle fractions like ⅝, ¾, ½, ¼, ⅜, ⅞
|
||||||
|
const fractions = { '⅛': 0.125, '¼': 0.25, '⅜': 0.375, '½': 0.5, '⅝': 0.625, '¾': 0.75, '⅞': 0.875 };
|
||||||
|
let value = 0;
|
||||||
|
|
||||||
|
for (const [frac, num] of Object.entries(fractions)) {
|
||||||
|
if (str.includes(frac)) {
|
||||||
|
str = str.replace(frac, '');
|
||||||
|
value += num;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const numPart = parseFloat(str);
|
||||||
|
if (!isNaN(numPart)) value += numPart;
|
||||||
|
|
||||||
|
// Convert inches to meters
|
||||||
|
return value > 0 ? Math.round(value * 0.0254 * 1000) / 1000 : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse a dimensions line from @SPECS section
|
||||||
|
// Examples: "Width: 23⅝" and 50¾".", "Height: 29½"", "Depth: 15⅜""
|
||||||
|
function parseDimensions(specsLines) {
|
||||||
|
let width = null, height = null, depth = null;
|
||||||
|
|
||||||
|
for (const line of specsLines) {
|
||||||
|
const lower = line.toLowerCase();
|
||||||
|
|
||||||
|
// Try "Width: X" pattern
|
||||||
|
const wMatch = line.match(/Width:\s*([^,.\n]+)/i);
|
||||||
|
if (wMatch) {
|
||||||
|
// Take first value if multiple ("23⅝" and 50¾"")
|
||||||
|
const parts = wMatch[1].split(/\s+and\s+/);
|
||||||
|
width = parseInchDim(parts[parts.length - 1]); // take largest
|
||||||
|
}
|
||||||
|
|
||||||
|
const hMatch = line.match(/Height:\s*([^,.\n]+)/i);
|
||||||
|
if (hMatch) {
|
||||||
|
const parts = hMatch[1].split(/\s+and\s+/);
|
||||||
|
height = parseInchDim(parts[parts.length - 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dMatch = line.match(/Depth:\s*([^,.\n]+)/i);
|
||||||
|
if (dMatch) {
|
||||||
|
const parts = dMatch[1].split(/\s+and\s+/);
|
||||||
|
depth = parseInchDim(parts[parts.length - 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also try "WxDxH" or "W"xD"xH"" pattern
|
||||||
|
const xMatch = line.match(/(\d+[⅛¼⅜½⅝¾⅞]?)"?\s*x\s*(\d+[⅛¼⅜½⅝¾⅞]?)"?\s*x\s*(\d+[⅛¼⅜½⅝¾⅞]?)"/i);
|
||||||
|
if (xMatch) {
|
||||||
|
width = width || parseInchDim(xMatch[1]);
|
||||||
|
depth = depth || parseInchDim(xMatch[2]);
|
||||||
|
height = height || parseInchDim(xMatch[3]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (width && height && depth) {
|
||||||
|
return { width, depth, height };
|
||||||
|
}
|
||||||
|
// At minimum need width and one other
|
||||||
|
if (width && (height || depth)) {
|
||||||
|
return {
|
||||||
|
width,
|
||||||
|
depth: depth || Math.round(width * 0.5 * 1000) / 1000,
|
||||||
|
height: height || Math.round(width * 0.8 * 1000) / 1000
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a simple procedural box mesh from dimensions
|
||||||
|
function generateMesh(dims, category) {
|
||||||
|
const { width, depth, height } = dims;
|
||||||
|
const color = {
|
||||||
|
seating: '#7a8a9a',
|
||||||
|
tables: '#b09870',
|
||||||
|
storage: '#f0ece4',
|
||||||
|
beds: '#f5f0eb',
|
||||||
|
kitchen: '#e0dcd4',
|
||||||
|
office: '#cccccc',
|
||||||
|
}[category] || '#aaaaaa';
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'group',
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
name: 'body',
|
||||||
|
geometry: 'box',
|
||||||
|
size: [width, height, depth],
|
||||||
|
position: [0, height / 2, 0],
|
||||||
|
color
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate slug ID from product name
|
||||||
|
function slugify(name) {
|
||||||
|
return 'ikea-hf-' + name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[äöü]/g, c => ({ 'ä': 'ae', 'ö': 'oe', 'ü': 'ue' }[c]))
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/(^-|-$)/g, '')
|
||||||
|
.slice(0, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guess category from product name/context
|
||||||
|
function guessCategory(name, contextCategory) {
|
||||||
|
const lower = name.toLowerCase();
|
||||||
|
if (/sofa|couch|loveseat/i.test(lower)) return 'seating';
|
||||||
|
if (/chair|armchair|stool/i.test(lower)) return 'seating';
|
||||||
|
if (/desk|table/i.test(lower)) return 'tables';
|
||||||
|
if (/shelf|bookcase|shelving|kallax|billy/i.test(lower)) return 'storage';
|
||||||
|
if (/dresser|drawer|wardrobe|pax|malm.*drawer/i.test(lower)) return 'storage';
|
||||||
|
if (/tv.*bench|tv.*stand|besta|bestå/i.test(lower)) return 'storage';
|
||||||
|
if (/bed|mattress/i.test(lower)) return 'beds';
|
||||||
|
if (/cabinet|kitchen|metod|knoxhult/i.test(lower)) return 'kitchen';
|
||||||
|
if (/office/i.test(lower)) return 'office';
|
||||||
|
|
||||||
|
// Try context category
|
||||||
|
for (const [key, cat] of Object.entries(CATEGORY_MAP)) {
|
||||||
|
if (contextCategory && contextCategory.toLowerCase().includes(key)) return cat;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'storage'; // default
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract IKEA series name from product name
|
||||||
|
function extractSeries(name) {
|
||||||
|
// IKEA series are typically the first all-caps word
|
||||||
|
const match = name.match(/^([A-ZÅÄÖ]{2,})/);
|
||||||
|
return match ? match[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRows(offset, length) {
|
||||||
|
const url = `${API_BASE}/rows?dataset=${DATASET}&config=default&split=train&offset=${offset}&length=${length}`;
|
||||||
|
const resp = await fetch(url);
|
||||||
|
if (!resp.ok) throw new Error(`API error: ${resp.status}`);
|
||||||
|
const data = await resp.json();
|
||||||
|
return data.rows?.map(r => r.row?.text || '') || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importDataset(maxItems = 50) {
|
||||||
|
console.error(`Fetching IKEA products from HuggingFace (limit: ${maxItems})...`);
|
||||||
|
|
||||||
|
const items = [];
|
||||||
|
const seenIds = new Set();
|
||||||
|
let offset = 0;
|
||||||
|
let currentProduct = null;
|
||||||
|
let currentSection = null;
|
||||||
|
let currentCategory = null;
|
||||||
|
let specsLines = [];
|
||||||
|
let totalRows = 0;
|
||||||
|
|
||||||
|
// Process in batches
|
||||||
|
while (items.length < maxItems) {
|
||||||
|
let rows;
|
||||||
|
try {
|
||||||
|
rows = await fetchRows(offset, BATCH_SIZE);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(` Fetch error at offset ${offset}: ${e.message}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rows || rows.length === 0) break;
|
||||||
|
totalRows += rows.length;
|
||||||
|
|
||||||
|
for (const line of rows) {
|
||||||
|
// Track sections
|
||||||
|
if (line.startsWith('# @CATEGORY')) {
|
||||||
|
currentSection = 'category';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.startsWith('# @PRODUCT')) {
|
||||||
|
currentSection = 'product';
|
||||||
|
currentProduct = {};
|
||||||
|
specsLines = [];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.startsWith('# @SPECS')) {
|
||||||
|
currentSection = 'specs';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.startsWith('# @FILTERS')) {
|
||||||
|
currentSection = 'filters';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.startsWith('# @ITEMS')) {
|
||||||
|
currentSection = 'items';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.startsWith('# @IMAGES')) {
|
||||||
|
currentSection = 'images';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line === '---' || line.startsWith('# DISCLAIMER')) {
|
||||||
|
// End of product — process if we have one
|
||||||
|
if (currentProduct && currentProduct.name) {
|
||||||
|
const dims = parseDimensions(specsLines);
|
||||||
|
if (dims && dims.width > 0.1 && dims.height > 0.1) {
|
||||||
|
const category = guessCategory(currentProduct.name, currentCategory);
|
||||||
|
const id = slugify(currentProduct.name);
|
||||||
|
|
||||||
|
if (!seenIds.has(id)) {
|
||||||
|
seenIds.add(id);
|
||||||
|
items.push({
|
||||||
|
id,
|
||||||
|
name: currentProduct.name,
|
||||||
|
ikeaSeries: extractSeries(currentProduct.name),
|
||||||
|
sku: currentProduct.sku || null,
|
||||||
|
category,
|
||||||
|
rooms: ROOM_MAP[category] || [],
|
||||||
|
dimensions: dims,
|
||||||
|
mesh: generateMesh(dims, category)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (items.length >= maxItems) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
currentProduct = null;
|
||||||
|
currentSection = line.startsWith('# DISCLAIMER') ? 'disclaimer' : null;
|
||||||
|
specsLines = [];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse line content based on section
|
||||||
|
if (currentSection === 'category') {
|
||||||
|
const nameMatch = line.match(/^Name:\s*(.+)/);
|
||||||
|
if (nameMatch) currentCategory = nameMatch[1].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentSection === 'product' && currentProduct) {
|
||||||
|
const nameMatch = line.match(/^Name:\s*(.+)/);
|
||||||
|
if (nameMatch) currentProduct.name = nameMatch[1].trim();
|
||||||
|
const skuMatch = line.match(/^SKU:\s*(.+)/);
|
||||||
|
if (skuMatch) currentProduct.sku = skuMatch[1].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentSection === 'specs') {
|
||||||
|
if (line.trim()) specsLines.push(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.length >= maxItems) break;
|
||||||
|
offset += BATCH_SIZE;
|
||||||
|
|
||||||
|
// Safety limit: don't scan more than 100k rows
|
||||||
|
if (offset > 100000) {
|
||||||
|
console.error(` Reached scan limit at ${offset} rows`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(` Scanned ${totalRows} rows, extracted ${items.length} items with dimensions`);
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
let limit = 100;
|
||||||
|
let outputPath = null;
|
||||||
|
|
||||||
|
for (let i = 0; i < args.length; i++) {
|
||||||
|
if (args[i] === '--limit' && args[i + 1]) limit = parseInt(args[i + 1], 10);
|
||||||
|
if (args[i] === '--output' && args[i + 1]) outputPath = args[i + 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = await importDataset(limit);
|
||||||
|
|
||||||
|
const catalog = {
|
||||||
|
version: '1.0',
|
||||||
|
source: 'huggingface-ikea-us-commercetxt',
|
||||||
|
units: 'meters',
|
||||||
|
description: `Imported from HuggingFace dataset tsazan/ikea-us-commercetxt (${items.length} items)`,
|
||||||
|
categories: [...new Set(items.map(i => i.category))].sort(),
|
||||||
|
items
|
||||||
|
};
|
||||||
|
|
||||||
|
const json = JSON.stringify(catalog, null, 2);
|
||||||
|
|
||||||
|
if (outputPath) {
|
||||||
|
const fs = await import('fs');
|
||||||
|
fs.writeFileSync(outputPath, json);
|
||||||
|
console.error(`Wrote ${items.length} items to ${outputPath}`);
|
||||||
|
} else {
|
||||||
|
process.stdout.write(json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(e => {
|
||||||
|
console.error('Error:', e.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
185
src/catalog.js
185
src/catalog.js
@@ -1,8 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* CatalogPanel — left sidebar for browsing furniture catalog.
|
* CatalogPanel — left sidebar for browsing furniture catalog.
|
||||||
*
|
*
|
||||||
* Shows categories, search, and item cards. Clicking an item
|
* Shows source tabs (All / Standard / IKEA), categories, series filter,
|
||||||
* adds it to the center of the selected room via DesignState.
|
* search, and item cards. Clicking an item adds it to the center of the
|
||||||
|
* selected room via DesignState.
|
||||||
*/
|
*/
|
||||||
export class CatalogPanel {
|
export class CatalogPanel {
|
||||||
constructor(container, { renderer, state, interaction }) {
|
constructor(container, { renderer, state, interaction }) {
|
||||||
@@ -11,7 +12,9 @@ export class CatalogPanel {
|
|||||||
this.state = state;
|
this.state = state;
|
||||||
this.interaction = interaction;
|
this.interaction = interaction;
|
||||||
|
|
||||||
|
this.selectedSource = 'all'; // 'all', 'standard', 'ikea'
|
||||||
this.selectedCategory = 'all';
|
this.selectedCategory = 'all';
|
||||||
|
this.selectedSeries = 'all';
|
||||||
this.searchQuery = '';
|
this.searchQuery = '';
|
||||||
this.selectedRoomId = null;
|
this.selectedRoomId = null;
|
||||||
|
|
||||||
@@ -25,6 +28,11 @@ export class CatalogPanel {
|
|||||||
this.container.innerHTML = '';
|
this.container.innerHTML = '';
|
||||||
this.container.className = 'catalog-panel';
|
this.container.className = 'catalog-panel';
|
||||||
|
|
||||||
|
// Source tabs
|
||||||
|
this._sourceBar = document.createElement('div');
|
||||||
|
this._sourceBar.className = 'catalog-source-tabs';
|
||||||
|
this.container.appendChild(this._sourceBar);
|
||||||
|
|
||||||
// Search
|
// Search
|
||||||
const searchWrap = document.createElement('div');
|
const searchWrap = document.createElement('div');
|
||||||
searchWrap.className = 'catalog-search';
|
searchWrap.className = 'catalog-search';
|
||||||
@@ -40,6 +48,12 @@ export class CatalogPanel {
|
|||||||
this._categoryBar.className = 'catalog-categories';
|
this._categoryBar.className = 'catalog-categories';
|
||||||
this.container.appendChild(this._categoryBar);
|
this.container.appendChild(this._categoryBar);
|
||||||
|
|
||||||
|
// Series filter (IKEA only, hidden by default)
|
||||||
|
this._seriesBar = document.createElement('div');
|
||||||
|
this._seriesBar.className = 'catalog-series';
|
||||||
|
this._seriesBar.style.display = 'none';
|
||||||
|
this.container.appendChild(this._seriesBar);
|
||||||
|
|
||||||
// Items list
|
// Items list
|
||||||
this._itemList = document.createElement('div');
|
this._itemList = document.createElement('div');
|
||||||
this._itemList.className = 'catalog-items';
|
this._itemList.className = 'catalog-items';
|
||||||
@@ -58,16 +72,57 @@ export class CatalogPanel {
|
|||||||
this._createFormContainer.style.display = 'none';
|
this._createFormContainer.style.display = 'none';
|
||||||
this.container.appendChild(this._createFormContainer);
|
this.container.appendChild(this._createFormContainer);
|
||||||
|
|
||||||
|
this._renderSourceTabs();
|
||||||
this._renderCategories();
|
this._renderCategories();
|
||||||
|
this._renderSeriesFilter();
|
||||||
this._renderItems();
|
this._renderItems();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_renderSourceTabs() {
|
||||||
|
this._sourceBar.innerHTML = '';
|
||||||
|
const hasIkea = this._hasIkeaItems();
|
||||||
|
|
||||||
|
const sources = [
|
||||||
|
{ id: 'all', label: 'All' },
|
||||||
|
{ id: 'standard', label: 'Standard' },
|
||||||
|
];
|
||||||
|
if (hasIkea) {
|
||||||
|
sources.push({ id: 'ikea', label: 'IKEA' });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const src of sources) {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'catalog-source-btn' + (src.id === this.selectedSource ? ' active' : '');
|
||||||
|
btn.textContent = src.label;
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
this.selectedSource = src.id;
|
||||||
|
this.selectedSeries = 'all';
|
||||||
|
this._renderSourceTabs();
|
||||||
|
this._renderCategories();
|
||||||
|
this._renderSeriesFilter();
|
||||||
|
this._renderItems();
|
||||||
|
});
|
||||||
|
this._sourceBar.appendChild(btn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Item count badge
|
||||||
|
const count = this._getFilteredItems().length;
|
||||||
|
const badge = document.createElement('span');
|
||||||
|
badge.className = 'catalog-count';
|
||||||
|
badge.textContent = count;
|
||||||
|
this._sourceBar.appendChild(badge);
|
||||||
|
}
|
||||||
|
|
||||||
_renderCategories() {
|
_renderCategories() {
|
||||||
const catalog = this.renderer.catalogData;
|
const catalog = this.renderer.catalogData;
|
||||||
if (!catalog) return;
|
if (!catalog) return;
|
||||||
|
|
||||||
this._categoryBar.innerHTML = '';
|
this._categoryBar.innerHTML = '';
|
||||||
const categories = ['all', ...catalog.categories];
|
|
||||||
|
// Get categories from filtered items
|
||||||
|
const items = this._getSourceFilteredItems();
|
||||||
|
const activeCats = new Set(items.map(it => it.category));
|
||||||
|
const categories = ['all', ...catalog.categories.filter(c => activeCats.has(c))];
|
||||||
|
|
||||||
const LABELS = {
|
const LABELS = {
|
||||||
all: 'All',
|
all: 'All',
|
||||||
@@ -90,12 +145,101 @@ export class CatalogPanel {
|
|||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
this.selectedCategory = cat;
|
this.selectedCategory = cat;
|
||||||
this._renderCategories();
|
this._renderCategories();
|
||||||
|
this._renderSeriesFilter();
|
||||||
this._renderItems();
|
this._renderItems();
|
||||||
});
|
});
|
||||||
this._categoryBar.appendChild(btn);
|
this._categoryBar.appendChild(btn);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_renderSeriesFilter() {
|
||||||
|
// Only show series filter when IKEA source is active
|
||||||
|
if (this.selectedSource !== 'ikea') {
|
||||||
|
this._seriesBar.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = this._getSourceFilteredItems();
|
||||||
|
const seriesSet = new Set();
|
||||||
|
for (const it of items) {
|
||||||
|
if (it.ikeaSeries) seriesSet.add(it.ikeaSeries);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seriesSet.size < 2) {
|
||||||
|
this._seriesBar.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._seriesBar.style.display = '';
|
||||||
|
this._seriesBar.innerHTML = '';
|
||||||
|
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.className = 'catalog-series-label';
|
||||||
|
label.textContent = 'Series:';
|
||||||
|
this._seriesBar.appendChild(label);
|
||||||
|
|
||||||
|
const seriesList = ['all', ...Array.from(seriesSet).sort()];
|
||||||
|
for (const s of seriesList) {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'catalog-series-btn' + (s === this.selectedSeries ? ' active' : '');
|
||||||
|
btn.textContent = s === 'all' ? 'All' : s;
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
this.selectedSeries = s;
|
||||||
|
this._renderSeriesFilter();
|
||||||
|
this._renderItems();
|
||||||
|
});
|
||||||
|
this._seriesBar.appendChild(btn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_hasIkeaItems() {
|
||||||
|
const catalog = this.renderer.catalogData;
|
||||||
|
if (!catalog) return false;
|
||||||
|
return catalog.items.some(it => it.id.startsWith('ikea-'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get items filtered by source tab only */
|
||||||
|
_getSourceFilteredItems() {
|
||||||
|
const catalog = this.renderer.catalogData;
|
||||||
|
if (!catalog) return [];
|
||||||
|
|
||||||
|
let items = catalog.items;
|
||||||
|
if (this.selectedSource === 'ikea') {
|
||||||
|
items = items.filter(it => it.id.startsWith('ikea-'));
|
||||||
|
} else if (this.selectedSource === 'standard') {
|
||||||
|
items = items.filter(it => !it.id.startsWith('ikea-'));
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get items with all filters applied */
|
||||||
|
_getFilteredItems() {
|
||||||
|
let items = this._getSourceFilteredItems();
|
||||||
|
|
||||||
|
// Filter by category
|
||||||
|
if (this.selectedCategory !== 'all') {
|
||||||
|
items = items.filter(it => it.category === this.selectedCategory);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by series (IKEA only)
|
||||||
|
if (this.selectedSeries !== 'all') {
|
||||||
|
items = items.filter(it => it.ikeaSeries === this.selectedSeries);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by search
|
||||||
|
if (this.searchQuery) {
|
||||||
|
const q = this.searchQuery.toLowerCase();
|
||||||
|
items = items.filter(it =>
|
||||||
|
it.name.toLowerCase().includes(q) ||
|
||||||
|
it.id.toLowerCase().includes(q) ||
|
||||||
|
it.category.toLowerCase().includes(q) ||
|
||||||
|
(it.ikeaSeries && it.ikeaSeries.toLowerCase().includes(q))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
_renderItems() {
|
_renderItems() {
|
||||||
const catalog = this.renderer.catalogData;
|
const catalog = this.renderer.catalogData;
|
||||||
if (!catalog) {
|
if (!catalog) {
|
||||||
@@ -103,22 +247,11 @@ export class CatalogPanel {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let items = catalog.items;
|
const items = this._getFilteredItems();
|
||||||
|
|
||||||
// Filter by category
|
// Update count badge
|
||||||
if (this.selectedCategory !== 'all') {
|
const badge = this._sourceBar.querySelector('.catalog-count');
|
||||||
items = items.filter(it => it.category === this.selectedCategory);
|
if (badge) badge.textContent = items.length;
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by search
|
|
||||||
if (this.searchQuery) {
|
|
||||||
const q = this.searchQuery.toLowerCase();
|
|
||||||
items = items.filter(it =>
|
|
||||||
it.name.toLowerCase().includes(q) ||
|
|
||||||
it.id.toLowerCase().includes(q) ||
|
|
||||||
it.category.toLowerCase().includes(q)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
this._itemList.innerHTML = '';
|
this._itemList.innerHTML = '';
|
||||||
|
|
||||||
@@ -142,13 +275,18 @@ export class CatalogPanel {
|
|||||||
const color = item.mesh?.parts?.[0]?.color || '#888';
|
const color = item.mesh?.parts?.[0]?.color || '#888';
|
||||||
|
|
||||||
const dims = item.dimensions;
|
const dims = item.dimensions;
|
||||||
const dimStr = `${dims.width}×${dims.depth}×${dims.height}m`;
|
const dimStr = `${dims.width}\u00d7${dims.depth}\u00d7${dims.height}m`;
|
||||||
|
|
||||||
|
// Add IKEA badge for IKEA items
|
||||||
|
const isIkea = item.id.startsWith('ikea-');
|
||||||
|
const badge = isIkea ? `<span class="catalog-item-badge">IKEA</span>` : '';
|
||||||
|
const series = isIkea && item.ikeaSeries ? `<span class="catalog-item-series">${item.ikeaSeries}</span>` : '';
|
||||||
|
|
||||||
card.innerHTML =
|
card.innerHTML =
|
||||||
`<div class="catalog-item-swatch" style="background:${color}"></div>` +
|
`<div class="catalog-item-swatch" style="background:${color}"></div>` +
|
||||||
`<div class="catalog-item-info">` +
|
`<div class="catalog-item-info">` +
|
||||||
`<div class="catalog-item-name">${item.name}</div>` +
|
`<div class="catalog-item-name">${badge}${item.name}</div>` +
|
||||||
`<div class="catalog-item-dims">${dimStr}</div>` +
|
`<div class="catalog-item-dims">${dimStr}${series}</div>` +
|
||||||
`</div>` +
|
`</div>` +
|
||||||
`<button class="catalog-item-add" title="Add to room">+</button>`;
|
`<button class="catalog-item-add" title="Add to room">+</button>`;
|
||||||
|
|
||||||
@@ -397,8 +535,11 @@ export class CatalogPanel {
|
|||||||
this.selectedRoomId = roomId;
|
this.selectedRoomId = roomId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Refresh the item list (e.g., after floor change). */
|
/** Refresh the full panel (e.g., after catalog merge or floor change). */
|
||||||
refresh() {
|
refresh() {
|
||||||
|
this._renderSourceTabs();
|
||||||
|
this._renderCategories();
|
||||||
|
this._renderSeriesFilter();
|
||||||
this._renderItems();
|
this._renderItems();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
754
src/floorplan-import.js
Normal file
754
src/floorplan-import.js
Normal file
@@ -0,0 +1,754 @@
|
|||||||
|
/**
|
||||||
|
* FloorplanImporter - Analyzes floor plan images using LLM vision APIs
|
||||||
|
* and converts them into house JSON for the 3D viewer.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const API_PROVIDERS = {
|
||||||
|
claude: {
|
||||||
|
name: 'Claude (Anthropic)',
|
||||||
|
endpoint: 'https://api.anthropic.com/v1/messages',
|
||||||
|
model: 'claude-sonnet-4-5-20250929',
|
||||||
|
buildRequest(base64Image, mediaType, systemPrompt, userPrompt, apiKey) {
|
||||||
|
return {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-api-key': apiKey,
|
||||||
|
'anthropic-version': '2023-06-01',
|
||||||
|
'anthropic-dangerous-direct-browser-access': 'true'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: this.model,
|
||||||
|
max_tokens: 8192,
|
||||||
|
system: systemPrompt,
|
||||||
|
messages: [{
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{ type: 'image', source: { type: 'base64', media_type: mediaType, data: base64Image } },
|
||||||
|
{ type: 'text', text: userPrompt }
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
};
|
||||||
|
},
|
||||||
|
extractJSON(response) {
|
||||||
|
return response.content[0].text;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
openai: {
|
||||||
|
name: 'OpenAI (GPT-4o)',
|
||||||
|
endpoint: 'https://api.openai.com/v1/chat/completions',
|
||||||
|
model: 'gpt-4o',
|
||||||
|
buildRequest(base64Image, mediaType, systemPrompt, userPrompt, apiKey) {
|
||||||
|
return {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${apiKey}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: this.model,
|
||||||
|
max_tokens: 8192,
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: systemPrompt },
|
||||||
|
{ role: 'user', content: [
|
||||||
|
{ type: 'image_url', image_url: { url: `data:${mediaType};base64,${base64Image}` } },
|
||||||
|
{ type: 'text', text: userPrompt }
|
||||||
|
]}
|
||||||
|
],
|
||||||
|
response_format: { type: 'json_object' }
|
||||||
|
})
|
||||||
|
};
|
||||||
|
},
|
||||||
|
extractJSON(response) {
|
||||||
|
return response.choices[0].message.content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT = `You are a floor plan analyzer. Given an image of a floor plan or floor layout,
|
||||||
|
extract the room structure and output valid JSON matching the exact schema below.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- All dimensions in meters. Use standard architectural conventions if no scale bar
|
||||||
|
is visible (standard interior door = 0.9m wide, entry door = 1.0-1.1m)
|
||||||
|
- Rooms are axis-aligned rectangles positioned on a coordinate grid
|
||||||
|
- Position {x, y} is the room's bottom-left corner (x = west-east, y = south-north)
|
||||||
|
- Each room has walls on 4 cardinal directions (north, south, east, west)
|
||||||
|
- Walls are "exterior" if they face outside the building, "interior" otherwise
|
||||||
|
- Doors have: id, type (entry|interior|patio|open), position (meters from wall start),
|
||||||
|
width, height, connectsTo (adjacent room id or "exterior")
|
||||||
|
- Windows have: id, type (casement|fixed), position, width, height, sillHeight
|
||||||
|
- Generate unique IDs: "{floorId}-{roomSlug}" for rooms, "{roomId}-d{n}" for doors,
|
||||||
|
"{roomId}-w{n}" for windows
|
||||||
|
- Room types: living, kitchen, dining, bedroom, bathroom, hallway, office, utility,
|
||||||
|
storage, laundry, garage
|
||||||
|
- Flooring: "tile" for kitchen/bathroom/utility/hallway, "hardwood" for others
|
||||||
|
|
||||||
|
Output ONLY valid JSON, no markdown fences, no explanation.`;
|
||||||
|
|
||||||
|
export class FloorplanImporter {
|
||||||
|
constructor(renderer, options = {}) {
|
||||||
|
this.renderer = renderer;
|
||||||
|
this.onHouseLoaded = options.onHouseLoaded || null;
|
||||||
|
this._overlay = null;
|
||||||
|
this._imageFile = null;
|
||||||
|
this._imagePreviewData = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
open() {
|
||||||
|
if (this._overlay) return;
|
||||||
|
this._overlay = this._buildModal();
|
||||||
|
document.body.appendChild(this._overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
if (this._overlay) {
|
||||||
|
this._overlay.remove();
|
||||||
|
this._overlay = null;
|
||||||
|
}
|
||||||
|
this._imageFile = null;
|
||||||
|
this._imagePreviewData = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildModal() {
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'fp-overlay';
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<style>
|
||||||
|
.fp-overlay {
|
||||||
|
position: fixed; inset: 0; z-index: 1000;
|
||||||
|
background: rgba(0,0,0,0.5);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
}
|
||||||
|
.fp-modal {
|
||||||
|
background: rgba(255,255,255,0.97); border-radius: 8px;
|
||||||
|
width: 520px; max-height: 90vh; overflow-y: auto;
|
||||||
|
padding: 24px; box-shadow: 0 8px 32px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
.fp-modal h2 { font-size: 16px; margin: 0 0 16px; color: #333; }
|
||||||
|
.fp-drop-zone {
|
||||||
|
border: 2px dashed #ccc; border-radius: 6px;
|
||||||
|
padding: 32px; text-align: center; cursor: pointer;
|
||||||
|
transition: border-color 0.2s, background 0.2s;
|
||||||
|
color: #888; font-size: 13px; position: relative;
|
||||||
|
min-height: 120px; display: flex; flex-direction: column;
|
||||||
|
align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.fp-drop-zone.dragover { border-color: #4a90d9; background: #e8f0fe; }
|
||||||
|
.fp-drop-zone img {
|
||||||
|
max-width: 100%; max-height: 200px; border-radius: 4px; margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.fp-drop-zone input[type="file"] {
|
||||||
|
position: absolute; inset: 0; opacity: 0; cursor: pointer;
|
||||||
|
}
|
||||||
|
.fp-field { margin-top: 12px; }
|
||||||
|
.fp-field label {
|
||||||
|
display: block; font-size: 11px; color: #666;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.3px; margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.fp-field input, .fp-field select, .fp-field textarea {
|
||||||
|
width: 100%; padding: 6px 10px; border: 1px solid #ccc;
|
||||||
|
border-radius: 4px; font-size: 13px; outline: none;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.fp-field input:focus, .fp-field select:focus, .fp-field textarea:focus {
|
||||||
|
border-color: #4a90d9;
|
||||||
|
}
|
||||||
|
.fp-row { display: flex; gap: 12px; }
|
||||||
|
.fp-row .fp-field { flex: 1; }
|
||||||
|
.fp-api-row { display: flex; gap: 8px; align-items: flex-end; }
|
||||||
|
.fp-api-row .fp-field:first-child { width: 160px; flex: none; }
|
||||||
|
.fp-api-row .fp-field:last-child { flex: 1; }
|
||||||
|
.fp-actions { margin-top: 16px; display: flex; gap: 8px; }
|
||||||
|
.fp-btn {
|
||||||
|
padding: 8px 16px; border: 1px solid #ccc; border-radius: 4px;
|
||||||
|
font-size: 13px; cursor: pointer; background: #fff;
|
||||||
|
}
|
||||||
|
.fp-btn:hover { background: #f0f0f0; }
|
||||||
|
.fp-btn-primary {
|
||||||
|
background: #4a90d9; color: #fff; border-color: #4a90d9;
|
||||||
|
}
|
||||||
|
.fp-btn-primary:hover { background: #3a7bc8; }
|
||||||
|
.fp-btn-primary:disabled {
|
||||||
|
background: #a0c4e8; border-color: #a0c4e8; cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.fp-btn-danger { color: #c44; }
|
||||||
|
.fp-btn-danger:hover { background: #fdd; }
|
||||||
|
.fp-status {
|
||||||
|
margin-top: 12px; font-size: 12px; color: #666;
|
||||||
|
display: none; align-items: center; gap: 8px;
|
||||||
|
}
|
||||||
|
.fp-status.visible { display: flex; }
|
||||||
|
.fp-spinner {
|
||||||
|
width: 16px; height: 16px; border: 2px solid #ccc;
|
||||||
|
border-top-color: #4a90d9; border-radius: 50%;
|
||||||
|
animation: fp-spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes fp-spin { to { transform: rotate(360deg); } }
|
||||||
|
.fp-error { color: #c44; font-size: 12px; margin-top: 8px; }
|
||||||
|
.fp-preview { margin-top: 16px; display: none; }
|
||||||
|
.fp-preview.visible { display: block; }
|
||||||
|
.fp-preview h3 { font-size: 14px; margin: 0 0 8px; color: #333; }
|
||||||
|
.fp-preview-summary {
|
||||||
|
font-size: 12px; color: #555; margin-bottom: 10px;
|
||||||
|
padding: 8px; background: #f5f5f5; border-radius: 4px;
|
||||||
|
}
|
||||||
|
.fp-room-list {
|
||||||
|
max-height: 200px; overflow-y: auto;
|
||||||
|
border: 1px solid #eee; border-radius: 4px;
|
||||||
|
}
|
||||||
|
.fp-room-item {
|
||||||
|
display: flex; justify-content: space-between;
|
||||||
|
padding: 6px 10px; font-size: 12px; border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
.fp-room-item:last-child { border-bottom: none; }
|
||||||
|
.fp-room-dims { color: #888; }
|
||||||
|
.fp-json-edit {
|
||||||
|
display: none; margin-top: 8px;
|
||||||
|
}
|
||||||
|
.fp-json-edit.visible { display: block; }
|
||||||
|
.fp-json-edit textarea {
|
||||||
|
width: 100%; height: 300px; font-family: monospace;
|
||||||
|
font-size: 11px; white-space: pre; tab-size: 2;
|
||||||
|
}
|
||||||
|
.fp-clear-key {
|
||||||
|
font-size: 11px; color: #888; cursor: pointer;
|
||||||
|
text-decoration: underline; margin-left: 4px;
|
||||||
|
}
|
||||||
|
.fp-clear-key:hover { color: #c44; }
|
||||||
|
</style>
|
||||||
|
<div class="fp-modal">
|
||||||
|
<h2>Import Floor Plan</h2>
|
||||||
|
|
||||||
|
<div class="fp-drop-zone" id="fp-drop-zone">
|
||||||
|
<input type="file" accept="image/png,image/jpeg,image/webp" id="fp-file-input">
|
||||||
|
<div id="fp-drop-label">Drop image here or click to browse<br><small>PNG, JPG, WebP</small></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fp-row">
|
||||||
|
<div class="fp-field">
|
||||||
|
<label>Building Name</label>
|
||||||
|
<input type="text" id="fp-name" value="Imported Floor Plan" placeholder="My House">
|
||||||
|
</div>
|
||||||
|
<div class="fp-field" style="width:100px;flex:none">
|
||||||
|
<label>Floors Shown</label>
|
||||||
|
<select id="fp-floors">
|
||||||
|
<option value="1">1</option>
|
||||||
|
<option value="2">2</option>
|
||||||
|
<option value="3">3</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fp-field">
|
||||||
|
<label>Scale Hint (optional)</label>
|
||||||
|
<input type="text" id="fp-scale" placeholder="e.g. The living room is about 5m wide">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fp-api-row">
|
||||||
|
<div class="fp-field">
|
||||||
|
<label>API Provider</label>
|
||||||
|
<select id="fp-provider">
|
||||||
|
<option value="claude">Claude (Anthropic)</option>
|
||||||
|
<option value="openai">OpenAI (GPT-4o)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="fp-field">
|
||||||
|
<label>API Key <span class="fp-clear-key" id="fp-clear-key">clear saved</span></label>
|
||||||
|
<input type="password" id="fp-api-key" placeholder="Enter API key">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fp-status" id="fp-status">
|
||||||
|
<div class="fp-spinner"></div>
|
||||||
|
<span id="fp-status-text">Analyzing floor plan...</span>
|
||||||
|
</div>
|
||||||
|
<div class="fp-error" id="fp-error"></div>
|
||||||
|
|
||||||
|
<div class="fp-actions" id="fp-actions-main">
|
||||||
|
<button class="fp-btn fp-btn-primary" id="fp-analyze" disabled>Analyze Floor Plan</button>
|
||||||
|
<button class="fp-btn" id="fp-cancel">Cancel</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fp-preview" id="fp-preview">
|
||||||
|
<h3>Result Preview</h3>
|
||||||
|
<div class="fp-preview-summary" id="fp-summary"></div>
|
||||||
|
<div class="fp-room-list" id="fp-room-list"></div>
|
||||||
|
|
||||||
|
<div class="fp-json-edit" id="fp-json-edit">
|
||||||
|
<textarea id="fp-json-textarea"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fp-actions" style="margin-top:12px">
|
||||||
|
<button class="fp-btn fp-btn-primary" id="fp-accept">Accept & Load</button>
|
||||||
|
<button class="fp-btn" id="fp-edit-json">Edit JSON</button>
|
||||||
|
<button class="fp-btn" id="fp-reanalyze">Re-analyze</button>
|
||||||
|
<button class="fp-btn fp-btn-danger" id="fp-cancel2">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Wire up events after inserting into DOM
|
||||||
|
requestAnimationFrame(() => this._wireEvents(overlay));
|
||||||
|
return overlay;
|
||||||
|
}
|
||||||
|
|
||||||
|
_wireEvents(overlay) {
|
||||||
|
const $ = (id) => overlay.querySelector(`#${id}`);
|
||||||
|
|
||||||
|
// Close on overlay background click
|
||||||
|
overlay.addEventListener('click', (e) => {
|
||||||
|
if (e.target === overlay) this.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// File input / drag-drop
|
||||||
|
const dropZone = $('fp-drop-zone');
|
||||||
|
const fileInput = $('fp-file-input');
|
||||||
|
const dropLabel = $('fp-drop-label');
|
||||||
|
|
||||||
|
dropZone.addEventListener('dragover', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dropZone.classList.add('dragover');
|
||||||
|
});
|
||||||
|
dropZone.addEventListener('dragleave', () => {
|
||||||
|
dropZone.classList.remove('dragover');
|
||||||
|
});
|
||||||
|
dropZone.addEventListener('drop', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dropZone.classList.remove('dragover');
|
||||||
|
const file = e.dataTransfer.files[0];
|
||||||
|
if (file && file.type.startsWith('image/')) {
|
||||||
|
this._handleImageUpload(file, dropZone, dropLabel);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
fileInput.addEventListener('change', (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (file) this._handleImageUpload(file, dropZone, dropLabel);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load saved API key
|
||||||
|
const providerSelect = $('fp-provider');
|
||||||
|
const apiKeyInput = $('fp-api-key');
|
||||||
|
const loadSavedKey = () => {
|
||||||
|
const saved = localStorage.getItem(`floorplan-api-key-${providerSelect.value}`);
|
||||||
|
apiKeyInput.value = saved || '';
|
||||||
|
this._updateAnalyzeButton(overlay);
|
||||||
|
};
|
||||||
|
providerSelect.addEventListener('change', loadSavedKey);
|
||||||
|
loadSavedKey();
|
||||||
|
|
||||||
|
// Save key on input
|
||||||
|
apiKeyInput.addEventListener('input', () => {
|
||||||
|
const key = apiKeyInput.value.trim();
|
||||||
|
if (key) {
|
||||||
|
localStorage.setItem(`floorplan-api-key-${providerSelect.value}`, key);
|
||||||
|
}
|
||||||
|
this._updateAnalyzeButton(overlay);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear saved key
|
||||||
|
$('fp-clear-key').addEventListener('click', () => {
|
||||||
|
localStorage.removeItem(`floorplan-api-key-${providerSelect.value}`);
|
||||||
|
apiKeyInput.value = '';
|
||||||
|
this._updateAnalyzeButton(overlay);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Analyze button
|
||||||
|
$('fp-analyze').addEventListener('click', () => this._doAnalyze(overlay));
|
||||||
|
|
||||||
|
// Cancel
|
||||||
|
$('fp-cancel').addEventListener('click', () => this.close());
|
||||||
|
$('fp-cancel2').addEventListener('click', () => this.close());
|
||||||
|
|
||||||
|
// Accept
|
||||||
|
$('fp-accept').addEventListener('click', () => {
|
||||||
|
const jsonEdit = $('fp-json-edit');
|
||||||
|
let data = this._resultData;
|
||||||
|
if (jsonEdit.classList.contains('visible')) {
|
||||||
|
try {
|
||||||
|
data = JSON.parse($('fp-json-textarea').value);
|
||||||
|
} catch (e) {
|
||||||
|
$('fp-error').textContent = 'Invalid JSON: ' + e.message;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._applyToRenderer(data);
|
||||||
|
this.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Edit JSON toggle
|
||||||
|
$('fp-edit-json').addEventListener('click', () => {
|
||||||
|
const jsonEdit = $('fp-json-edit');
|
||||||
|
const btn = $('fp-edit-json');
|
||||||
|
if (jsonEdit.classList.contains('visible')) {
|
||||||
|
jsonEdit.classList.remove('visible');
|
||||||
|
btn.textContent = 'Edit JSON';
|
||||||
|
} else {
|
||||||
|
$('fp-json-textarea').value = JSON.stringify(this._resultData, null, 2);
|
||||||
|
jsonEdit.classList.add('visible');
|
||||||
|
btn.textContent = 'Hide JSON';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-analyze
|
||||||
|
$('fp-reanalyze').addEventListener('click', () => {
|
||||||
|
$('fp-preview').classList.remove('visible');
|
||||||
|
$('fp-json-edit').classList.remove('visible');
|
||||||
|
$('fp-actions-main').style.display = 'flex';
|
||||||
|
$('fp-error').textContent = '';
|
||||||
|
this._doAnalyze(overlay);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleImageUpload(file, dropZone, dropLabel) {
|
||||||
|
this._imageFile = file;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
// Show image preview in the drop zone
|
||||||
|
dropLabel.innerHTML = '';
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = e.target.result;
|
||||||
|
dropLabel.appendChild(img);
|
||||||
|
const info = document.createElement('small');
|
||||||
|
info.textContent = `${file.name} (${(file.size / 1024).toFixed(0)} KB)`;
|
||||||
|
info.style.color = '#888';
|
||||||
|
dropLabel.appendChild(info);
|
||||||
|
this._updateAnalyzeButton(this._overlay);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateAnalyzeButton(overlay) {
|
||||||
|
const btn = overlay.querySelector('#fp-analyze');
|
||||||
|
const apiKey = overlay.querySelector('#fp-api-key').value.trim();
|
||||||
|
btn.disabled = !this._imageFile || !apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
async _preprocessImage(file) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const maxDim = 2048;
|
||||||
|
let { width, height } = img;
|
||||||
|
if (width > maxDim || height > maxDim) {
|
||||||
|
const scale = maxDim / Math.max(width, height);
|
||||||
|
width = Math.round(width * scale);
|
||||||
|
height = Math.round(height * scale);
|
||||||
|
}
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx.drawImage(img, 0, 0, width, height);
|
||||||
|
|
||||||
|
const mediaType = file.type === 'image/png' ? 'image/png' : 'image/jpeg';
|
||||||
|
const quality = mediaType === 'image/jpeg' ? 0.9 : undefined;
|
||||||
|
const base64 = canvas.toDataURL(mediaType, quality).split(',')[1];
|
||||||
|
resolve({ base64, mediaType, width, height });
|
||||||
|
};
|
||||||
|
img.src = URL.createObjectURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildPrompt(name, floorCount, scaleHint) {
|
||||||
|
let prompt = `Analyze this floor plan image. The building is named "${name}".\n`;
|
||||||
|
if (scaleHint) {
|
||||||
|
prompt += `Scale reference: ${scaleHint}\n`;
|
||||||
|
} else {
|
||||||
|
prompt += `Estimate dimensions from standard door widths.\n`;
|
||||||
|
}
|
||||||
|
prompt += `This image shows ${floorCount} floor(s).\n\n`;
|
||||||
|
prompt += `Output the house JSON with this structure:
|
||||||
|
{
|
||||||
|
"name": "...",
|
||||||
|
"description": "...",
|
||||||
|
"units": "meters",
|
||||||
|
"building": {
|
||||||
|
"footprint": { "width": <number>, "depth": <number> },
|
||||||
|
"wallThickness": 0.24,
|
||||||
|
"roofType": "gable"
|
||||||
|
},
|
||||||
|
"floors": [
|
||||||
|
{
|
||||||
|
"id": "eg",
|
||||||
|
"name": "...",
|
||||||
|
"nameEN": "...",
|
||||||
|
"level": 0,
|
||||||
|
"ceilingHeight": 2.6,
|
||||||
|
"rooms": [
|
||||||
|
{
|
||||||
|
"id": "eg-room-slug",
|
||||||
|
"name": "...",
|
||||||
|
"nameEN": "...",
|
||||||
|
"type": "living|kitchen|...",
|
||||||
|
"position": { "x": <meters>, "y": <meters> },
|
||||||
|
"dimensions": { "width": <meters>, "length": <meters> },
|
||||||
|
"flooring": "tile|hardwood",
|
||||||
|
"walls": {
|
||||||
|
"south": { "type": "exterior|interior", "doors": [...], "windows": [...] },
|
||||||
|
"north": { ... },
|
||||||
|
"east": { ... },
|
||||||
|
"west": { ... }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`;
|
||||||
|
return prompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
async _analyzeWithLLM(base64Image, mediaType, overlay) {
|
||||||
|
const provider = overlay.querySelector('#fp-provider').value;
|
||||||
|
const apiKey = overlay.querySelector('#fp-api-key').value.trim();
|
||||||
|
const name = overlay.querySelector('#fp-name').value.trim() || 'Imported Floor Plan';
|
||||||
|
const floorCount = parseInt(overlay.querySelector('#fp-floors').value);
|
||||||
|
const scaleHint = overlay.querySelector('#fp-scale').value.trim();
|
||||||
|
|
||||||
|
const providerConfig = API_PROVIDERS[provider];
|
||||||
|
const userPrompt = this._buildPrompt(name, floorCount, scaleHint);
|
||||||
|
const reqOptions = providerConfig.buildRequest(base64Image, mediaType, SYSTEM_PROMPT, userPrompt, apiKey);
|
||||||
|
|
||||||
|
const response = await fetch(providerConfig.endpoint, reqOptions);
|
||||||
|
if (!response.ok) {
|
||||||
|
const errBody = await response.text();
|
||||||
|
throw new Error(`API error (${response.status}): ${errBody}`);
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
const jsonText = providerConfig.extractJSON(data);
|
||||||
|
return jsonText;
|
||||||
|
}
|
||||||
|
|
||||||
|
async _doAnalyze(overlay) {
|
||||||
|
const $ = (id) => overlay.querySelector(`#${id}`);
|
||||||
|
const statusEl = $('fp-status');
|
||||||
|
const statusText = $('fp-status-text');
|
||||||
|
const errorEl = $('fp-error');
|
||||||
|
const analyzeBtn = $('fp-analyze');
|
||||||
|
|
||||||
|
errorEl.textContent = '';
|
||||||
|
statusEl.classList.add('visible');
|
||||||
|
statusText.textContent = 'Preprocessing image...';
|
||||||
|
analyzeBtn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { base64, mediaType } = await this._preprocessImage(this._imageFile);
|
||||||
|
|
||||||
|
statusText.textContent = 'Analyzing floor plan with AI...';
|
||||||
|
|
||||||
|
const jsonText = await this._analyzeWithLLM(base64, mediaType, overlay);
|
||||||
|
|
||||||
|
statusText.textContent = 'Parsing result...';
|
||||||
|
|
||||||
|
// Strip markdown fences if present
|
||||||
|
let cleaned = jsonText.trim();
|
||||||
|
if (cleaned.startsWith('```')) {
|
||||||
|
cleaned = cleaned.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
let houseData;
|
||||||
|
try {
|
||||||
|
houseData = JSON.parse(cleaned);
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`Failed to parse LLM response as JSON: ${e.message}\n\nRaw response:\n${jsonText.substring(0, 500)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-repair common issues
|
||||||
|
houseData = this._autoRepair(houseData);
|
||||||
|
|
||||||
|
// Validate
|
||||||
|
const { valid, errors } = this._validateHouseJSON(houseData);
|
||||||
|
if (!valid) {
|
||||||
|
console.warn('Validation warnings:', errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._resultData = houseData;
|
||||||
|
this._showPreview(houseData, overlay);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
errorEl.textContent = err.message;
|
||||||
|
} finally {
|
||||||
|
statusEl.classList.remove('visible');
|
||||||
|
analyzeBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_validateHouseJSON(data) {
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
if (!data.name) errors.push('Missing building name');
|
||||||
|
if (!data.building?.footprint) errors.push('Missing building footprint');
|
||||||
|
if (!data.floors?.length) errors.push('No floors found');
|
||||||
|
|
||||||
|
for (const floor of (data.floors || [])) {
|
||||||
|
if (!floor.rooms?.length) {
|
||||||
|
errors.push(`Floor "${floor.name}" has no rooms`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const room of floor.rooms) {
|
||||||
|
if (!room.id) errors.push('Room missing id');
|
||||||
|
if (!room.position) errors.push(`Room "${room.id}" missing position`);
|
||||||
|
if (!room.dimensions) errors.push(`Room "${room.id}" missing dimensions`);
|
||||||
|
if (!room.walls) errors.push(`Room "${room.id}" missing walls`);
|
||||||
|
|
||||||
|
for (const dir of ['north', 'south', 'east', 'west']) {
|
||||||
|
const wall = room.walls?.[dir];
|
||||||
|
if (!wall) errors.push(`Room "${room.id}" missing ${dir} wall`);
|
||||||
|
if (wall && !['exterior', 'interior'].includes(wall.type)) {
|
||||||
|
errors.push(`Room "${room.id}" ${dir} wall has invalid type "${wall.type}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: errors.length === 0, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
_autoRepair(data) {
|
||||||
|
if (!data.name) data.name = 'Imported Floor Plan';
|
||||||
|
if (!data.units) data.units = 'meters';
|
||||||
|
if (!data.building) data.building = {};
|
||||||
|
if (!data.building.footprint) {
|
||||||
|
// Compute from rooms
|
||||||
|
let maxX = 0, maxY = 0;
|
||||||
|
for (const floor of (data.floors || [])) {
|
||||||
|
for (const room of (floor.rooms || [])) {
|
||||||
|
const rx = (parseFloat(room.position?.x) || 0) + (parseFloat(room.dimensions?.width) || 0);
|
||||||
|
const ry = (parseFloat(room.position?.y) || 0) + (parseFloat(room.dimensions?.length) || 0);
|
||||||
|
maxX = Math.max(maxX, rx);
|
||||||
|
maxY = Math.max(maxY, ry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data.building.footprint = { width: maxX || 10, depth: maxY || 10 };
|
||||||
|
}
|
||||||
|
if (!data.building.wallThickness) data.building.wallThickness = 0.24;
|
||||||
|
if (!data.building.roofType) data.building.roofType = 'gable';
|
||||||
|
|
||||||
|
const tileTypes = new Set(['kitchen', 'bathroom', 'utility', 'hallway', 'laundry']);
|
||||||
|
|
||||||
|
for (const floor of (data.floors || [])) {
|
||||||
|
if (!floor.id) floor.id = `f${floor.level || 0}`;
|
||||||
|
if (!floor.name) floor.name = floor.nameEN || `Floor ${floor.level || 0}`;
|
||||||
|
if (!floor.nameEN) floor.nameEN = floor.name;
|
||||||
|
if (floor.level === undefined) floor.level = 0;
|
||||||
|
if (!floor.ceilingHeight) floor.ceilingHeight = 2.6;
|
||||||
|
|
||||||
|
for (const room of (floor.rooms || [])) {
|
||||||
|
// Fix string numbers
|
||||||
|
if (room.position) {
|
||||||
|
room.position.x = parseFloat(room.position.x) || 0;
|
||||||
|
room.position.y = parseFloat(room.position.y) || 0;
|
||||||
|
} else {
|
||||||
|
room.position = { x: 0, y: 0 };
|
||||||
|
}
|
||||||
|
if (room.dimensions) {
|
||||||
|
room.dimensions.width = parseFloat(room.dimensions.width) || 3;
|
||||||
|
room.dimensions.length = parseFloat(room.dimensions.length) || 3;
|
||||||
|
} else {
|
||||||
|
room.dimensions = { width: 3, length: 3 };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!room.id) {
|
||||||
|
const slug = (room.nameEN || room.name || 'room').toLowerCase().replace(/\s+/g, '-');
|
||||||
|
room.id = `${floor.id}-${slug}`;
|
||||||
|
}
|
||||||
|
if (!room.name) room.name = room.nameEN || room.id;
|
||||||
|
if (!room.nameEN) room.nameEN = room.name;
|
||||||
|
if (!room.type) room.type = 'living';
|
||||||
|
if (!room.flooring) {
|
||||||
|
room.flooring = tileTypes.has(room.type) ? 'tile' : 'hardwood';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure walls exist
|
||||||
|
if (!room.walls) room.walls = {};
|
||||||
|
for (const dir of ['north', 'south', 'east', 'west']) {
|
||||||
|
if (!room.walls[dir]) {
|
||||||
|
room.walls[dir] = { type: 'interior' };
|
||||||
|
}
|
||||||
|
const wall = room.walls[dir];
|
||||||
|
if (!['exterior', 'interior'].includes(wall.type)) {
|
||||||
|
wall.type = 'interior';
|
||||||
|
}
|
||||||
|
if (!wall.doors) wall.doors = [];
|
||||||
|
if (!wall.windows) wall.windows = [];
|
||||||
|
|
||||||
|
// Fix door/window numeric fields
|
||||||
|
for (const door of wall.doors) {
|
||||||
|
door.position = parseFloat(door.position) || 0;
|
||||||
|
door.width = parseFloat(door.width) || 0.9;
|
||||||
|
door.height = parseFloat(door.height) || 2.1;
|
||||||
|
}
|
||||||
|
for (const win of wall.windows) {
|
||||||
|
win.position = parseFloat(win.position) || 0;
|
||||||
|
win.width = parseFloat(win.width) || 1.2;
|
||||||
|
win.height = parseFloat(win.height) || 1.2;
|
||||||
|
if (win.sillHeight !== undefined) {
|
||||||
|
win.sillHeight = parseFloat(win.sillHeight) || 0.9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
_showPreview(houseData, overlay) {
|
||||||
|
const $ = (id) => overlay.querySelector(`#${id}`);
|
||||||
|
|
||||||
|
// Hide main actions, show preview
|
||||||
|
$('fp-actions-main').style.display = 'none';
|
||||||
|
$('fp-preview').classList.add('visible');
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
let totalRooms = 0, totalDoors = 0, totalWindows = 0;
|
||||||
|
for (const floor of houseData.floors) {
|
||||||
|
for (const room of floor.rooms) {
|
||||||
|
totalRooms++;
|
||||||
|
for (const dir of ['north', 'south', 'east', 'west']) {
|
||||||
|
totalDoors += (room.walls[dir]?.doors?.length || 0);
|
||||||
|
totalWindows += (room.walls[dir]?.windows?.length || 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$('fp-summary').textContent =
|
||||||
|
`Found: ${totalRooms} rooms, ${totalDoors} doors, ${totalWindows} windows across ${houseData.floors.length} floor(s)`;
|
||||||
|
|
||||||
|
// Room list
|
||||||
|
const roomList = $('fp-room-list');
|
||||||
|
roomList.innerHTML = '';
|
||||||
|
for (const floor of houseData.floors) {
|
||||||
|
for (const room of floor.rooms) {
|
||||||
|
const w = room.dimensions.width;
|
||||||
|
const l = room.dimensions.length;
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'fp-room-item';
|
||||||
|
item.innerHTML = `<span>${room.nameEN || room.name}</span><span class="fp-room-dims">${w.toFixed(1)} x ${l.toFixed(1)}m</span>`;
|
||||||
|
roomList.appendChild(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_applyToRenderer(houseData) {
|
||||||
|
this.renderer.houseData = houseData;
|
||||||
|
this.renderer.currentFloor = 0;
|
||||||
|
this.renderer._clearFloor();
|
||||||
|
|
||||||
|
const floor = houseData.floors[0];
|
||||||
|
for (const room of floor.rooms) {
|
||||||
|
this.renderer._renderRoom(room, floor.ceilingHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch event for UI to rebuild
|
||||||
|
this.renderer.container.dispatchEvent(new CustomEvent('houseloaded', {
|
||||||
|
detail: { name: houseData.name, floors: houseData.floors.length }
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (this.onHouseLoaded) {
|
||||||
|
this.onHouseLoaded(houseData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
681
src/house-editor.js
Normal file
681
src/house-editor.js
Normal file
@@ -0,0 +1,681 @@
|
|||||||
|
/**
|
||||||
|
* HouseEditor — UI panel for house customization.
|
||||||
|
*
|
||||||
|
* Provides controls to create/edit houses, add/remove rooms,
|
||||||
|
* adjust dimensions, manage floors, and save as templates.
|
||||||
|
*/
|
||||||
|
export class HouseEditor {
|
||||||
|
constructor(container, { renderer, onHouseChanged }) {
|
||||||
|
this.container = container;
|
||||||
|
this.renderer = renderer;
|
||||||
|
this.onHouseChanged = onHouseChanged || (() => {});
|
||||||
|
this._editing = false;
|
||||||
|
this._selectedRoomId = null;
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
get houseData() {
|
||||||
|
return this.renderer.houseData;
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
this.container.innerHTML = '';
|
||||||
|
|
||||||
|
if (!this.houseData) {
|
||||||
|
this.container.innerHTML = '<p style="color:#999;font-size:12px;">No house loaded</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle button
|
||||||
|
const toggleBtn = document.createElement('button');
|
||||||
|
toggleBtn.className = 'he-toggle-btn';
|
||||||
|
toggleBtn.textContent = this._editing ? 'Close Editor' : 'Edit House';
|
||||||
|
toggleBtn.addEventListener('click', () => {
|
||||||
|
this._editing = !this._editing;
|
||||||
|
this.render();
|
||||||
|
});
|
||||||
|
this.container.appendChild(toggleBtn);
|
||||||
|
|
||||||
|
if (!this._editing) return;
|
||||||
|
|
||||||
|
// House metadata section
|
||||||
|
this._renderMetadataSection();
|
||||||
|
|
||||||
|
// Building section
|
||||||
|
this._renderBuildingSection();
|
||||||
|
|
||||||
|
// Floor management
|
||||||
|
this._renderFloorSection();
|
||||||
|
|
||||||
|
// Room list with editing
|
||||||
|
this._renderRoomSection();
|
||||||
|
|
||||||
|
// Room editor (if a room is selected)
|
||||||
|
if (this._selectedRoomId) {
|
||||||
|
this._renderRoomEditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save as template
|
||||||
|
this._renderSaveSection();
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedRoom(roomId) {
|
||||||
|
this._selectedRoomId = roomId;
|
||||||
|
if (this._editing) {
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Metadata Section ----
|
||||||
|
|
||||||
|
_renderMetadataSection() {
|
||||||
|
const section = this._createSection('House Info');
|
||||||
|
|
||||||
|
const nameRow = this._createFieldRow('Name');
|
||||||
|
const nameInput = document.createElement('input');
|
||||||
|
nameInput.className = 'he-input';
|
||||||
|
nameInput.value = this.houseData.name || '';
|
||||||
|
nameInput.addEventListener('change', () => {
|
||||||
|
this.houseData.name = nameInput.value;
|
||||||
|
this.onHouseChanged('name');
|
||||||
|
});
|
||||||
|
nameRow.appendChild(nameInput);
|
||||||
|
section.appendChild(nameRow);
|
||||||
|
|
||||||
|
const descRow = this._createFieldRow('Description');
|
||||||
|
const descInput = document.createElement('input');
|
||||||
|
descInput.className = 'he-input';
|
||||||
|
descInput.value = this.houseData.description || '';
|
||||||
|
descInput.addEventListener('change', () => {
|
||||||
|
this.houseData.description = descInput.value;
|
||||||
|
this.onHouseChanged('description');
|
||||||
|
});
|
||||||
|
descRow.appendChild(descInput);
|
||||||
|
section.appendChild(descRow);
|
||||||
|
|
||||||
|
this.container.appendChild(section);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Building Section ----
|
||||||
|
|
||||||
|
_renderBuildingSection() {
|
||||||
|
const section = this._createSection('Building');
|
||||||
|
const building = this.houseData.building || {};
|
||||||
|
const footprint = building.footprint || {};
|
||||||
|
|
||||||
|
const widthRow = this._createFieldRow('Width (m)');
|
||||||
|
const widthInput = this._createNumberInput(footprint.width || 12, 4, 30, 0.5, (val) => {
|
||||||
|
if (!this.houseData.building) this.houseData.building = {};
|
||||||
|
if (!this.houseData.building.footprint) this.houseData.building.footprint = {};
|
||||||
|
this.houseData.building.footprint.width = val;
|
||||||
|
this.onHouseChanged('building');
|
||||||
|
});
|
||||||
|
widthRow.appendChild(widthInput);
|
||||||
|
section.appendChild(widthRow);
|
||||||
|
|
||||||
|
const depthRow = this._createFieldRow('Depth (m)');
|
||||||
|
const depthInput = this._createNumberInput(footprint.depth || 10, 4, 30, 0.5, (val) => {
|
||||||
|
if (!this.houseData.building) this.houseData.building = {};
|
||||||
|
if (!this.houseData.building.footprint) this.houseData.building.footprint = {};
|
||||||
|
this.houseData.building.footprint.depth = val;
|
||||||
|
this.onHouseChanged('building');
|
||||||
|
});
|
||||||
|
depthRow.appendChild(depthInput);
|
||||||
|
section.appendChild(depthRow);
|
||||||
|
|
||||||
|
this.container.appendChild(section);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Floor Section ----
|
||||||
|
|
||||||
|
_renderFloorSection() {
|
||||||
|
const section = this._createSection('Floors');
|
||||||
|
|
||||||
|
const floors = this.houseData.floors || [];
|
||||||
|
for (let i = 0; i < floors.length; i++) {
|
||||||
|
const floor = floors[i];
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'he-floor-row';
|
||||||
|
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.className = 'he-floor-label';
|
||||||
|
label.textContent = `${floor.name} (${floor.rooms.length} rooms)`;
|
||||||
|
row.appendChild(label);
|
||||||
|
|
||||||
|
// Ceiling height
|
||||||
|
const heightInput = this._createNumberInput(floor.ceilingHeight || 2.5, 2.2, 4.0, 0.1, (val) => {
|
||||||
|
floor.ceilingHeight = val;
|
||||||
|
this._rebuildFloor();
|
||||||
|
});
|
||||||
|
heightInput.title = 'Ceiling height';
|
||||||
|
heightInput.style.width = '55px';
|
||||||
|
row.appendChild(heightInput);
|
||||||
|
|
||||||
|
// Remove floor button (only if more than 1 floor)
|
||||||
|
if (floors.length > 1) {
|
||||||
|
const removeBtn = document.createElement('button');
|
||||||
|
removeBtn.className = 'he-icon-btn he-icon-btn-danger';
|
||||||
|
removeBtn.textContent = '\u00d7';
|
||||||
|
removeBtn.title = 'Remove floor';
|
||||||
|
removeBtn.addEventListener('click', () => this._removeFloor(i));
|
||||||
|
row.appendChild(removeBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
section.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
const addBtn = document.createElement('button');
|
||||||
|
addBtn.className = 'he-add-btn';
|
||||||
|
addBtn.textContent = '+ Add Floor';
|
||||||
|
addBtn.addEventListener('click', () => this._addFloor());
|
||||||
|
section.appendChild(addBtn);
|
||||||
|
|
||||||
|
this.container.appendChild(section);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Room Section ----
|
||||||
|
|
||||||
|
_renderRoomSection() {
|
||||||
|
const section = this._createSection('Rooms');
|
||||||
|
|
||||||
|
const floor = this.houseData.floors[this.renderer.currentFloor];
|
||||||
|
if (!floor) return;
|
||||||
|
|
||||||
|
for (const room of floor.rooms) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'he-room-row' + (this._selectedRoomId === room.id ? ' active' : '');
|
||||||
|
|
||||||
|
const info = document.createElement('span');
|
||||||
|
info.className = 'he-room-info';
|
||||||
|
info.textContent = `${room.name} (${room.dimensions.width}\u00d7${room.dimensions.length}m)`;
|
||||||
|
info.addEventListener('click', () => {
|
||||||
|
this._selectedRoomId = room.id;
|
||||||
|
this.renderer.focusRoom(room.id);
|
||||||
|
this.render();
|
||||||
|
});
|
||||||
|
row.appendChild(info);
|
||||||
|
|
||||||
|
const removeBtn = document.createElement('button');
|
||||||
|
removeBtn.className = 'he-icon-btn he-icon-btn-danger';
|
||||||
|
removeBtn.textContent = '\u00d7';
|
||||||
|
removeBtn.title = 'Remove room';
|
||||||
|
removeBtn.addEventListener('click', () => this._removeRoom(room.id));
|
||||||
|
row.appendChild(removeBtn);
|
||||||
|
|
||||||
|
section.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
const addBtn = document.createElement('button');
|
||||||
|
addBtn.className = 'he-add-btn';
|
||||||
|
addBtn.textContent = '+ Add Room';
|
||||||
|
addBtn.addEventListener('click', () => this._showAddRoomForm(section, addBtn));
|
||||||
|
section.appendChild(addBtn);
|
||||||
|
|
||||||
|
this.container.appendChild(section);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Room Editor ----
|
||||||
|
|
||||||
|
_renderRoomEditor() {
|
||||||
|
const floor = this.houseData.floors[this.renderer.currentFloor];
|
||||||
|
if (!floor) return;
|
||||||
|
const room = floor.rooms.find(r => r.id === this._selectedRoomId);
|
||||||
|
if (!room) return;
|
||||||
|
|
||||||
|
const section = this._createSection(`Edit: ${room.name}`);
|
||||||
|
|
||||||
|
// Name
|
||||||
|
const nameRow = this._createFieldRow('Name');
|
||||||
|
const nameInput = document.createElement('input');
|
||||||
|
nameInput.className = 'he-input';
|
||||||
|
nameInput.value = room.name;
|
||||||
|
nameInput.addEventListener('change', () => {
|
||||||
|
room.name = nameInput.value;
|
||||||
|
this._rebuildFloor();
|
||||||
|
});
|
||||||
|
nameRow.appendChild(nameInput);
|
||||||
|
section.appendChild(nameRow);
|
||||||
|
|
||||||
|
// English name
|
||||||
|
const nameEnRow = this._createFieldRow('Name (EN)');
|
||||||
|
const nameEnInput = document.createElement('input');
|
||||||
|
nameEnInput.className = 'he-input';
|
||||||
|
nameEnInput.value = room.nameEN || '';
|
||||||
|
nameEnInput.addEventListener('change', () => {
|
||||||
|
room.nameEN = nameEnInput.value;
|
||||||
|
this._rebuildFloor();
|
||||||
|
});
|
||||||
|
nameEnRow.appendChild(nameEnInput);
|
||||||
|
section.appendChild(nameEnRow);
|
||||||
|
|
||||||
|
// Type
|
||||||
|
const typeRow = this._createFieldRow('Type');
|
||||||
|
const typeSelect = document.createElement('select');
|
||||||
|
typeSelect.className = 'he-input';
|
||||||
|
const roomTypes = [
|
||||||
|
'living', 'kitchen', 'dining', 'bedroom', 'bathroom',
|
||||||
|
'hallway', 'office', 'utility', 'storage', 'garage'
|
||||||
|
];
|
||||||
|
for (const t of roomTypes) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = t;
|
||||||
|
opt.textContent = t.charAt(0).toUpperCase() + t.slice(1);
|
||||||
|
if (room.type === t) opt.selected = true;
|
||||||
|
typeSelect.appendChild(opt);
|
||||||
|
}
|
||||||
|
typeSelect.addEventListener('change', () => {
|
||||||
|
room.type = typeSelect.value;
|
||||||
|
});
|
||||||
|
typeRow.appendChild(typeSelect);
|
||||||
|
section.appendChild(typeRow);
|
||||||
|
|
||||||
|
// Dimensions
|
||||||
|
const dimsLabel = document.createElement('div');
|
||||||
|
dimsLabel.className = 'he-field-label';
|
||||||
|
dimsLabel.textContent = 'Dimensions';
|
||||||
|
section.appendChild(dimsLabel);
|
||||||
|
|
||||||
|
const dimsRow = document.createElement('div');
|
||||||
|
dimsRow.className = 'he-dims-row';
|
||||||
|
|
||||||
|
const wLabel = document.createElement('label');
|
||||||
|
wLabel.className = 'he-dim-label';
|
||||||
|
wLabel.textContent = 'W';
|
||||||
|
dimsRow.appendChild(wLabel);
|
||||||
|
const wInput = this._createNumberInput(room.dimensions.width, 1, 15, 0.25, (val) => {
|
||||||
|
room.dimensions.width = val;
|
||||||
|
this._rebuildFloor();
|
||||||
|
});
|
||||||
|
dimsRow.appendChild(wInput);
|
||||||
|
|
||||||
|
const lLabel = document.createElement('label');
|
||||||
|
lLabel.className = 'he-dim-label';
|
||||||
|
lLabel.textContent = 'L';
|
||||||
|
dimsRow.appendChild(lLabel);
|
||||||
|
const lInput = this._createNumberInput(room.dimensions.length, 1, 15, 0.25, (val) => {
|
||||||
|
room.dimensions.length = val;
|
||||||
|
this._rebuildFloor();
|
||||||
|
});
|
||||||
|
dimsRow.appendChild(lInput);
|
||||||
|
|
||||||
|
section.appendChild(dimsRow);
|
||||||
|
|
||||||
|
// Position
|
||||||
|
const posLabel = document.createElement('div');
|
||||||
|
posLabel.className = 'he-field-label';
|
||||||
|
posLabel.textContent = 'Position';
|
||||||
|
section.appendChild(posLabel);
|
||||||
|
|
||||||
|
const posRow = document.createElement('div');
|
||||||
|
posRow.className = 'he-dims-row';
|
||||||
|
|
||||||
|
const xLabel = document.createElement('label');
|
||||||
|
xLabel.className = 'he-dim-label';
|
||||||
|
xLabel.textContent = 'X';
|
||||||
|
posRow.appendChild(xLabel);
|
||||||
|
const xInput = this._createNumberInput(room.position.x, 0, 30, 0.25, (val) => {
|
||||||
|
room.position.x = val;
|
||||||
|
this._rebuildFloor();
|
||||||
|
});
|
||||||
|
posRow.appendChild(xInput);
|
||||||
|
|
||||||
|
const yLabel = document.createElement('label');
|
||||||
|
yLabel.className = 'he-dim-label';
|
||||||
|
yLabel.textContent = 'Y';
|
||||||
|
posRow.appendChild(yLabel);
|
||||||
|
const yInput = this._createNumberInput(room.position.y, 0, 30, 0.25, (val) => {
|
||||||
|
room.position.y = val;
|
||||||
|
this._rebuildFloor();
|
||||||
|
});
|
||||||
|
posRow.appendChild(yInput);
|
||||||
|
|
||||||
|
section.appendChild(posRow);
|
||||||
|
|
||||||
|
// Flooring
|
||||||
|
const flooringRow = this._createFieldRow('Flooring');
|
||||||
|
const flooringSelect = document.createElement('select');
|
||||||
|
flooringSelect.className = 'he-input';
|
||||||
|
for (const f of ['hardwood', 'tile']) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = f;
|
||||||
|
opt.textContent = f.charAt(0).toUpperCase() + f.slice(1);
|
||||||
|
if (room.flooring === f) opt.selected = true;
|
||||||
|
flooringSelect.appendChild(opt);
|
||||||
|
}
|
||||||
|
flooringSelect.addEventListener('change', () => {
|
||||||
|
room.flooring = flooringSelect.value;
|
||||||
|
this._rebuildFloor();
|
||||||
|
});
|
||||||
|
flooringRow.appendChild(flooringSelect);
|
||||||
|
section.appendChild(flooringRow);
|
||||||
|
|
||||||
|
this.container.appendChild(section);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Save Section ----
|
||||||
|
|
||||||
|
_renderSaveSection() {
|
||||||
|
const section = this._createSection('Template');
|
||||||
|
|
||||||
|
const saveBtn = document.createElement('button');
|
||||||
|
saveBtn.className = 'he-save-btn';
|
||||||
|
saveBtn.textContent = 'Save as House Template';
|
||||||
|
saveBtn.addEventListener('click', () => this._saveAsTemplate());
|
||||||
|
section.appendChild(saveBtn);
|
||||||
|
|
||||||
|
const newBtn = document.createElement('button');
|
||||||
|
newBtn.className = 'he-add-btn';
|
||||||
|
newBtn.style.marginTop = '6px';
|
||||||
|
newBtn.textContent = 'New Empty House';
|
||||||
|
newBtn.addEventListener('click', () => this._createNewHouse());
|
||||||
|
section.appendChild(newBtn);
|
||||||
|
|
||||||
|
this.container.appendChild(section);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Actions ----
|
||||||
|
|
||||||
|
_addFloor() {
|
||||||
|
const floors = this.houseData.floors;
|
||||||
|
const level = floors.length;
|
||||||
|
const id = `floor-${level}`;
|
||||||
|
floors.push({
|
||||||
|
id,
|
||||||
|
name: `Floor ${level}`,
|
||||||
|
nameEN: `Floor ${level}`,
|
||||||
|
level,
|
||||||
|
ceilingHeight: 2.5,
|
||||||
|
rooms: []
|
||||||
|
});
|
||||||
|
this.onHouseChanged('floors');
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
_removeFloor(index) {
|
||||||
|
this.houseData.floors.splice(index, 1);
|
||||||
|
// Re-index levels
|
||||||
|
this.houseData.floors.forEach((f, i) => { f.level = i; });
|
||||||
|
// If current floor was removed, switch to last available
|
||||||
|
if (this.renderer.currentFloor >= this.houseData.floors.length) {
|
||||||
|
this.renderer.showFloor(this.houseData.floors.length - 1);
|
||||||
|
} else {
|
||||||
|
this._rebuildFloor();
|
||||||
|
}
|
||||||
|
this.onHouseChanged('floors');
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
_showAddRoomForm(section, addBtn) {
|
||||||
|
// Replace button with form
|
||||||
|
addBtn.style.display = 'none';
|
||||||
|
|
||||||
|
const form = document.createElement('div');
|
||||||
|
form.className = 'he-add-room-form';
|
||||||
|
|
||||||
|
const nameInput = document.createElement('input');
|
||||||
|
nameInput.className = 'he-input';
|
||||||
|
nameInput.placeholder = 'Room name';
|
||||||
|
form.appendChild(nameInput);
|
||||||
|
|
||||||
|
const typeSelect = document.createElement('select');
|
||||||
|
typeSelect.className = 'he-input';
|
||||||
|
const roomTypes = [
|
||||||
|
'living', 'kitchen', 'dining', 'bedroom', 'bathroom',
|
||||||
|
'hallway', 'office', 'utility', 'storage', 'garage'
|
||||||
|
];
|
||||||
|
for (const t of roomTypes) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = t;
|
||||||
|
opt.textContent = t.charAt(0).toUpperCase() + t.slice(1);
|
||||||
|
typeSelect.appendChild(opt);
|
||||||
|
}
|
||||||
|
form.appendChild(typeSelect);
|
||||||
|
|
||||||
|
const dimsRow = document.createElement('div');
|
||||||
|
dimsRow.className = 'he-dims-row';
|
||||||
|
const wInput = document.createElement('input');
|
||||||
|
wInput.className = 'he-input';
|
||||||
|
wInput.type = 'number';
|
||||||
|
wInput.value = '4';
|
||||||
|
wInput.min = '1';
|
||||||
|
wInput.max = '15';
|
||||||
|
wInput.step = '0.25';
|
||||||
|
wInput.placeholder = 'Width';
|
||||||
|
wInput.style.flex = '1';
|
||||||
|
dimsRow.appendChild(wInput);
|
||||||
|
|
||||||
|
const xSpan = document.createElement('span');
|
||||||
|
xSpan.textContent = '\u00d7';
|
||||||
|
xSpan.style.padding = '0 4px';
|
||||||
|
xSpan.style.color = '#999';
|
||||||
|
dimsRow.appendChild(xSpan);
|
||||||
|
|
||||||
|
const lInput = document.createElement('input');
|
||||||
|
lInput.className = 'he-input';
|
||||||
|
lInput.type = 'number';
|
||||||
|
lInput.value = '3';
|
||||||
|
lInput.min = '1';
|
||||||
|
lInput.max = '15';
|
||||||
|
lInput.step = '0.25';
|
||||||
|
lInput.placeholder = 'Length';
|
||||||
|
lInput.style.flex = '1';
|
||||||
|
dimsRow.appendChild(lInput);
|
||||||
|
form.appendChild(dimsRow);
|
||||||
|
|
||||||
|
const actions = document.createElement('div');
|
||||||
|
actions.className = 'he-form-actions';
|
||||||
|
|
||||||
|
const cancelBtn = document.createElement('button');
|
||||||
|
cancelBtn.className = 'he-cancel-btn';
|
||||||
|
cancelBtn.textContent = 'Cancel';
|
||||||
|
cancelBtn.addEventListener('click', () => {
|
||||||
|
form.remove();
|
||||||
|
addBtn.style.display = '';
|
||||||
|
});
|
||||||
|
actions.appendChild(cancelBtn);
|
||||||
|
|
||||||
|
const submitBtn = document.createElement('button');
|
||||||
|
submitBtn.className = 'he-submit-btn';
|
||||||
|
submitBtn.textContent = 'Add';
|
||||||
|
submitBtn.addEventListener('click', () => {
|
||||||
|
const name = nameInput.value.trim();
|
||||||
|
if (!name) { nameInput.focus(); return; }
|
||||||
|
this._addRoom({
|
||||||
|
name,
|
||||||
|
type: typeSelect.value,
|
||||||
|
width: parseFloat(wInput.value) || 4,
|
||||||
|
length: parseFloat(lInput.value) || 3
|
||||||
|
});
|
||||||
|
});
|
||||||
|
actions.appendChild(submitBtn);
|
||||||
|
form.appendChild(actions);
|
||||||
|
|
||||||
|
section.appendChild(form);
|
||||||
|
nameInput.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
_addRoom({ name, type, width, length }) {
|
||||||
|
const floor = this.houseData.floors[this.renderer.currentFloor];
|
||||||
|
if (!floor) return;
|
||||||
|
|
||||||
|
// Auto-position: find rightmost edge of existing rooms
|
||||||
|
let maxX = 0;
|
||||||
|
for (const r of floor.rooms) {
|
||||||
|
const edge = r.position.x + r.dimensions.width;
|
||||||
|
if (edge > maxX) maxX = edge;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = `${floor.id}-${name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${Date.now().toString(36).slice(-4)}`;
|
||||||
|
const room = {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
nameEN: name,
|
||||||
|
type,
|
||||||
|
position: { x: maxX + 0.24, y: 0 },
|
||||||
|
dimensions: { width, length },
|
||||||
|
flooring: (type === 'bathroom' || type === 'kitchen' || type === 'utility') ? 'tile' : 'hardwood',
|
||||||
|
walls: {
|
||||||
|
south: { type: 'exterior' },
|
||||||
|
north: { type: 'exterior' },
|
||||||
|
west: { type: 'interior' },
|
||||||
|
east: { type: 'exterior' }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
floor.rooms.push(room);
|
||||||
|
this._rebuildFloor();
|
||||||
|
this._selectedRoomId = room.id;
|
||||||
|
this.onHouseChanged('rooms');
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
_removeRoom(roomId) {
|
||||||
|
const floor = this.houseData.floors[this.renderer.currentFloor];
|
||||||
|
if (!floor) return;
|
||||||
|
|
||||||
|
const idx = floor.rooms.findIndex(r => r.id === roomId);
|
||||||
|
if (idx === -1) return;
|
||||||
|
|
||||||
|
floor.rooms.splice(idx, 1);
|
||||||
|
|
||||||
|
if (this._selectedRoomId === roomId) {
|
||||||
|
this._selectedRoomId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._rebuildFloor();
|
||||||
|
this.onHouseChanged('rooms');
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
_saveAsTemplate() {
|
||||||
|
const data = structuredClone(this.houseData);
|
||||||
|
data.savedAt = new Date().toISOString();
|
||||||
|
const json = JSON.stringify(data, null, 2);
|
||||||
|
const blob = new Blob([json], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${(data.name || 'house').replace(/\s+/g, '-').toLowerCase()}.json`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
_createNewHouse() {
|
||||||
|
const newHouse = {
|
||||||
|
name: 'New House',
|
||||||
|
description: '',
|
||||||
|
units: 'meters',
|
||||||
|
building: {
|
||||||
|
footprint: { width: 10, depth: 8 },
|
||||||
|
wallThickness: 0.24,
|
||||||
|
roofType: 'gable'
|
||||||
|
},
|
||||||
|
floors: [
|
||||||
|
{
|
||||||
|
id: 'floor-0',
|
||||||
|
name: 'Ground Floor',
|
||||||
|
nameEN: 'Ground Floor',
|
||||||
|
level: 0,
|
||||||
|
ceilingHeight: 2.6,
|
||||||
|
rooms: [
|
||||||
|
{
|
||||||
|
id: 'floor-0-hallway',
|
||||||
|
name: 'Hallway',
|
||||||
|
nameEN: 'Hallway',
|
||||||
|
type: 'hallway',
|
||||||
|
position: { x: 3.5, y: 0 },
|
||||||
|
dimensions: { width: 2.0, length: 8.0 },
|
||||||
|
flooring: 'tile',
|
||||||
|
walls: {
|
||||||
|
south: { type: 'exterior', doors: [{ id: 'entry', type: 'entry', position: 0.3, width: 1.1, height: 2.2, connectsTo: 'exterior' }] },
|
||||||
|
north: { type: 'exterior' },
|
||||||
|
west: { type: 'interior' },
|
||||||
|
east: { type: 'interior' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'floor-0-living',
|
||||||
|
name: 'Living Room',
|
||||||
|
nameEN: 'Living Room',
|
||||||
|
type: 'living',
|
||||||
|
position: { x: 0, y: 3.0 },
|
||||||
|
dimensions: { width: 3.5, length: 5.0 },
|
||||||
|
flooring: 'hardwood',
|
||||||
|
walls: {
|
||||||
|
south: { type: 'interior' },
|
||||||
|
north: { type: 'exterior', windows: [{ id: 'lr-w1', type: 'casement', position: 0.5, width: 1.4, height: 1.4, sillHeight: 0.6 }] },
|
||||||
|
west: { type: 'exterior', windows: [{ id: 'lr-w2', type: 'casement', position: 1.5, width: 1.2, height: 1.4, sillHeight: 0.6 }] },
|
||||||
|
east: { type: 'interior' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'floor-0-kitchen',
|
||||||
|
name: 'Kitchen',
|
||||||
|
nameEN: 'Kitchen',
|
||||||
|
type: 'kitchen',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
dimensions: { width: 3.5, length: 3.0 },
|
||||||
|
flooring: 'tile',
|
||||||
|
walls: {
|
||||||
|
south: { type: 'exterior', windows: [{ id: 'k-w1', type: 'casement', position: 1.0, width: 1.2, height: 1.2, sillHeight: 0.9 }] },
|
||||||
|
north: { type: 'interior' },
|
||||||
|
west: { type: 'exterior' },
|
||||||
|
east: { type: 'interior' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
this.renderer.houseData = newHouse;
|
||||||
|
this.renderer.showFloor(0);
|
||||||
|
this._selectedRoomId = null;
|
||||||
|
this.onHouseChanged('new');
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
_rebuildFloor() {
|
||||||
|
this.renderer.showFloor(this.renderer.currentFloor);
|
||||||
|
this.onHouseChanged('rebuild');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- UI Helpers ----
|
||||||
|
|
||||||
|
_createSection(title) {
|
||||||
|
const section = document.createElement('div');
|
||||||
|
section.className = 'he-section';
|
||||||
|
const h = document.createElement('div');
|
||||||
|
h.className = 'he-section-title';
|
||||||
|
h.textContent = title;
|
||||||
|
section.appendChild(h);
|
||||||
|
return section;
|
||||||
|
}
|
||||||
|
|
||||||
|
_createFieldRow(label) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'he-field-row';
|
||||||
|
const lbl = document.createElement('label');
|
||||||
|
lbl.className = 'he-field-label';
|
||||||
|
lbl.textContent = label;
|
||||||
|
row.appendChild(lbl);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
_createNumberInput(value, min, max, step, onChange) {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.className = 'he-input he-num-input';
|
||||||
|
input.type = 'number';
|
||||||
|
input.value = value;
|
||||||
|
input.min = min;
|
||||||
|
input.max = max;
|
||||||
|
input.step = step;
|
||||||
|
input.addEventListener('change', () => {
|
||||||
|
const val = parseFloat(input.value);
|
||||||
|
if (!isNaN(val) && val >= min && val <= max) {
|
||||||
|
onChange(val);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
}
|
||||||
287
src/index.html
287
src/index.html
@@ -14,7 +14,7 @@
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
width: 280px;
|
width: 300px;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
background: rgba(255, 255, 255, 0.95);
|
background: rgba(255, 255, 255, 0.95);
|
||||||
border-left: 1px solid #ddd;
|
border-left: 1px solid #ddd;
|
||||||
@@ -207,6 +207,89 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Source tabs */
|
||||||
|
.catalog-source-tabs {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 12px 6px;
|
||||||
|
gap: 4px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
.catalog-source-btn {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.catalog-source-btn.active {
|
||||||
|
background: #4a90d9;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #4a90d9;
|
||||||
|
}
|
||||||
|
.catalog-count {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #999;
|
||||||
|
background: #f0f0f0;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Series filter */
|
||||||
|
.catalog-series {
|
||||||
|
padding: 4px 12px 6px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
.catalog-series-label {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #888;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
margin-right: 2px;
|
||||||
|
}
|
||||||
|
.catalog-series-btn {
|
||||||
|
padding: 2px 6px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
.catalog-series-btn.active {
|
||||||
|
background: #0058a3;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #0058a3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* IKEA badge */
|
||||||
|
.catalog-item-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 8px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: #0058a3;
|
||||||
|
color: #ffda1a;
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
margin-right: 4px;
|
||||||
|
vertical-align: middle;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.catalog-item-series {
|
||||||
|
font-size: 9px;
|
||||||
|
color: #0058a3;
|
||||||
|
margin-left: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
/* Custom furniture creator */
|
/* Custom furniture creator */
|
||||||
.catalog-create-btn {
|
.catalog-create-btn {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -321,6 +404,169 @@
|
|||||||
}
|
}
|
||||||
.export-btn:hover { background: #f0f0f0; }
|
.export-btn:hover { background: #f0f0f0; }
|
||||||
.export-btn:active { background: #e0e0e0; }
|
.export-btn:active { background: #e0e0e0; }
|
||||||
|
|
||||||
|
/* House Editor */
|
||||||
|
#house-editor {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
.he-toggle-btn {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 7px;
|
||||||
|
border: 1px solid #4a90d9;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #fff;
|
||||||
|
color: #4a90d9;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.he-toggle-btn:hover { background: #e8f0fe; }
|
||||||
|
.he-section {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
}
|
||||||
|
.he-section-title {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #666;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.he-field-row {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
.he-field-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #888;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.he-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 4px 7px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 12px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.he-input:focus { border-color: #4a90d9; }
|
||||||
|
.he-num-input { width: 65px; }
|
||||||
|
.he-dims-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.he-dim-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #888;
|
||||||
|
min-width: 12px;
|
||||||
|
}
|
||||||
|
.he-dims-row .he-input { flex: 1; min-width: 0; }
|
||||||
|
.he-floor-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
.he-floor-label {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.he-room-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 6px;
|
||||||
|
margin: 1px 0;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.he-room-row:hover { background: #f0f4fa; }
|
||||||
|
.he-room-row.active { background: #e0eaf5; }
|
||||||
|
.he-room-info {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.he-icon-btn {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.he-icon-btn-danger { color: #c44; }
|
||||||
|
.he-icon-btn-danger:hover { background: #fdd; }
|
||||||
|
.he-add-btn {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 5px;
|
||||||
|
margin-top: 4px;
|
||||||
|
border: 1px dashed #aaa;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: transparent;
|
||||||
|
color: #666;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.he-add-btn:hover { background: #f5f5f5; border-color: #4a90d9; color: #4a90d9; }
|
||||||
|
.he-add-room-form {
|
||||||
|
margin-top: 6px;
|
||||||
|
padding: 8px;
|
||||||
|
background: #f9f9f9;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.he-add-room-form .he-input { margin-bottom: 5px; }
|
||||||
|
.he-form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.he-cancel-btn, .he-submit-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 5px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.he-cancel-btn { background: #fff; }
|
||||||
|
.he-cancel-btn:hover { background: #f0f0f0; }
|
||||||
|
.he-submit-btn { background: #4a90d9; color: #fff; border-color: #4a90d9; }
|
||||||
|
.he-submit-btn:hover { background: #3a7bc8; }
|
||||||
|
.he-save-btn {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 7px;
|
||||||
|
border: 1px solid #4a90d9;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #4a90d9;
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.he-save-btn:hover { background: #3a7bc8; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -341,7 +587,9 @@
|
|||||||
<button class="export-btn" id="btn-save">Save JSON</button>
|
<button class="export-btn" id="btn-save">Save JSON</button>
|
||||||
<button class="export-btn" id="btn-load">Load JSON</button>
|
<button class="export-btn" id="btn-load">Load JSON</button>
|
||||||
<button class="export-btn" id="btn-screenshot">Screenshot</button>
|
<button class="export-btn" id="btn-screenshot">Screenshot</button>
|
||||||
|
<button class="export-btn" id="btn-import-floorplan">Import Floor Plan</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="house-editor"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="info">Click a room to select it. Click furniture to edit. Scroll to zoom, drag to orbit.</div>
|
<div id="info">Click a room to select it. Click furniture to edit. Scroll to zoom, drag to orbit.</div>
|
||||||
@@ -361,6 +609,8 @@
|
|||||||
import { ThemeManager } from './themes.js';
|
import { ThemeManager } from './themes.js';
|
||||||
import { ExportManager } from './export.js';
|
import { ExportManager } from './export.js';
|
||||||
import { CatalogPanel } from './catalog.js';
|
import { CatalogPanel } from './catalog.js';
|
||||||
|
import { HouseEditor } from './house-editor.js';
|
||||||
|
import { FloorplanImporter } from './floorplan-import.js';
|
||||||
|
|
||||||
const viewer = document.getElementById('viewer');
|
const viewer = document.getElementById('viewer');
|
||||||
const houseRenderer = new HouseRenderer(viewer);
|
const houseRenderer = new HouseRenderer(viewer);
|
||||||
@@ -371,10 +621,16 @@
|
|||||||
let themeManager = null;
|
let themeManager = null;
|
||||||
let exportManager = null;
|
let exportManager = null;
|
||||||
let catalogPanel = null;
|
let catalogPanel = null;
|
||||||
|
let houseEditor = null;
|
||||||
|
let floorplanImporter = null;
|
||||||
|
|
||||||
houseRenderer.loadHouse('../data/sample-house.json').then(async (house) => {
|
houseRenderer.loadHouse('../data/sample-house.json').then(async (house) => {
|
||||||
document.getElementById('house-name').textContent = house.name;
|
document.getElementById('house-name').textContent = house.name;
|
||||||
await houseRenderer.loadCatalog('../data/furniture-catalog.json');
|
await houseRenderer.loadCatalog('../data/furniture-catalog.json');
|
||||||
|
// Merge IKEA catalog items into the main catalog
|
||||||
|
await houseRenderer.mergeCatalog('../data/ikea-catalog.json').catch(e =>
|
||||||
|
console.warn('IKEA catalog not loaded:', e.message)
|
||||||
|
);
|
||||||
const design = await houseRenderer.loadDesign('../designs/sample-house-design.json');
|
const design = await houseRenderer.loadDesign('../designs/sample-house-design.json');
|
||||||
|
|
||||||
// Initialize state and interaction manager
|
// Initialize state and interaction manager
|
||||||
@@ -398,6 +654,24 @@
|
|||||||
state: designState,
|
state: designState,
|
||||||
interaction
|
interaction
|
||||||
});
|
});
|
||||||
|
houseEditor = new HouseEditor(document.getElementById('house-editor'), {
|
||||||
|
renderer: houseRenderer,
|
||||||
|
onHouseChanged: (what) => {
|
||||||
|
document.getElementById('house-name').textContent = houseRenderer.houseData.name;
|
||||||
|
buildFloorButtons();
|
||||||
|
buildRoomList();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
floorplanImporter = new FloorplanImporter(houseRenderer, {
|
||||||
|
onHouseLoaded: (houseData) => {
|
||||||
|
document.getElementById('house-name').textContent = houseData.name;
|
||||||
|
buildFloorButtons();
|
||||||
|
buildRoomList();
|
||||||
|
selectedRoom = null;
|
||||||
|
if (catalogPanel) catalogPanel.setSelectedRoom(null);
|
||||||
|
if (houseEditor) houseEditor.setSelectedRoom(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
buildFloorButtons();
|
buildFloorButtons();
|
||||||
buildRoomList();
|
buildRoomList();
|
||||||
buildThemeButtons();
|
buildThemeButtons();
|
||||||
@@ -421,6 +695,7 @@
|
|||||||
buildRoomList();
|
buildRoomList();
|
||||||
selectedRoom = null;
|
selectedRoom = null;
|
||||||
if (catalogPanel) catalogPanel.setSelectedRoom(null);
|
if (catalogPanel) catalogPanel.setSelectedRoom(null);
|
||||||
|
if (houseEditor) houseEditor.setSelectedRoom(null);
|
||||||
});
|
});
|
||||||
container.appendChild(btn);
|
container.appendChild(btn);
|
||||||
}
|
}
|
||||||
@@ -446,6 +721,7 @@
|
|||||||
el.classList.toggle('active', el.dataset.roomId === roomId);
|
el.classList.toggle('active', el.dataset.roomId === roomId);
|
||||||
});
|
});
|
||||||
if (catalogPanel) catalogPanel.setSelectedRoom(roomId);
|
if (catalogPanel) catalogPanel.setSelectedRoom(roomId);
|
||||||
|
if (houseEditor) houseEditor.setSelectedRoom(roomId);
|
||||||
const room = houseRenderer.getRooms().find(r => r.id === roomId);
|
const room = houseRenderer.getRooms().find(r => r.id === roomId);
|
||||||
if (room) {
|
if (room) {
|
||||||
document.getElementById('info').textContent = `${room.name} (${room.nameEN}) — ${room.area} m²`;
|
document.getElementById('info').textContent = `${room.name} (${room.nameEN}) — ${room.area} m²`;
|
||||||
@@ -483,6 +759,9 @@
|
|||||||
document.getElementById('btn-screenshot').addEventListener('click', () => {
|
document.getElementById('btn-screenshot').addEventListener('click', () => {
|
||||||
exportManager.exportScreenshot();
|
exportManager.exportScreenshot();
|
||||||
});
|
});
|
||||||
|
document.getElementById('btn-import-floorplan').addEventListener('click', () => {
|
||||||
|
if (floorplanImporter) floorplanImporter.open();
|
||||||
|
});
|
||||||
|
|
||||||
// Ctrl+S / Cmd+S to save design
|
// Ctrl+S / Cmd+S to save design
|
||||||
window.addEventListener('keydown', (e) => {
|
window.addEventListener('keydown', (e) => {
|
||||||
@@ -499,6 +778,12 @@
|
|||||||
viewer.addEventListener('designloaded', (e) => {
|
viewer.addEventListener('designloaded', (e) => {
|
||||||
document.getElementById('info').textContent = `Loaded design: ${e.detail.name}`;
|
document.getElementById('info').textContent = `Loaded design: ${e.detail.name}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update UI when a floor plan is imported
|
||||||
|
viewer.addEventListener('houseloaded', (e) => {
|
||||||
|
document.getElementById('info').textContent =
|
||||||
|
`Imported floor plan: ${e.detail.name} (${e.detail.floors} floor${e.detail.floors > 1 ? 's' : ''})`;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -120,6 +120,37 @@ export class HouseRenderer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async mergeCatalog(url) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) throw new Error(`Failed to load catalog: ${res.status} ${res.statusText}`);
|
||||||
|
const extra = await res.json();
|
||||||
|
if (!this.catalogData) {
|
||||||
|
return this.loadCatalog(url);
|
||||||
|
}
|
||||||
|
// Merge categories
|
||||||
|
for (const cat of extra.categories || []) {
|
||||||
|
if (!this.catalogData.categories.includes(cat)) {
|
||||||
|
this.catalogData.categories.push(cat);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Merge items, avoiding duplicates by id
|
||||||
|
for (const item of extra.items || []) {
|
||||||
|
if (!this._catalogIndex.has(item.id)) {
|
||||||
|
this.catalogData.items.push(item);
|
||||||
|
this._catalogIndex.set(item.id, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Store extra catalog for tabbed access
|
||||||
|
if (!this._extraCatalogs) this._extraCatalogs = [];
|
||||||
|
this._extraCatalogs.push(extra);
|
||||||
|
return extra;
|
||||||
|
} catch (err) {
|
||||||
|
this._emitError('mergeCatalog', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async loadDesign(url) {
|
async loadDesign(url) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
|
|||||||
10
tests/__mocks__/OrbitControls.js
Normal file
10
tests/__mocks__/OrbitControls.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export class OrbitControls {
|
||||||
|
constructor() {
|
||||||
|
this.target = { set: () => {} };
|
||||||
|
this.enableDamping = false;
|
||||||
|
this.dampingFactor = 0;
|
||||||
|
this.enabled = true;
|
||||||
|
}
|
||||||
|
update() {}
|
||||||
|
dispose() {}
|
||||||
|
}
|
||||||
199
tests/__mocks__/three.js
Normal file
199
tests/__mocks__/three.js
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
// Minimal THREE.js mock for unit testing
|
||||||
|
|
||||||
|
export class Vector2 {
|
||||||
|
constructor(x = 0, y = 0) { this.x = x; this.y = y; }
|
||||||
|
set(x, y) { this.x = x; this.y = y; return this; }
|
||||||
|
copy(v) { this.x = v.x; this.y = v.y; return this; }
|
||||||
|
clone() { return new Vector2(this.x, this.y); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Vector3 {
|
||||||
|
constructor(x = 0, y = 0, z = 0) { this.x = x; this.y = y; this.z = z; }
|
||||||
|
set(x, y, z) { this.x = x; this.y = y; this.z = z; return this; }
|
||||||
|
copy(v) { this.x = v.x; this.y = v.y; this.z = v.z; return this; }
|
||||||
|
clone() { return new Vector3(this.x, this.y, this.z); }
|
||||||
|
sub(v) { this.x -= v.x; this.y -= v.y; this.z -= v.z; return this; }
|
||||||
|
add(v) { this.x += v.x; this.y += v.y; this.z += v.z; return this; }
|
||||||
|
multiplyScalar(s) { this.x *= s; this.y *= s; this.z *= s; return this; }
|
||||||
|
equals(v) { return this.x === v.x && this.y === v.y && this.z === v.z; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Euler {
|
||||||
|
constructor(x = 0, y = 0, z = 0) { this.x = x; this.y = y; this.z = z; }
|
||||||
|
copy(e) { this.x = e.x; this.y = e.y; this.z = e.z; return this; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Color {
|
||||||
|
constructor(c) { this._hex = typeof c === 'number' ? c : 0; }
|
||||||
|
setHex(h) { this._hex = h; return this; }
|
||||||
|
clone() { return new Color(this._hex); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Object3D {
|
||||||
|
constructor() {
|
||||||
|
this.children = [];
|
||||||
|
this.parent = null;
|
||||||
|
this.position = new Vector3();
|
||||||
|
this.rotation = new Euler();
|
||||||
|
this.scale = new Vector3(1, 1, 1);
|
||||||
|
this.userData = {};
|
||||||
|
}
|
||||||
|
add(child) { this.children.push(child); child.parent = this; }
|
||||||
|
remove(child) {
|
||||||
|
const i = this.children.indexOf(child);
|
||||||
|
if (i >= 0) this.children.splice(i, 1);
|
||||||
|
child.parent = null;
|
||||||
|
}
|
||||||
|
traverse(fn) {
|
||||||
|
fn(this);
|
||||||
|
for (const child of this.children) child.traverse(fn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Group extends Object3D {}
|
||||||
|
|
||||||
|
export class Scene extends Object3D {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.background = new Color(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Mesh extends Object3D {
|
||||||
|
constructor(geometry, material) {
|
||||||
|
super();
|
||||||
|
this.geometry = geometry;
|
||||||
|
this.material = material;
|
||||||
|
this.isMesh = true;
|
||||||
|
this.castShadow = false;
|
||||||
|
this.receiveShadow = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Sprite extends Object3D {
|
||||||
|
constructor(material) {
|
||||||
|
super();
|
||||||
|
this.material = material;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LineSegments extends Object3D {
|
||||||
|
constructor(geometry, material) {
|
||||||
|
super();
|
||||||
|
this.geometry = geometry;
|
||||||
|
this.material = material;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BoxGeometry {
|
||||||
|
constructor(w, h, d) { this.parameters = { width: w, height: h, depth: d }; }
|
||||||
|
dispose() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PlaneGeometry {
|
||||||
|
constructor(w, h) { this.parameters = { width: w, height: h }; }
|
||||||
|
dispose() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CylinderGeometry {
|
||||||
|
constructor(rT, rB, h, s) { this.parameters = { radiusTop: rT, radiusBottom: rB, height: h, radialSegments: s }; }
|
||||||
|
dispose() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EdgesGeometry {
|
||||||
|
constructor(geo) { this._source = geo; }
|
||||||
|
dispose() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MeshStandardMaterial {
|
||||||
|
constructor(opts = {}) {
|
||||||
|
Object.assign(this, opts);
|
||||||
|
this.emissive = new Color(0);
|
||||||
|
}
|
||||||
|
clone() { const m = new MeshStandardMaterial(); Object.assign(m, this); m.emissive = this.emissive.clone(); return m; }
|
||||||
|
dispose() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MeshBasicMaterial {
|
||||||
|
constructor(opts = {}) { Object.assign(this, opts); }
|
||||||
|
dispose() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SpriteMaterial {
|
||||||
|
constructor(opts = {}) { Object.assign(this, opts); }
|
||||||
|
dispose() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LineBasicMaterial {
|
||||||
|
constructor(opts = {}) { Object.assign(this, opts); }
|
||||||
|
dispose() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CanvasTexture {
|
||||||
|
constructor() {}
|
||||||
|
dispose() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Plane {
|
||||||
|
constructor(normal, constant) { this.normal = normal; this.constant = constant; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Raycaster {
|
||||||
|
constructor() { this.ray = { intersectPlane: () => new Vector3() }; }
|
||||||
|
setFromCamera() {}
|
||||||
|
intersectObject() { return []; }
|
||||||
|
intersectObjects() { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PerspectiveCamera extends Object3D {
|
||||||
|
constructor(fov, aspect, near, far) {
|
||||||
|
super();
|
||||||
|
this.fov = fov;
|
||||||
|
this.aspect = aspect;
|
||||||
|
this.near = near;
|
||||||
|
this.far = far;
|
||||||
|
}
|
||||||
|
lookAt() {}
|
||||||
|
updateProjectionMatrix() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AmbientLight extends Object3D {
|
||||||
|
constructor(color, intensity) {
|
||||||
|
super();
|
||||||
|
this.color = new Color(color);
|
||||||
|
this.intensity = intensity;
|
||||||
|
this.isAmbientLight = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DirectionalLight extends Object3D {
|
||||||
|
constructor(color, intensity) {
|
||||||
|
super();
|
||||||
|
this.color = new Color(color);
|
||||||
|
this.intensity = intensity;
|
||||||
|
this.isDirectionalLight = true;
|
||||||
|
this.castShadow = false;
|
||||||
|
this.shadow = {
|
||||||
|
mapSize: { width: 0, height: 0 },
|
||||||
|
camera: { left: 0, right: 0, top: 0, bottom: 0, near: 0, far: 0 }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GridHelper extends Object3D {
|
||||||
|
constructor() { super(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WebGLRenderer {
|
||||||
|
constructor() {
|
||||||
|
this.domElement = { addEventListener: () => {}, removeEventListener: () => {}, getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }), toDataURL: () => 'data:image/png;base64,fake' };
|
||||||
|
this.shadowMap = { enabled: false };
|
||||||
|
}
|
||||||
|
setSize() {}
|
||||||
|
setPixelRatio() {}
|
||||||
|
getPixelRatio() { return 1; }
|
||||||
|
getSize(v) { v.set(800, 600); }
|
||||||
|
render() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DoubleSide = 2;
|
||||||
372
tests/catalog.test.js
Normal file
372
tests/catalog.test.js
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { CatalogPanel } from '../src/catalog.js';
|
||||||
|
|
||||||
|
function makeCatalogData() {
|
||||||
|
return {
|
||||||
|
categories: ['seating', 'tables', 'storage'],
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'chair-1', name: 'Dining Chair', category: 'seating',
|
||||||
|
dimensions: { width: 0.45, depth: 0.5, height: 0.9 },
|
||||||
|
rooms: ['wohnzimmer'],
|
||||||
|
mesh: { type: 'group', parts: [{ color: '#8b4513' }] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'table-1', name: 'Kitchen Table', category: 'tables',
|
||||||
|
dimensions: { width: 1.4, depth: 0.8, height: 0.75 },
|
||||||
|
rooms: ['küche'],
|
||||||
|
mesh: { type: 'group', parts: [{ color: '#d2b48c' }] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ikea-shelf-1', name: 'KALLAX Shelf', category: 'storage',
|
||||||
|
dimensions: { width: 0.77, depth: 0.39, height: 1.47 },
|
||||||
|
rooms: [],
|
||||||
|
ikeaSeries: 'KALLAX',
|
||||||
|
mesh: { type: 'group', parts: [{ color: '#ffffff' }] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ikea-chair-1', name: 'POÄNG Chair', category: 'seating',
|
||||||
|
dimensions: { width: 0.68, depth: 0.82, height: 1.0 },
|
||||||
|
rooms: ['wohnzimmer'],
|
||||||
|
ikeaSeries: 'POÄNG',
|
||||||
|
mesh: { type: 'group', parts: [{ color: '#b5651d' }] }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMockRenderer(catalogData) {
|
||||||
|
const listeners = {};
|
||||||
|
return {
|
||||||
|
catalogData: catalogData || makeCatalogData(),
|
||||||
|
_catalogIndex: new Map((catalogData || makeCatalogData()).items.map(i => [i.id, i])),
|
||||||
|
container: {
|
||||||
|
addEventListener: (t, fn) => { listeners[t] = listeners[t] || []; listeners[t].push(fn); },
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
_listeners: listeners
|
||||||
|
},
|
||||||
|
houseData: {
|
||||||
|
floors: [{
|
||||||
|
id: 'eg',
|
||||||
|
rooms: [{
|
||||||
|
id: 'eg-wohnzimmer', name: 'Wohnzimmer', type: 'living',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
dimensions: { width: 5, length: 4 }
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
currentFloor: 0,
|
||||||
|
getRooms: () => [
|
||||||
|
{ id: 'eg-wohnzimmer', name: 'Wohnzimmer', nameEN: 'Living Room', type: 'living', area: 20 }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMockState() {
|
||||||
|
return {
|
||||||
|
addFurniture: vi.fn(() => 0),
|
||||||
|
onChange: vi.fn(() => () => {})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMockInteraction() {
|
||||||
|
return {
|
||||||
|
selectedRoomId: null,
|
||||||
|
onChange: vi.fn(() => () => {})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeContainer() {
|
||||||
|
// Minimal DOM element mock
|
||||||
|
const el = {
|
||||||
|
innerHTML: '',
|
||||||
|
className: '',
|
||||||
|
style: {},
|
||||||
|
children: [],
|
||||||
|
querySelectorAll: () => [],
|
||||||
|
querySelector: (sel) => {
|
||||||
|
if (sel === '.catalog-count') return { textContent: '' };
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
appendChild: vi.fn(function(child) { this.children.push(child); return child; }),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dataset: {}
|
||||||
|
};
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a DOM element factory for jsdom-free testing
|
||||||
|
function setupDomMock() {
|
||||||
|
const elements = [];
|
||||||
|
|
||||||
|
globalThis.document = {
|
||||||
|
createElement: (tag) => {
|
||||||
|
const el = {
|
||||||
|
tagName: tag.toUpperCase(),
|
||||||
|
className: '',
|
||||||
|
innerHTML: '',
|
||||||
|
textContent: '',
|
||||||
|
style: {},
|
||||||
|
dataset: {},
|
||||||
|
value: '',
|
||||||
|
type: '',
|
||||||
|
placeholder: '',
|
||||||
|
step: '',
|
||||||
|
min: '',
|
||||||
|
max: '',
|
||||||
|
children: [],
|
||||||
|
_listeners: {},
|
||||||
|
appendChild: vi.fn(function(child) { this.children.push(child); return child; }),
|
||||||
|
addEventListener: vi.fn(function(type, fn) {
|
||||||
|
this._listeners[type] = this._listeners[type] || [];
|
||||||
|
this._listeners[type].push(fn);
|
||||||
|
}),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
querySelector: function(sel) {
|
||||||
|
if (sel === '.catalog-count') return { textContent: '' };
|
||||||
|
if (sel === '.catalog-item-add') return { addEventListener: vi.fn(), removeEventListener: vi.fn() };
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
querySelectorAll: () => [],
|
||||||
|
focus: vi.fn(),
|
||||||
|
click: vi.fn()
|
||||||
|
};
|
||||||
|
elements.push(el);
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return elements;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CatalogPanel', () => {
|
||||||
|
let panel, container, mockRenderer, mockState, mockInteraction;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setupDomMock();
|
||||||
|
container = makeContainer();
|
||||||
|
mockRenderer = makeMockRenderer();
|
||||||
|
mockState = makeMockState();
|
||||||
|
mockInteraction = makeMockInteraction();
|
||||||
|
panel = new CatalogPanel(container, {
|
||||||
|
renderer: mockRenderer,
|
||||||
|
state: mockState,
|
||||||
|
interaction: mockInteraction
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('initialization', () => {
|
||||||
|
it('sets default filter state', () => {
|
||||||
|
expect(panel.selectedSource).toBe('all');
|
||||||
|
expect(panel.selectedCategory).toBe('all');
|
||||||
|
expect(panel.selectedSeries).toBe('all');
|
||||||
|
expect(panel.searchQuery).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores renderer, state, and interaction references', () => {
|
||||||
|
expect(panel.renderer).toBe(mockRenderer);
|
||||||
|
expect(panel.state).toBe(mockState);
|
||||||
|
expect(panel.interaction).toBe(mockInteraction);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('_hasIkeaItems', () => {
|
||||||
|
it('returns true when IKEA items exist', () => {
|
||||||
|
expect(panel._hasIkeaItems()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false with no IKEA items', () => {
|
||||||
|
mockRenderer.catalogData.items = mockRenderer.catalogData.items.filter(i => !i.id.startsWith('ikea-'));
|
||||||
|
expect(panel._hasIkeaItems()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false with no catalog', () => {
|
||||||
|
mockRenderer.catalogData = null;
|
||||||
|
expect(panel._hasIkeaItems()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('_getSourceFilteredItems', () => {
|
||||||
|
it('returns all items for "all" source', () => {
|
||||||
|
panel.selectedSource = 'all';
|
||||||
|
expect(panel._getSourceFilteredItems()).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns only IKEA items for "ikea" source', () => {
|
||||||
|
panel.selectedSource = 'ikea';
|
||||||
|
const items = panel._getSourceFilteredItems();
|
||||||
|
expect(items).toHaveLength(2);
|
||||||
|
expect(items.every(i => i.id.startsWith('ikea-'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns only standard items for "standard" source', () => {
|
||||||
|
panel.selectedSource = 'standard';
|
||||||
|
const items = panel._getSourceFilteredItems();
|
||||||
|
expect(items).toHaveLength(2);
|
||||||
|
expect(items.every(i => !i.id.startsWith('ikea-'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array with no catalog', () => {
|
||||||
|
mockRenderer.catalogData = null;
|
||||||
|
expect(panel._getSourceFilteredItems()).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('_getFilteredItems', () => {
|
||||||
|
it('filters by category', () => {
|
||||||
|
panel.selectedCategory = 'seating';
|
||||||
|
const items = panel._getFilteredItems();
|
||||||
|
expect(items).toHaveLength(2); // chair-1 + ikea-chair-1
|
||||||
|
expect(items.every(i => i.category === 'seating')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by source + category', () => {
|
||||||
|
panel.selectedSource = 'ikea';
|
||||||
|
panel.selectedCategory = 'seating';
|
||||||
|
const items = panel._getFilteredItems();
|
||||||
|
expect(items).toHaveLength(1);
|
||||||
|
expect(items[0].id).toBe('ikea-chair-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by series', () => {
|
||||||
|
panel.selectedSource = 'ikea';
|
||||||
|
panel.selectedSeries = 'KALLAX';
|
||||||
|
const items = panel._getFilteredItems();
|
||||||
|
expect(items).toHaveLength(1);
|
||||||
|
expect(items[0].id).toBe('ikea-shelf-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by search query', () => {
|
||||||
|
panel.searchQuery = 'kallax';
|
||||||
|
const items = panel._getFilteredItems();
|
||||||
|
expect(items).toHaveLength(1);
|
||||||
|
expect(items[0].id).toBe('ikea-shelf-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('search matches on name, id, and category', () => {
|
||||||
|
panel.searchQuery = 'chair';
|
||||||
|
const items = panel._getFilteredItems();
|
||||||
|
expect(items.length).toBeGreaterThanOrEqual(2); // chair-1, ikea-chair-1
|
||||||
|
});
|
||||||
|
|
||||||
|
it('search is case-insensitive', () => {
|
||||||
|
panel.searchQuery = 'DINING';
|
||||||
|
const items = panel._getFilteredItems();
|
||||||
|
expect(items).toHaveLength(1);
|
||||||
|
expect(items[0].id).toBe('chair-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('combined filters narrow results', () => {
|
||||||
|
panel.selectedSource = 'standard';
|
||||||
|
panel.selectedCategory = 'tables';
|
||||||
|
panel.searchQuery = 'kitchen';
|
||||||
|
const items = panel._getFilteredItems();
|
||||||
|
expect(items).toHaveLength(1);
|
||||||
|
expect(items[0].id).toBe('table-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no matches returns empty array', () => {
|
||||||
|
panel.searchQuery = 'nonexistent-item-xyz';
|
||||||
|
expect(panel._getFilteredItems()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('_getTargetRoom', () => {
|
||||||
|
it('uses selectedRoomId if set', () => {
|
||||||
|
panel.selectedRoomId = 'custom-room';
|
||||||
|
expect(panel._getTargetRoom({})).toBe('custom-room');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to interaction selectedRoomId', () => {
|
||||||
|
mockInteraction.selectedRoomId = 'interaction-room';
|
||||||
|
expect(panel._getTargetRoom({})).toBe('interaction-room');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches catalog item room hints to floor rooms', () => {
|
||||||
|
const item = { rooms: ['wohnzimmer'] };
|
||||||
|
expect(panel._getTargetRoom(item)).toBe('eg-wohnzimmer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to first room when no hint matches', () => {
|
||||||
|
const item = { rooms: ['bathroom'] };
|
||||||
|
expect(panel._getTargetRoom(item)).toBe('eg-wohnzimmer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to first room with no hints', () => {
|
||||||
|
expect(panel._getTargetRoom({ rooms: [] })).toBe('eg-wohnzimmer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null with no rooms available', () => {
|
||||||
|
mockRenderer.getRooms = () => [];
|
||||||
|
expect(panel._getTargetRoom({ rooms: [] })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('_placeItem', () => {
|
||||||
|
it('calls state.addFurniture with correct placement', () => {
|
||||||
|
const item = { id: 'chair-1', rooms: ['wohnzimmer'] };
|
||||||
|
panel._placeItem(item);
|
||||||
|
|
||||||
|
expect(mockState.addFurniture).toHaveBeenCalledTimes(1);
|
||||||
|
const [roomId, placement] = mockState.addFurniture.mock.calls[0];
|
||||||
|
expect(roomId).toBe('eg-wohnzimmer');
|
||||||
|
expect(placement.catalogId).toBe('chair-1');
|
||||||
|
expect(placement.position.x).toBe(2.5); // center of 5m wide room
|
||||||
|
expect(placement.position.z).toBe(2); // center of 4m long room
|
||||||
|
expect(placement.rotation).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing when no target room found', () => {
|
||||||
|
mockRenderer.getRooms = () => [];
|
||||||
|
panel._placeItem({ id: 'x', rooms: [] });
|
||||||
|
expect(mockState.addFurniture).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setSelectedRoom', () => {
|
||||||
|
it('sets selectedRoomId', () => {
|
||||||
|
panel.setSelectedRoom('room-x');
|
||||||
|
expect(panel.selectedRoomId).toBe('room-x');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears with null', () => {
|
||||||
|
panel.setSelectedRoom('room-x');
|
||||||
|
panel.setSelectedRoom(null);
|
||||||
|
expect(panel.selectedRoomId).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('_addCustomItem', () => {
|
||||||
|
it('adds item to catalog', () => {
|
||||||
|
const countBefore = mockRenderer.catalogData.items.length;
|
||||||
|
panel._addCustomItem({
|
||||||
|
name: 'My Shelf', width: 1, depth: 0.5, height: 1.5,
|
||||||
|
color: '#aabbcc', category: 'storage'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockRenderer.catalogData.items.length).toBe(countBefore + 1);
|
||||||
|
const added = mockRenderer.catalogData.items[countBefore];
|
||||||
|
expect(added.id).toBe('custom-my-shelf');
|
||||||
|
expect(added.name).toBe('My Shelf');
|
||||||
|
expect(added.category).toBe('storage');
|
||||||
|
expect(added.dimensions).toEqual({ width: 1, depth: 0.5, height: 1.5 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates unique id on collision', () => {
|
||||||
|
panel._addCustomItem({ name: 'Shelf', width: 1, depth: 0.5, height: 1, color: '#000', category: 'storage' });
|
||||||
|
panel._addCustomItem({ name: 'Shelf', width: 1, depth: 0.5, height: 1, color: '#000', category: 'storage' });
|
||||||
|
|
||||||
|
const customItems = mockRenderer.catalogData.items.filter(i => i.id.startsWith('custom-shelf'));
|
||||||
|
expect(customItems).toHaveLength(2);
|
||||||
|
expect(customItems[0].id).not.toBe(customItems[1].id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing with no catalog', () => {
|
||||||
|
mockRenderer.catalogData = null;
|
||||||
|
panel._addCustomItem({ name: 'X', width: 1, depth: 1, height: 1, color: '#000', category: 'storage' });
|
||||||
|
// Should not throw
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
190
tests/export.test.js
Normal file
190
tests/export.test.js
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { ExportManager } from '../src/export.js';
|
||||||
|
import { DesignState } from '../src/state.js';
|
||||||
|
import { Vector2 } from '../tests/__mocks__/three.js';
|
||||||
|
|
||||||
|
function makeMockRenderer() {
|
||||||
|
return {
|
||||||
|
renderer: {
|
||||||
|
getSize: vi.fn((v) => v.set(800, 600)),
|
||||||
|
getPixelRatio: vi.fn(() => 1),
|
||||||
|
setSize: vi.fn(),
|
||||||
|
setPixelRatio: vi.fn(),
|
||||||
|
render: vi.fn(),
|
||||||
|
domElement: {
|
||||||
|
toDataURL: vi.fn(() => 'data:image/png;base64,mock')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
camera: {
|
||||||
|
aspect: 800 / 600,
|
||||||
|
updateProjectionMatrix: vi.fn()
|
||||||
|
},
|
||||||
|
scene: {},
|
||||||
|
container: {
|
||||||
|
dispatchEvent: vi.fn()
|
||||||
|
},
|
||||||
|
houseData: {
|
||||||
|
floors: [{ id: 'eg', rooms: [{ id: 'r1', name: 'Room1' }] }]
|
||||||
|
},
|
||||||
|
currentFloor: 0,
|
||||||
|
designData: null,
|
||||||
|
_clearFloor: vi.fn(),
|
||||||
|
_renderRoom: vi.fn(),
|
||||||
|
_placeFurnitureForFloor: vi.fn()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDesign() {
|
||||||
|
return {
|
||||||
|
name: 'Test Design',
|
||||||
|
rooms: [{ roomId: 'r1', furniture: [{ catalogId: 'c1', position: { x: 1, z: 2 }, rotation: 0 }] }]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ExportManager', () => {
|
||||||
|
let exportMgr, state, mockRenderer;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Stub DOM APIs
|
||||||
|
globalThis.URL = { createObjectURL: vi.fn(() => 'blob:url'), revokeObjectURL: vi.fn() };
|
||||||
|
globalThis.Blob = class Blob { constructor(parts, opts) { this.parts = parts; this.type = opts?.type; } };
|
||||||
|
globalThis.document = globalThis.document || {};
|
||||||
|
globalThis.document.createElement = (tag) => {
|
||||||
|
return { href: '', download: '', click: vi.fn(), type: '', accept: '', files: [], addEventListener: vi.fn() };
|
||||||
|
};
|
||||||
|
|
||||||
|
state = new DesignState(makeDesign());
|
||||||
|
mockRenderer = makeMockRenderer();
|
||||||
|
exportMgr = new ExportManager(mockRenderer, state);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('exportDesignJSON', () => {
|
||||||
|
it('creates a download with correct filename', () => {
|
||||||
|
const mockAnchor = { href: '', download: '', click: vi.fn() };
|
||||||
|
globalThis.document.createElement = () => mockAnchor;
|
||||||
|
|
||||||
|
exportMgr.exportDesignJSON();
|
||||||
|
|
||||||
|
expect(mockAnchor.download).toBe('Test Design.json');
|
||||||
|
expect(mockAnchor.click).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes exportedAt timestamp', () => {
|
||||||
|
let blobContent = '';
|
||||||
|
globalThis.Blob = class {
|
||||||
|
constructor(parts) { blobContent = parts[0]; }
|
||||||
|
};
|
||||||
|
globalThis.document.createElement = () => ({ href: '', download: '', click: vi.fn() });
|
||||||
|
|
||||||
|
exportMgr.exportDesignJSON();
|
||||||
|
const data = JSON.parse(blobContent);
|
||||||
|
expect(data.exportedAt).toBeDefined();
|
||||||
|
expect(new Date(data.exportedAt).getTime()).not.toBeNaN();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exports current state data', () => {
|
||||||
|
let blobContent = '';
|
||||||
|
globalThis.Blob = class {
|
||||||
|
constructor(parts) { blobContent = parts[0]; }
|
||||||
|
};
|
||||||
|
globalThis.document.createElement = () => ({ href: '', download: '', click: vi.fn() });
|
||||||
|
|
||||||
|
exportMgr.exportDesignJSON();
|
||||||
|
const data = JSON.parse(blobContent);
|
||||||
|
expect(data.name).toBe('Test Design');
|
||||||
|
expect(data.rooms).toHaveLength(1);
|
||||||
|
expect(data.rooms[0].furniture[0].catalogId).toBe('c1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses "design" as fallback filename', () => {
|
||||||
|
// State with no name
|
||||||
|
state.loadDesign({ rooms: [] });
|
||||||
|
const mockAnchor = { href: '', download: '', click: vi.fn() };
|
||||||
|
globalThis.document.createElement = () => mockAnchor;
|
||||||
|
|
||||||
|
exportMgr.exportDesignJSON();
|
||||||
|
expect(mockAnchor.download).toBe('design.json');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('_loadDesignFile', () => {
|
||||||
|
it('loads valid design file', async () => {
|
||||||
|
const design = { name: 'Loaded', rooms: [{ roomId: 'r2', furniture: [] }] };
|
||||||
|
const file = { text: () => Promise.resolve(JSON.stringify(design)), name: 'loaded.json' };
|
||||||
|
|
||||||
|
await exportMgr._loadDesignFile(file);
|
||||||
|
|
||||||
|
expect(state.design.name).toBe('Loaded');
|
||||||
|
expect(state.design.rooms).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects file without rooms array', async () => {
|
||||||
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
const file = { text: () => Promise.resolve('{"name": "bad"}'), name: 'bad.json' };
|
||||||
|
|
||||||
|
await exportMgr._loadDesignFile(file);
|
||||||
|
|
||||||
|
expect(mockRenderer.container.dispatchEvent).toHaveBeenCalled();
|
||||||
|
const event = mockRenderer.container.dispatchEvent.mock.calls.find(
|
||||||
|
c => c[0].type === 'loaderror'
|
||||||
|
);
|
||||||
|
expect(event).toBeDefined();
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid JSON', async () => {
|
||||||
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
const file = { text: () => Promise.resolve('not json'), name: 'bad.json' };
|
||||||
|
|
||||||
|
await exportMgr._loadDesignFile(file);
|
||||||
|
|
||||||
|
expect(mockRenderer.container.dispatchEvent).toHaveBeenCalled();
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispatches designloaded event on success', async () => {
|
||||||
|
const design = { name: 'Success', rooms: [{ roomId: 'r1', furniture: [] }] };
|
||||||
|
const file = { text: () => Promise.resolve(JSON.stringify(design)), name: 'test.json' };
|
||||||
|
|
||||||
|
await exportMgr._loadDesignFile(file);
|
||||||
|
|
||||||
|
const event = mockRenderer.container.dispatchEvent.mock.calls.find(
|
||||||
|
c => c[0].type === 'designloaded'
|
||||||
|
);
|
||||||
|
expect(event).toBeDefined();
|
||||||
|
expect(event[0].detail.name).toBe('Success');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('exportScreenshot', () => {
|
||||||
|
it('renders at requested resolution', () => {
|
||||||
|
const mockAnchor = { href: '', download: '', click: vi.fn() };
|
||||||
|
globalThis.document.createElement = () => mockAnchor;
|
||||||
|
|
||||||
|
exportMgr.exportScreenshot(1920, 1080);
|
||||||
|
|
||||||
|
expect(mockRenderer.renderer.setSize).toHaveBeenCalledWith(1920, 1080);
|
||||||
|
expect(mockRenderer.renderer.render).toHaveBeenCalled();
|
||||||
|
expect(mockAnchor.download).toBe('house-design.png');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores original renderer size after capture', () => {
|
||||||
|
const mockAnchor = { href: '', download: '', click: vi.fn() };
|
||||||
|
globalThis.document.createElement = () => mockAnchor;
|
||||||
|
|
||||||
|
exportMgr.exportScreenshot();
|
||||||
|
|
||||||
|
// setSize called twice: once for capture, once to restore
|
||||||
|
expect(mockRenderer.renderer.setSize).toHaveBeenCalledTimes(2);
|
||||||
|
// Last call restores original 800x600
|
||||||
|
const lastCall = mockRenderer.renderer.setSize.mock.calls[1];
|
||||||
|
expect(lastCall).toEqual([800, 600]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates camera projection matrix twice', () => {
|
||||||
|
globalThis.document.createElement = () => ({ href: '', download: '', click: vi.fn() });
|
||||||
|
exportMgr.exportScreenshot();
|
||||||
|
expect(mockRenderer.camera.updateProjectionMatrix).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
292
tests/floorplan-import.test.js
Normal file
292
tests/floorplan-import.test.js
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { FloorplanImporter } from '../src/floorplan-import.js';
|
||||||
|
|
||||||
|
// Minimal renderer mock
|
||||||
|
function makeRenderer() {
|
||||||
|
const listeners = {};
|
||||||
|
return {
|
||||||
|
houseData: null,
|
||||||
|
currentFloor: 0,
|
||||||
|
container: {
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
addEventListener: (type, fn) => {
|
||||||
|
listeners[type] = listeners[type] || [];
|
||||||
|
listeners[type].push(fn);
|
||||||
|
},
|
||||||
|
_listeners: listeners
|
||||||
|
},
|
||||||
|
_clearFloor: vi.fn(),
|
||||||
|
_renderRoom: vi.fn(),
|
||||||
|
showFloor: vi.fn()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid house data for testing
|
||||||
|
function makeSampleHouse() {
|
||||||
|
return {
|
||||||
|
name: 'Test House',
|
||||||
|
description: 'A test house',
|
||||||
|
units: 'meters',
|
||||||
|
building: {
|
||||||
|
footprint: { width: 10, depth: 8 },
|
||||||
|
wallThickness: 0.24,
|
||||||
|
roofType: 'gable'
|
||||||
|
},
|
||||||
|
floors: [{
|
||||||
|
id: 'eg',
|
||||||
|
name: 'Ground Floor',
|
||||||
|
nameEN: 'Ground Floor',
|
||||||
|
level: 0,
|
||||||
|
ceilingHeight: 2.6,
|
||||||
|
rooms: [
|
||||||
|
{
|
||||||
|
id: 'eg-living',
|
||||||
|
name: 'Living Room',
|
||||||
|
nameEN: 'Living Room',
|
||||||
|
type: 'living',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
dimensions: { width: 5, length: 4 },
|
||||||
|
flooring: 'hardwood',
|
||||||
|
walls: {
|
||||||
|
south: { type: 'exterior', doors: [], windows: [] },
|
||||||
|
north: { type: 'interior', doors: [{ id: 'eg-living-d1', type: 'interior', position: 1, width: 0.9, height: 2.1, connectsTo: 'eg-kitchen' }], windows: [] },
|
||||||
|
east: { type: 'interior', doors: [], windows: [] },
|
||||||
|
west: { type: 'exterior', doors: [], windows: [{ id: 'eg-living-w1', type: 'casement', position: 1.5, width: 1.2, height: 1.2, sillHeight: 0.9 }] }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'eg-kitchen',
|
||||||
|
name: 'Kitchen',
|
||||||
|
nameEN: 'Kitchen',
|
||||||
|
type: 'kitchen',
|
||||||
|
position: { x: 0, y: 4 },
|
||||||
|
dimensions: { width: 5, length: 4 },
|
||||||
|
flooring: 'tile',
|
||||||
|
walls: {
|
||||||
|
south: { type: 'interior', doors: [], windows: [] },
|
||||||
|
north: { type: 'exterior', doors: [], windows: [] },
|
||||||
|
east: { type: 'interior', doors: [], windows: [] },
|
||||||
|
west: { type: 'exterior', doors: [], windows: [] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('FloorplanImporter', () => {
|
||||||
|
let renderer;
|
||||||
|
let importer;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
renderer = makeRenderer();
|
||||||
|
importer = new FloorplanImporter(renderer);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('_validateHouseJSON', () => {
|
||||||
|
it('validates correct house data', () => {
|
||||||
|
const result = importer._validateHouseJSON(makeSampleHouse());
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
expect(result.errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('catches missing name', () => {
|
||||||
|
const data = makeSampleHouse();
|
||||||
|
delete data.name;
|
||||||
|
const result = importer._validateHouseJSON(data);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors).toContain('Missing building name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('catches missing building footprint', () => {
|
||||||
|
const data = makeSampleHouse();
|
||||||
|
delete data.building.footprint;
|
||||||
|
const result = importer._validateHouseJSON(data);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors).toContain('Missing building footprint');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('catches missing floors', () => {
|
||||||
|
const data = makeSampleHouse();
|
||||||
|
data.floors = [];
|
||||||
|
const result = importer._validateHouseJSON(data);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors).toContain('No floors found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('catches rooms with missing walls', () => {
|
||||||
|
const data = makeSampleHouse();
|
||||||
|
delete data.floors[0].rooms[0].walls;
|
||||||
|
const result = importer._validateHouseJSON(data);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors).toContain('Room "eg-living" missing walls');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('catches invalid wall type', () => {
|
||||||
|
const data = makeSampleHouse();
|
||||||
|
data.floors[0].rooms[0].walls.south.type = 'concrete';
|
||||||
|
const result = importer._validateHouseJSON(data);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors.some(e => e.includes('invalid type'))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('_autoRepair', () => {
|
||||||
|
it('adds missing name', () => {
|
||||||
|
const data = { floors: [{ rooms: [] }] };
|
||||||
|
const repaired = importer._autoRepair(data);
|
||||||
|
expect(repaired.name).toBe('Imported Floor Plan');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds missing units', () => {
|
||||||
|
const data = { floors: [] };
|
||||||
|
const repaired = importer._autoRepair(data);
|
||||||
|
expect(repaired.units).toBe('meters');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts string numbers to floats', () => {
|
||||||
|
const data = {
|
||||||
|
floors: [{
|
||||||
|
rooms: [{
|
||||||
|
id: 'test',
|
||||||
|
position: { x: '3.5', y: '2.0' },
|
||||||
|
dimensions: { width: '4', length: '5.5' },
|
||||||
|
walls: {
|
||||||
|
north: { type: 'exterior', doors: [], windows: [] },
|
||||||
|
south: { type: 'exterior', doors: [], windows: [] },
|
||||||
|
east: { type: 'interior', doors: [], windows: [] },
|
||||||
|
west: { type: 'interior', doors: [], windows: [] }
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
const repaired = importer._autoRepair(data);
|
||||||
|
const room = repaired.floors[0].rooms[0];
|
||||||
|
expect(room.position.x).toBe(3.5);
|
||||||
|
expect(room.position.y).toBe(2.0);
|
||||||
|
expect(room.dimensions.width).toBe(4);
|
||||||
|
expect(room.dimensions.length).toBe(5.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates missing IDs', () => {
|
||||||
|
const data = {
|
||||||
|
floors: [{
|
||||||
|
id: 'eg',
|
||||||
|
rooms: [{
|
||||||
|
nameEN: 'Living Room',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
dimensions: { width: 4, length: 4 },
|
||||||
|
walls: {}
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
const repaired = importer._autoRepair(data);
|
||||||
|
expect(repaired.floors[0].rooms[0].id).toBe('eg-living-room');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('infers flooring from room type', () => {
|
||||||
|
const data = {
|
||||||
|
floors: [{
|
||||||
|
id: 'eg',
|
||||||
|
rooms: [
|
||||||
|
{ id: 'eg-k', type: 'kitchen', position: { x: 0, y: 0 }, dimensions: { width: 3, length: 3 }, walls: {} },
|
||||||
|
{ id: 'eg-b', type: 'bedroom', position: { x: 3, y: 0 }, dimensions: { width: 3, length: 3 }, walls: {} }
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
const repaired = importer._autoRepair(data);
|
||||||
|
expect(repaired.floors[0].rooms[0].flooring).toBe('tile');
|
||||||
|
expect(repaired.floors[0].rooms[1].flooring).toBe('hardwood');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds missing walls', () => {
|
||||||
|
const data = {
|
||||||
|
floors: [{
|
||||||
|
rooms: [{
|
||||||
|
id: 'test',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
dimensions: { width: 3, length: 3 }
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
const repaired = importer._autoRepair(data);
|
||||||
|
const room = repaired.floors[0].rooms[0];
|
||||||
|
expect(room.walls.north).toBeDefined();
|
||||||
|
expect(room.walls.south).toBeDefined();
|
||||||
|
expect(room.walls.east).toBeDefined();
|
||||||
|
expect(room.walls.west).toBeDefined();
|
||||||
|
expect(room.walls.north.type).toBe('interior');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computes footprint from rooms when missing', () => {
|
||||||
|
const data = {
|
||||||
|
floors: [{
|
||||||
|
rooms: [
|
||||||
|
{ id: 'r1', position: { x: 0, y: 0 }, dimensions: { width: 5, length: 4 }, walls: {} },
|
||||||
|
{ id: 'r2', position: { x: 5, y: 0 }, dimensions: { width: 3, length: 4 }, walls: {} }
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
const repaired = importer._autoRepair(data);
|
||||||
|
expect(repaired.building.footprint.width).toBe(8);
|
||||||
|
expect(repaired.building.footprint.depth).toBe(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('_applyToRenderer', () => {
|
||||||
|
it('sets house data on renderer', () => {
|
||||||
|
const data = makeSampleHouse();
|
||||||
|
importer._applyToRenderer(data);
|
||||||
|
expect(renderer.houseData).toBe(data);
|
||||||
|
expect(renderer.currentFloor).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears and renders floor', () => {
|
||||||
|
const data = makeSampleHouse();
|
||||||
|
importer._applyToRenderer(data);
|
||||||
|
expect(renderer._clearFloor).toHaveBeenCalled();
|
||||||
|
expect(renderer._renderRoom).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispatches houseloaded event', () => {
|
||||||
|
const data = makeSampleHouse();
|
||||||
|
importer._applyToRenderer(data);
|
||||||
|
expect(renderer.container.dispatchEvent).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: 'houseloaded',
|
||||||
|
detail: { name: 'Test House', floors: 1 }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onHouseLoaded callback', () => {
|
||||||
|
const callback = vi.fn();
|
||||||
|
const importerWithCb = new FloorplanImporter(renderer, { onHouseLoaded: callback });
|
||||||
|
const data = makeSampleHouse();
|
||||||
|
importerWithCb._applyToRenderer(data);
|
||||||
|
expect(callback).toHaveBeenCalledWith(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('_buildPrompt', () => {
|
||||||
|
it('includes building name', () => {
|
||||||
|
const prompt = importer._buildPrompt('My House', 1, '');
|
||||||
|
expect(prompt).toContain('My House');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes scale hint when provided', () => {
|
||||||
|
const prompt = importer._buildPrompt('House', 1, 'Living room is 5m wide');
|
||||||
|
expect(prompt).toContain('Scale reference: Living room is 5m wide');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses default scale message when no hint', () => {
|
||||||
|
const prompt = importer._buildPrompt('House', 1, '');
|
||||||
|
expect(prompt).toContain('Estimate dimensions from standard door widths');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes floor count', () => {
|
||||||
|
const prompt = importer._buildPrompt('House', 2, '');
|
||||||
|
expect(prompt).toContain('2 floor(s)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
311
tests/interaction.test.js
Normal file
311
tests/interaction.test.js
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { InteractionManager } from '../src/interaction.js';
|
||||||
|
import { DesignState } from '../src/state.js';
|
||||||
|
import { Group, Vector3, Euler } from '../tests/__mocks__/three.js';
|
||||||
|
|
||||||
|
function makeState() {
|
||||||
|
return new DesignState({
|
||||||
|
name: 'Test',
|
||||||
|
rooms: [{
|
||||||
|
roomId: 'room-1',
|
||||||
|
furniture: [
|
||||||
|
{ catalogId: 'chair', position: { x: 1, z: 2 }, rotation: 0 },
|
||||||
|
{ catalogId: 'table', position: { x: 3, z: 4 }, rotation: 90 }
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMockRenderer() {
|
||||||
|
const listeners = {};
|
||||||
|
const canvasListeners = {};
|
||||||
|
const container = {
|
||||||
|
addEventListener: (t, fn) => { listeners[t] = listeners[t] || []; listeners[t].push(fn); },
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn()
|
||||||
|
};
|
||||||
|
|
||||||
|
const furnitureMeshes = new Map();
|
||||||
|
|
||||||
|
return {
|
||||||
|
container,
|
||||||
|
renderer: {
|
||||||
|
domElement: {
|
||||||
|
addEventListener: (t, fn) => { canvasListeners[t] = canvasListeners[t] || []; canvasListeners[t].push(fn); },
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
camera: { position: new Vector3(6, 12, 14) },
|
||||||
|
raycaster: {
|
||||||
|
setFromCamera: vi.fn(),
|
||||||
|
intersectObject: vi.fn(() => []),
|
||||||
|
ray: { intersectPlane: vi.fn(() => new Vector3()) }
|
||||||
|
},
|
||||||
|
houseData: {
|
||||||
|
floors: [{
|
||||||
|
id: 'eg',
|
||||||
|
rooms: [{
|
||||||
|
id: 'room-1',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
dimensions: { width: 5, length: 5 }
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
currentFloor: 0,
|
||||||
|
furnitureMeshes,
|
||||||
|
setControlsEnabled: vi.fn(),
|
||||||
|
designData: {},
|
||||||
|
_clearFloor: vi.fn(),
|
||||||
|
_renderRoom: vi.fn(),
|
||||||
|
_placeFurnitureForFloor: vi.fn(),
|
||||||
|
_listeners: listeners,
|
||||||
|
_canvasListeners: canvasListeners
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('InteractionManager', () => {
|
||||||
|
let interaction, state, mockRenderer;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
globalThis.window = globalThis.window || {};
|
||||||
|
globalThis.window.addEventListener = vi.fn();
|
||||||
|
globalThis.window.removeEventListener = vi.fn();
|
||||||
|
|
||||||
|
state = makeState();
|
||||||
|
mockRenderer = makeMockRenderer();
|
||||||
|
interaction = new InteractionManager(mockRenderer, state);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mode system', () => {
|
||||||
|
it('starts in view mode', () => {
|
||||||
|
expect(interaction.mode).toBe('view');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setMode changes mode and emits event', () => {
|
||||||
|
const listener = vi.fn();
|
||||||
|
interaction.onChange(listener);
|
||||||
|
interaction.setMode('select');
|
||||||
|
expect(interaction.mode).toBe('select');
|
||||||
|
expect(listener).toHaveBeenCalledWith('modechange', { oldMode: 'view', newMode: 'select' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setMode does nothing if already in that mode', () => {
|
||||||
|
const listener = vi.fn();
|
||||||
|
interaction.onChange(listener);
|
||||||
|
interaction.setMode('view');
|
||||||
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switching to view clears selection', () => {
|
||||||
|
// Use select() to properly set up, which also transitions to 'select' mode
|
||||||
|
const mesh = new Group();
|
||||||
|
mesh.userData = { roomId: 'room-1', furnitureIndex: 0, catalogId: 'chair', itemName: 'Chair' };
|
||||||
|
interaction.select(mesh);
|
||||||
|
expect(interaction.mode).toBe('select');
|
||||||
|
|
||||||
|
interaction.setMode('view');
|
||||||
|
expect(interaction.selectedObject).toBeNull();
|
||||||
|
expect(interaction.selectedRoomId).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('selection', () => {
|
||||||
|
it('select sets selection properties', () => {
|
||||||
|
const mesh = new Group();
|
||||||
|
mesh.userData = { roomId: 'room-1', furnitureIndex: 0, catalogId: 'chair', itemName: 'Chair' };
|
||||||
|
|
||||||
|
interaction.select(mesh);
|
||||||
|
expect(interaction.selectedObject).toBe(mesh);
|
||||||
|
expect(interaction.selectedRoomId).toBe('room-1');
|
||||||
|
expect(interaction.selectedIndex).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('select emits select event', () => {
|
||||||
|
const listener = vi.fn();
|
||||||
|
interaction.onChange(listener);
|
||||||
|
|
||||||
|
const mesh = new Group();
|
||||||
|
mesh.userData = { roomId: 'room-1', furnitureIndex: 0, catalogId: 'chair', itemName: 'Chair' };
|
||||||
|
interaction.select(mesh);
|
||||||
|
|
||||||
|
expect(listener).toHaveBeenCalledWith('select', expect.objectContaining({
|
||||||
|
roomId: 'room-1',
|
||||||
|
index: 0,
|
||||||
|
catalogId: 'chair'
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('select switches mode from view to select', () => {
|
||||||
|
const mesh = new Group();
|
||||||
|
mesh.userData = { roomId: 'room-1', furnitureIndex: 0, catalogId: 'chair', itemName: 'Chair' };
|
||||||
|
interaction.select(mesh);
|
||||||
|
expect(interaction.mode).toBe('select');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selecting same object does nothing', () => {
|
||||||
|
const mesh = new Group();
|
||||||
|
mesh.userData = { roomId: 'room-1', furnitureIndex: 0, catalogId: 'chair', itemName: 'Chair' };
|
||||||
|
interaction.select(mesh);
|
||||||
|
const listener = vi.fn();
|
||||||
|
interaction.onChange(listener);
|
||||||
|
interaction.select(mesh); // same mesh
|
||||||
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clearSelection resets state and emits deselect', () => {
|
||||||
|
const mesh = new Group();
|
||||||
|
mesh.userData = { roomId: 'room-1', furnitureIndex: 0, catalogId: 'chair', itemName: 'Chair' };
|
||||||
|
interaction.select(mesh);
|
||||||
|
|
||||||
|
const listener = vi.fn();
|
||||||
|
interaction.onChange(listener);
|
||||||
|
interaction.clearSelection();
|
||||||
|
|
||||||
|
expect(interaction.selectedObject).toBeNull();
|
||||||
|
expect(interaction.selectedRoomId).toBeNull();
|
||||||
|
expect(interaction.selectedIndex).toBe(-1);
|
||||||
|
expect(listener).toHaveBeenCalledWith('deselect', { roomId: 'room-1', index: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clearSelection does nothing with no selection', () => {
|
||||||
|
const listener = vi.fn();
|
||||||
|
interaction.onChange(listener);
|
||||||
|
interaction.clearSelection();
|
||||||
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('keyboard shortcuts', () => {
|
||||||
|
function keyDown(key, opts = {}) {
|
||||||
|
interaction._onKeyDown({
|
||||||
|
key,
|
||||||
|
ctrlKey: opts.ctrl || false,
|
||||||
|
metaKey: opts.meta || false,
|
||||||
|
shiftKey: opts.shift || false,
|
||||||
|
target: { tagName: opts.tagName || 'BODY' },
|
||||||
|
preventDefault: vi.fn()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('Ctrl+Z triggers undo', () => {
|
||||||
|
state.moveFurniture('room-1', 0, { x: 99 });
|
||||||
|
keyDown('z', { ctrl: true });
|
||||||
|
expect(state.getFurniture('room-1', 0).position.x).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Ctrl+Shift+Z triggers redo', () => {
|
||||||
|
state.moveFurniture('room-1', 0, { x: 99 });
|
||||||
|
state.undo();
|
||||||
|
keyDown('Z', { ctrl: true, shift: true });
|
||||||
|
expect(state.getFurniture('room-1', 0).position.x).toBe(99);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Ctrl+Y triggers redo', () => {
|
||||||
|
state.moveFurniture('room-1', 0, { x: 99 });
|
||||||
|
state.undo();
|
||||||
|
keyDown('y', { ctrl: true });
|
||||||
|
expect(state.getFurniture('room-1', 0).position.x).toBe(99);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Escape clears selection and returns to view mode', () => {
|
||||||
|
const mesh = new Group();
|
||||||
|
mesh.userData = { roomId: 'room-1', furnitureIndex: 0, catalogId: 'chair', itemName: 'Chair' };
|
||||||
|
interaction.select(mesh);
|
||||||
|
|
||||||
|
keyDown('Escape');
|
||||||
|
expect(interaction.selectedObject).toBeNull();
|
||||||
|
expect(interaction.mode).toBe('view');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Delete removes selected furniture', () => {
|
||||||
|
const mesh = new Group();
|
||||||
|
mesh.userData = { roomId: 'room-1', furnitureIndex: 0, catalogId: 'chair', itemName: 'Chair' };
|
||||||
|
interaction.select(mesh);
|
||||||
|
|
||||||
|
keyDown('Delete');
|
||||||
|
expect(state.getRoomFurniture('room-1')).toHaveLength(1);
|
||||||
|
// The chair (index 0) was removed; table remains
|
||||||
|
expect(state.getFurniture('room-1', 0).catalogId).toBe('table');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('R rotates selected furniture by -90 degrees', () => {
|
||||||
|
const mesh = new Group();
|
||||||
|
mesh.userData = { roomId: 'room-1', furnitureIndex: 0, catalogId: 'chair', itemName: 'Chair' };
|
||||||
|
interaction.select(mesh);
|
||||||
|
|
||||||
|
keyDown('r');
|
||||||
|
expect(state.getFurniture('room-1', 0).rotation).toBe(270); // (0 + (-90) + 360) % 360
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Shift+R rotates selected furniture by +90 degrees', () => {
|
||||||
|
const mesh = new Group();
|
||||||
|
mesh.userData = { roomId: 'room-1', furnitureIndex: 0, catalogId: 'chair', itemName: 'Chair' };
|
||||||
|
interaction.select(mesh);
|
||||||
|
|
||||||
|
keyDown('R', { shift: true });
|
||||||
|
expect(state.getFurniture('room-1', 0).rotation).toBe(90);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores shortcuts when focused on input', () => {
|
||||||
|
const listener = vi.fn();
|
||||||
|
state.onChange(listener);
|
||||||
|
state.moveFurniture('room-1', 0, { x: 99 });
|
||||||
|
listener.mockClear();
|
||||||
|
|
||||||
|
keyDown('z', { ctrl: true, tagName: 'INPUT' });
|
||||||
|
// Undo should NOT have fired
|
||||||
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selection-requiring shortcuts do nothing without selection', () => {
|
||||||
|
keyDown('Delete');
|
||||||
|
keyDown('r');
|
||||||
|
// Should not throw, no furniture removed
|
||||||
|
expect(state.getRoomFurniture('room-1')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('onChange / observer', () => {
|
||||||
|
it('registers and unregisters listeners', () => {
|
||||||
|
const listener = vi.fn();
|
||||||
|
const unsub = interaction.onChange(listener);
|
||||||
|
interaction.setMode('select');
|
||||||
|
expect(listener).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
unsub();
|
||||||
|
interaction.setMode('view');
|
||||||
|
expect(listener).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('snap settings', () => {
|
||||||
|
it('defaults to snap enabled at 0.25m', () => {
|
||||||
|
expect(interaction.snapEnabled).toBe(true);
|
||||||
|
expect(interaction.snapSize).toBe(0.25);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('_buildRoomBounds', () => {
|
||||||
|
it('populates room bounds from house data', () => {
|
||||||
|
interaction._buildRoomBounds();
|
||||||
|
const bounds = interaction._roomBounds.get('room-1');
|
||||||
|
expect(bounds).toEqual({
|
||||||
|
minX: 0, maxX: 5,
|
||||||
|
minZ: 0, maxZ: 5
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles missing house data', () => {
|
||||||
|
mockRenderer.houseData = null;
|
||||||
|
interaction._buildRoomBounds();
|
||||||
|
expect(interaction._roomBounds.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('dispose', () => {
|
||||||
|
it('does not throw', () => {
|
||||||
|
expect(() => interaction.dispose()).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
260
tests/renderer.test.js
Normal file
260
tests/renderer.test.js
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { HouseRenderer, COLORS } from '../src/renderer.js';
|
||||||
|
|
||||||
|
// Mock container with minimal DOM-like interface
|
||||||
|
function makeContainer() {
|
||||||
|
const listeners = {};
|
||||||
|
return {
|
||||||
|
clientWidth: 800,
|
||||||
|
clientHeight: 600,
|
||||||
|
appendChild: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
addEventListener: (type, fn) => {
|
||||||
|
listeners[type] = listeners[type] || [];
|
||||||
|
listeners[type].push(fn);
|
||||||
|
},
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
_listeners: listeners,
|
||||||
|
_fireEvent: (type, detail) => {
|
||||||
|
for (const fn of listeners[type] || []) fn({ detail });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('HouseRenderer', () => {
|
||||||
|
let renderer;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Stub global APIs used by the constructor
|
||||||
|
globalThis.window = globalThis.window || {};
|
||||||
|
globalThis.window.addEventListener = vi.fn();
|
||||||
|
globalThis.window.devicePixelRatio = 1;
|
||||||
|
globalThis.requestAnimationFrame = vi.fn();
|
||||||
|
|
||||||
|
// Stub document.createElement for canvas room labels
|
||||||
|
globalThis.document = globalThis.document || {};
|
||||||
|
globalThis.document.createElement = (tag) => {
|
||||||
|
if (tag === 'canvas') {
|
||||||
|
return {
|
||||||
|
getContext: () => ({
|
||||||
|
font: '',
|
||||||
|
measureText: () => ({ width: 100 }),
|
||||||
|
fillStyle: '',
|
||||||
|
fillRect: () => {},
|
||||||
|
textBaseline: '',
|
||||||
|
textAlign: '',
|
||||||
|
fillText: () => {}
|
||||||
|
}),
|
||||||
|
width: 0,
|
||||||
|
height: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { addEventListener: vi.fn(), click: vi.fn(), type: '', accept: '', files: [] };
|
||||||
|
};
|
||||||
|
|
||||||
|
renderer = new HouseRenderer(makeContainer());
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('COLORS export', () => {
|
||||||
|
it('exports expected color structure', () => {
|
||||||
|
expect(COLORS.wall).toHaveProperty('exterior');
|
||||||
|
expect(COLORS.wall).toHaveProperty('interior');
|
||||||
|
expect(COLORS.floor).toHaveProperty('tile');
|
||||||
|
expect(COLORS.floor).toHaveProperty('hardwood');
|
||||||
|
expect(COLORS).toHaveProperty('ceiling');
|
||||||
|
expect(COLORS).toHaveProperty('door');
|
||||||
|
expect(COLORS).toHaveProperty('window');
|
||||||
|
expect(COLORS).toHaveProperty('windowFrame');
|
||||||
|
expect(COLORS).toHaveProperty('grid');
|
||||||
|
expect(COLORS).toHaveProperty('selected');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('initial state', () => {
|
||||||
|
it('has null data references', () => {
|
||||||
|
expect(renderer.houseData).toBeNull();
|
||||||
|
expect(renderer.catalogData).toBeNull();
|
||||||
|
expect(renderer.designData).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts on floor 0', () => {
|
||||||
|
expect(renderer.currentFloor).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has empty mesh maps', () => {
|
||||||
|
expect(renderer.roomMeshes.size).toBe(0);
|
||||||
|
expect(renderer.furnitureMeshes.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('_computeWallSegments', () => {
|
||||||
|
it('returns single full-height segment with no openings', () => {
|
||||||
|
const segments = renderer._computeWallSegments([], 4, 2.6);
|
||||||
|
expect(segments).toHaveLength(1);
|
||||||
|
expect(segments[0]).toEqual({ w: 4, h: 2.6, cx: 2, cy: 1.3 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates segments around a door opening', () => {
|
||||||
|
const openings = [{ position: 1, width: 1, height: 2.1, bottom: 0 }];
|
||||||
|
const segments = renderer._computeWallSegments(openings, 4, 2.6);
|
||||||
|
|
||||||
|
// Left of door
|
||||||
|
const left = segments.find(s => s.cx < 1);
|
||||||
|
expect(left).toBeDefined();
|
||||||
|
expect(left.w).toBeCloseTo(1, 1);
|
||||||
|
expect(left.h).toBe(2.6);
|
||||||
|
|
||||||
|
// Above door
|
||||||
|
const above = segments.find(s => s.cy > 2.1);
|
||||||
|
expect(above).toBeDefined();
|
||||||
|
expect(above.w).toBe(1); // door width
|
||||||
|
expect(above.h).toBeCloseTo(0.5, 1);
|
||||||
|
|
||||||
|
// Right of door
|
||||||
|
const right = segments.find(s => s.cx > 2);
|
||||||
|
expect(right).toBeDefined();
|
||||||
|
expect(right.w).toBeCloseTo(2, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates segments around a window with sill', () => {
|
||||||
|
const openings = [{
|
||||||
|
position: 1, width: 1.2, height: 1.0,
|
||||||
|
bottom: 0.8, sillHeight: 0.8
|
||||||
|
}];
|
||||||
|
const segments = renderer._computeWallSegments(openings, 4, 2.6);
|
||||||
|
|
||||||
|
// Should have: left, above, below (sill), right
|
||||||
|
expect(segments.length).toBeGreaterThanOrEqual(4);
|
||||||
|
|
||||||
|
// Below-sill segment
|
||||||
|
const below = segments.find(s => s.h === 0.8 && s.w === 1.2);
|
||||||
|
expect(below).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles multiple openings', () => {
|
||||||
|
const openings = [
|
||||||
|
{ position: 0.5, width: 0.8, height: 2.1, bottom: 0 },
|
||||||
|
{ position: 2.5, width: 1.0, height: 1.0, bottom: 0.8 }
|
||||||
|
];
|
||||||
|
const segments = renderer._computeWallSegments(openings, 5, 2.6);
|
||||||
|
// Should have segments between and around both openings
|
||||||
|
expect(segments.length).toBeGreaterThanOrEqual(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getFloors / getRooms', () => {
|
||||||
|
it('getFloors returns empty array with no house data', () => {
|
||||||
|
expect(renderer.getFloors()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getRooms returns empty array with no house data', () => {
|
||||||
|
expect(renderer.getRooms()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getFloors returns floor list after loading', () => {
|
||||||
|
renderer.houseData = {
|
||||||
|
floors: [
|
||||||
|
{ id: 'eg', name: 'Erdgeschoss', nameEN: 'Ground Floor', rooms: [] },
|
||||||
|
{ id: 'og', name: 'Obergeschoss', nameEN: 'Upper Floor', rooms: [] }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
const floors = renderer.getFloors();
|
||||||
|
expect(floors).toHaveLength(2);
|
||||||
|
expect(floors[0]).toEqual({ index: 0, id: 'eg', name: 'Erdgeschoss', nameEN: 'Ground Floor' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getRooms returns rooms for current floor', () => {
|
||||||
|
renderer.houseData = {
|
||||||
|
floors: [{
|
||||||
|
id: 'eg', name: 'EG', rooms: [
|
||||||
|
{ id: 'r1', name: 'Room1', nameEN: 'R1', type: 'living', dimensions: { width: 4, length: 5 } },
|
||||||
|
{ id: 'r2', name: 'Room2', nameEN: 'R2', type: 'bedroom', dimensions: { width: 3, length: 3 } }
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
renderer.currentFloor = 0;
|
||||||
|
const rooms = renderer.getRooms();
|
||||||
|
expect(rooms).toHaveLength(2);
|
||||||
|
expect(rooms[0].id).toBe('r1');
|
||||||
|
expect(rooms[0].area).toBe(20);
|
||||||
|
expect(rooms[1].area).toBe(9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getRooms handles invalid floor index', () => {
|
||||||
|
renderer.houseData = { floors: [] };
|
||||||
|
renderer.currentFloor = 5;
|
||||||
|
expect(renderer.getRooms()).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setControlsEnabled', () => {
|
||||||
|
it('toggles controls.enabled', () => {
|
||||||
|
renderer.setControlsEnabled(false);
|
||||||
|
expect(renderer.controls.enabled).toBe(false);
|
||||||
|
renderer.setControlsEnabled(true);
|
||||||
|
expect(renderer.controls.enabled).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('showFloor', () => {
|
||||||
|
it('updates currentFloor', () => {
|
||||||
|
renderer.houseData = {
|
||||||
|
floors: [
|
||||||
|
{ id: 'eg', name: 'EG', ceilingHeight: 2.6, rooms: [] },
|
||||||
|
{ id: 'og', name: 'OG', ceilingHeight: 2.5, rooms: [] }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
renderer.showFloor(1);
|
||||||
|
expect(renderer.currentFloor).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('_buildFurnitureMesh', () => {
|
||||||
|
it('returns group for catalog item with box parts', () => {
|
||||||
|
const item = {
|
||||||
|
id: 'test',
|
||||||
|
mesh: {
|
||||||
|
type: 'group',
|
||||||
|
parts: [
|
||||||
|
{ geometry: 'box', size: [1, 0.5, 0.5], position: [0, 0.25, 0], color: '#8b4513' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const group = renderer._buildFurnitureMesh(item);
|
||||||
|
expect(group.children).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns group for cylinder parts', () => {
|
||||||
|
const item = {
|
||||||
|
id: 'test',
|
||||||
|
mesh: {
|
||||||
|
type: 'group',
|
||||||
|
parts: [
|
||||||
|
{ geometry: 'cylinder', radius: 0.1, height: 0.7, position: [0, 0.35, 0], color: '#333' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const group = renderer._buildFurnitureMesh(item);
|
||||||
|
expect(group.children).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty group for missing mesh def', () => {
|
||||||
|
const group = renderer._buildFurnitureMesh({ id: 'no-mesh' });
|
||||||
|
expect(group.children).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips unknown geometry types', () => {
|
||||||
|
const item = {
|
||||||
|
id: 'test',
|
||||||
|
mesh: {
|
||||||
|
type: 'group',
|
||||||
|
parts: [
|
||||||
|
{ geometry: 'sphere', radius: 0.5, position: [0, 0, 0], color: '#fff' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const group = renderer._buildFurnitureMesh(item);
|
||||||
|
expect(group.children).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
10
tests/setup.js
Normal file
10
tests/setup.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
// Polyfill browser globals missing in Node.js
|
||||||
|
|
||||||
|
if (typeof globalThis.CustomEvent === 'undefined') {
|
||||||
|
globalThis.CustomEvent = class CustomEvent {
|
||||||
|
constructor(type, opts = {}) {
|
||||||
|
this.type = type;
|
||||||
|
this.detail = opts.detail || null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
347
tests/state.test.js
Normal file
347
tests/state.test.js
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { DesignState } from '../src/state.js';
|
||||||
|
|
||||||
|
function makeDesign() {
|
||||||
|
return {
|
||||||
|
name: 'Test Design',
|
||||||
|
rooms: [
|
||||||
|
{
|
||||||
|
roomId: 'room-a',
|
||||||
|
furniture: [
|
||||||
|
{ catalogId: 'chair-1', position: { x: 1, z: 2 }, rotation: 0 },
|
||||||
|
{ catalogId: 'table-1', position: { x: 3, z: 4 }, rotation: 90 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
roomId: 'room-b',
|
||||||
|
furniture: [
|
||||||
|
{ catalogId: 'sofa-1', position: { x: 0, z: 0 }, rotation: 180 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DesignState', () => {
|
||||||
|
let state;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
state = new DesignState(makeDesign());
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Read operations ---
|
||||||
|
|
||||||
|
describe('read operations', () => {
|
||||||
|
it('returns the full design via .design', () => {
|
||||||
|
expect(state.design.name).toBe('Test Design');
|
||||||
|
expect(state.design.rooms).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getRoomDesign returns room by id', () => {
|
||||||
|
const room = state.getRoomDesign('room-a');
|
||||||
|
expect(room).toBeDefined();
|
||||||
|
expect(room.roomId).toBe('room-a');
|
||||||
|
expect(room.furniture).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getRoomDesign returns undefined for missing room', () => {
|
||||||
|
expect(state.getRoomDesign('nonexistent')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getFurniture returns specific item', () => {
|
||||||
|
const item = state.getFurniture('room-a', 0);
|
||||||
|
expect(item.catalogId).toBe('chair-1');
|
||||||
|
expect(item.position).toEqual({ x: 1, z: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getFurniture returns undefined for invalid index', () => {
|
||||||
|
expect(state.getFurniture('room-a', 99)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getFurniture returns undefined for missing room', () => {
|
||||||
|
expect(state.getFurniture('nonexistent', 0)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getRoomFurniture returns array for valid room', () => {
|
||||||
|
const furniture = state.getRoomFurniture('room-a');
|
||||||
|
expect(furniture).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getRoomFurniture returns empty array for missing room', () => {
|
||||||
|
expect(state.getRoomFurniture('nonexistent')).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Write operations ---
|
||||||
|
|
||||||
|
describe('updateFurniture', () => {
|
||||||
|
it('merges changes into furniture item', () => {
|
||||||
|
state.updateFurniture('room-a', 0, { color: 'red', rotation: 45 });
|
||||||
|
const item = state.getFurniture('room-a', 0);
|
||||||
|
expect(item.color).toBe('red');
|
||||||
|
expect(item.rotation).toBe(45);
|
||||||
|
// original fields preserved
|
||||||
|
expect(item.catalogId).toBe('chair-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws for invalid room', () => {
|
||||||
|
expect(() => state.updateFurniture('bad', 0, {})).toThrow('Room not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws for invalid index', () => {
|
||||||
|
expect(() => state.updateFurniture('room-a', 99, {})).toThrow('Furniture not found');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('moveFurniture', () => {
|
||||||
|
it('updates position', () => {
|
||||||
|
state.moveFurniture('room-a', 0, { x: 10, z: 20 });
|
||||||
|
const item = state.getFurniture('room-a', 0);
|
||||||
|
expect(item.position.x).toBe(10);
|
||||||
|
expect(item.position.z).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('partial position update (only x)', () => {
|
||||||
|
state.moveFurniture('room-a', 0, { x: 99 });
|
||||||
|
const item = state.getFurniture('room-a', 0);
|
||||||
|
expect(item.position.x).toBe(99);
|
||||||
|
expect(item.position.z).toBe(2); // unchanged
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rotateFurniture', () => {
|
||||||
|
it('sets rotation', () => {
|
||||||
|
state.rotateFurniture('room-a', 0, 270);
|
||||||
|
expect(state.getFurniture('room-a', 0).rotation).toBe(270);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('addFurniture', () => {
|
||||||
|
it('adds to existing room and returns new index', () => {
|
||||||
|
const idx = state.addFurniture('room-a', {
|
||||||
|
catalogId: 'lamp-1', position: { x: 5, z: 5 }, rotation: 0
|
||||||
|
});
|
||||||
|
expect(idx).toBe(2);
|
||||||
|
expect(state.getRoomFurniture('room-a')).toHaveLength(3);
|
||||||
|
expect(state.getFurniture('room-a', 2).catalogId).toBe('lamp-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates room entry if it does not exist', () => {
|
||||||
|
const idx = state.addFurniture('room-new', {
|
||||||
|
catalogId: 'desk-1', position: { x: 0, z: 0 }, rotation: 0
|
||||||
|
});
|
||||||
|
expect(idx).toBe(0);
|
||||||
|
const room = state.getRoomDesign('room-new');
|
||||||
|
expect(room).toBeDefined();
|
||||||
|
expect(room.furniture).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deep clones the placement (no aliasing)', () => {
|
||||||
|
const placement = { catalogId: 'x', position: { x: 1, z: 1 }, rotation: 0 };
|
||||||
|
state.addFurniture('room-a', placement);
|
||||||
|
placement.position.x = 999;
|
||||||
|
expect(state.getFurniture('room-a', 2).position.x).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('removeFurniture', () => {
|
||||||
|
it('removes and returns the item', () => {
|
||||||
|
const removed = state.removeFurniture('room-a', 0);
|
||||||
|
expect(removed.catalogId).toBe('chair-1');
|
||||||
|
expect(state.getRoomFurniture('room-a')).toHaveLength(1);
|
||||||
|
// remaining item shifted down
|
||||||
|
expect(state.getFurniture('room-a', 0).catalogId).toBe('table-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for invalid room', () => {
|
||||||
|
expect(state.removeFurniture('bad', 0)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for out-of-range index', () => {
|
||||||
|
expect(state.removeFurniture('room-a', -1)).toBeNull();
|
||||||
|
expect(state.removeFurniture('room-a', 99)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loadDesign', () => {
|
||||||
|
it('replaces entire design', () => {
|
||||||
|
const newDesign = { name: 'New', rooms: [{ roomId: 'r1', furniture: [] }] };
|
||||||
|
state.loadDesign(newDesign);
|
||||||
|
expect(state.design.name).toBe('New');
|
||||||
|
expect(state.design.rooms).toHaveLength(1);
|
||||||
|
expect(state.getRoomDesign('room-a')).toBeUndefined();
|
||||||
|
expect(state.getRoomDesign('r1')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deep clones the loaded design', () => {
|
||||||
|
const newDesign = { name: 'New', rooms: [] };
|
||||||
|
state.loadDesign(newDesign);
|
||||||
|
newDesign.name = 'Mutated';
|
||||||
|
expect(state.design.name).toBe('New');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Undo / Redo ---
|
||||||
|
|
||||||
|
describe('undo / redo', () => {
|
||||||
|
it('initially cannot undo or redo', () => {
|
||||||
|
expect(state.canUndo).toBe(false);
|
||||||
|
expect(state.canRedo).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can undo after a mutation', () => {
|
||||||
|
state.moveFurniture('room-a', 0, { x: 99 });
|
||||||
|
expect(state.canUndo).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('undo reverts last mutation', () => {
|
||||||
|
state.moveFurniture('room-a', 0, { x: 99 });
|
||||||
|
state.undo();
|
||||||
|
expect(state.getFurniture('room-a', 0).position.x).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redo re-applies after undo', () => {
|
||||||
|
state.moveFurniture('room-a', 0, { x: 99 });
|
||||||
|
state.undo();
|
||||||
|
state.redo();
|
||||||
|
expect(state.getFurniture('room-a', 0).position.x).toBe(99);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('undo returns false when empty', () => {
|
||||||
|
expect(state.undo()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redo returns false when empty', () => {
|
||||||
|
expect(state.redo()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('new mutation clears redo stack', () => {
|
||||||
|
state.moveFurniture('room-a', 0, { x: 10 });
|
||||||
|
state.undo();
|
||||||
|
expect(state.canRedo).toBe(true);
|
||||||
|
state.rotateFurniture('room-a', 0, 45);
|
||||||
|
expect(state.canRedo).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('multiple undos walk back through history', () => {
|
||||||
|
state.moveFurniture('room-a', 0, { x: 10 });
|
||||||
|
state.moveFurniture('room-a', 0, { x: 20 });
|
||||||
|
state.moveFurniture('room-a', 0, { x: 30 });
|
||||||
|
|
||||||
|
state.undo();
|
||||||
|
expect(state.getFurniture('room-a', 0).position.x).toBe(20);
|
||||||
|
state.undo();
|
||||||
|
expect(state.getFurniture('room-a', 0).position.x).toBe(10);
|
||||||
|
state.undo();
|
||||||
|
expect(state.getFurniture('room-a', 0).position.x).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects max undo limit', () => {
|
||||||
|
// Default _maxUndo is 50
|
||||||
|
for (let i = 0; i < 55; i++) {
|
||||||
|
state.moveFurniture('room-a', 0, { x: i });
|
||||||
|
}
|
||||||
|
// Should have at most 50 undo entries
|
||||||
|
let count = 0;
|
||||||
|
while (state.canUndo) {
|
||||||
|
state.undo();
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
expect(count).toBe(50);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Observers ---
|
||||||
|
|
||||||
|
describe('onChange', () => {
|
||||||
|
it('fires listener on mutation', () => {
|
||||||
|
const listener = vi.fn();
|
||||||
|
state.onChange(listener);
|
||||||
|
state.moveFurniture('room-a', 0, { x: 5 });
|
||||||
|
expect(listener).toHaveBeenCalledWith('furniture-move', { roomId: 'room-a', index: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fires correct event types', () => {
|
||||||
|
const events = [];
|
||||||
|
state.onChange((type) => events.push(type));
|
||||||
|
|
||||||
|
state.updateFurniture('room-a', 0, { color: 'blue' });
|
||||||
|
state.moveFurniture('room-a', 0, { x: 5 });
|
||||||
|
state.rotateFurniture('room-a', 0, 90);
|
||||||
|
state.addFurniture('room-a', { catalogId: 'x', position: { x: 0, z: 0 }, rotation: 0 });
|
||||||
|
state.removeFurniture('room-a', 0);
|
||||||
|
state.loadDesign({ name: 'n', rooms: [{ roomId: 'room-a', furniture: [] }] });
|
||||||
|
state.undo();
|
||||||
|
state.redo();
|
||||||
|
|
||||||
|
expect(events).toEqual([
|
||||||
|
'furniture-update', 'furniture-move', 'furniture-rotate',
|
||||||
|
'furniture-add', 'furniture-remove', 'design-load',
|
||||||
|
'undo', 'redo'
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns unsubscribe function', () => {
|
||||||
|
const listener = vi.fn();
|
||||||
|
const unsub = state.onChange(listener);
|
||||||
|
unsub();
|
||||||
|
state.moveFurniture('room-a', 0, { x: 5 });
|
||||||
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('catches listener errors without breaking', () => {
|
||||||
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
const badListener = () => { throw new Error('boom'); };
|
||||||
|
const goodListener = vi.fn();
|
||||||
|
|
||||||
|
state.onChange(badListener);
|
||||||
|
state.onChange(goodListener);
|
||||||
|
state.moveFurniture('room-a', 0, { x: 5 });
|
||||||
|
|
||||||
|
expect(goodListener).toHaveBeenCalled();
|
||||||
|
expect(errorSpy).toHaveBeenCalled();
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Serialization ---
|
||||||
|
|
||||||
|
describe('toJSON', () => {
|
||||||
|
it('returns a deep clone of state', () => {
|
||||||
|
const json = state.toJSON();
|
||||||
|
expect(json.name).toBe('Test Design');
|
||||||
|
// mutating the clone shouldn't affect state
|
||||||
|
json.name = 'Mutated';
|
||||||
|
expect(state.design.name).toBe('Test Design');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves structure after mutations', () => {
|
||||||
|
state.addFurniture('room-a', { catalogId: 'new', position: { x: 0, z: 0 }, rotation: 0 });
|
||||||
|
const json = state.toJSON();
|
||||||
|
const room = json.rooms.find(r => r.roomId === 'room-a');
|
||||||
|
expect(room.furniture).toHaveLength(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Constructor edge cases ---
|
||||||
|
|
||||||
|
describe('constructor', () => {
|
||||||
|
it('deep clones initial design (no aliasing)', () => {
|
||||||
|
const design = makeDesign();
|
||||||
|
const s = new DesignState(design);
|
||||||
|
design.rooms[0].furniture[0].position.x = 999;
|
||||||
|
expect(s.getFurniture('room-a', 0).position.x).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty rooms array', () => {
|
||||||
|
const s = new DesignState({ name: 'Empty', rooms: [] });
|
||||||
|
expect(s.design.rooms).toHaveLength(0);
|
||||||
|
expect(s.getRoomDesign('any')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles null/undefined state gracefully in _rebuildIndex', () => {
|
||||||
|
const s = new DesignState({ name: 'No rooms' });
|
||||||
|
expect(s.getRoomDesign('any')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
117
tests/themes.test.js
Normal file
117
tests/themes.test.js
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { COLORS } from '../src/renderer.js';
|
||||||
|
import { ThemeManager } from '../src/themes.js';
|
||||||
|
import { Scene, Color } from '../tests/__mocks__/three.js';
|
||||||
|
|
||||||
|
// Snapshot of original COLORS to restore between tests (shared mutable state)
|
||||||
|
const ORIGINAL_COLORS = {
|
||||||
|
wall: { ...COLORS.wall },
|
||||||
|
floor: { ...COLORS.floor },
|
||||||
|
ceiling: COLORS.ceiling,
|
||||||
|
door: COLORS.door,
|
||||||
|
window: COLORS.window,
|
||||||
|
windowFrame: COLORS.windowFrame,
|
||||||
|
grid: COLORS.grid,
|
||||||
|
selected: COLORS.selected
|
||||||
|
};
|
||||||
|
|
||||||
|
function restoreColors() {
|
||||||
|
Object.assign(COLORS.wall, ORIGINAL_COLORS.wall);
|
||||||
|
Object.assign(COLORS.floor, ORIGINAL_COLORS.floor);
|
||||||
|
COLORS.ceiling = ORIGINAL_COLORS.ceiling;
|
||||||
|
COLORS.door = ORIGINAL_COLORS.door;
|
||||||
|
COLORS.window = ORIGINAL_COLORS.window;
|
||||||
|
COLORS.windowFrame = ORIGINAL_COLORS.windowFrame;
|
||||||
|
COLORS.grid = ORIGINAL_COLORS.grid;
|
||||||
|
COLORS.selected = ORIGINAL_COLORS.selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMockRenderer() {
|
||||||
|
const scene = new Scene();
|
||||||
|
// Add mock lights to the scene
|
||||||
|
scene.add({ isAmbientLight: true, intensity: 0.6, traverse: (fn) => fn({ isAmbientLight: true, intensity: 0.6 }) });
|
||||||
|
scene.add({ isDirectionalLight: true, intensity: 0.8, traverse: (fn) => fn({ isDirectionalLight: true, intensity: 0.8 }) });
|
||||||
|
|
||||||
|
return {
|
||||||
|
scene,
|
||||||
|
currentFloor: 0,
|
||||||
|
_clearFloor: vi.fn(),
|
||||||
|
showFloor: vi.fn()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ThemeManager', () => {
|
||||||
|
let tm, mockRenderer;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
restoreColors();
|
||||||
|
mockRenderer = makeMockRenderer();
|
||||||
|
tm = new ThemeManager(mockRenderer);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
restoreColors();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getThemes', () => {
|
||||||
|
it('returns array of theme descriptors', () => {
|
||||||
|
const themes = tm.getThemes();
|
||||||
|
expect(themes.length).toBeGreaterThanOrEqual(4);
|
||||||
|
for (const theme of themes) {
|
||||||
|
expect(theme).toHaveProperty('id');
|
||||||
|
expect(theme).toHaveProperty('name');
|
||||||
|
expect(theme).toHaveProperty('swatch');
|
||||||
|
expect(typeof theme.id).toBe('string');
|
||||||
|
expect(typeof theme.name).toBe('string');
|
||||||
|
expect(theme.swatch).toMatch(/^#[0-9a-f]{6}$/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes expected theme ids', () => {
|
||||||
|
const ids = tm.getThemes().map(t => t.id);
|
||||||
|
expect(ids).toContain('default');
|
||||||
|
expect(ids).toContain('modern');
|
||||||
|
expect(ids).toContain('warm');
|
||||||
|
expect(ids).toContain('dark');
|
||||||
|
expect(ids).toContain('scandinavian');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('constructor', () => {
|
||||||
|
it('starts with default theme', () => {
|
||||||
|
expect(tm.currentTheme).toBe('default');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('applyTheme', () => {
|
||||||
|
it('updates currentTheme property', () => {
|
||||||
|
tm.applyTheme('dark');
|
||||||
|
expect(tm.currentTheme).toBe('dark');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing for invalid theme id', () => {
|
||||||
|
tm.applyTheme('nonexistent');
|
||||||
|
expect(tm.currentTheme).toBe('default');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mutates the shared COLORS object', () => {
|
||||||
|
const origExterior = COLORS.wall.exterior;
|
||||||
|
tm.applyTheme('dark');
|
||||||
|
expect(COLORS.wall.exterior).toBe(0x3a3a3a);
|
||||||
|
// Restore for other tests
|
||||||
|
tm.applyTheme('default');
|
||||||
|
expect(COLORS.wall.exterior).toBe(origExterior);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls _clearFloor and showFloor on renderer', () => {
|
||||||
|
tm.applyTheme('modern');
|
||||||
|
expect(mockRenderer._clearFloor).toHaveBeenCalled();
|
||||||
|
expect(mockRenderer.showFloor).toHaveBeenCalledWith(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates scene background', () => {
|
||||||
|
tm.applyTheme('dark');
|
||||||
|
expect(mockRenderer.scene.background._hex).toBe(0x222222);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
16
vitest.config.js
Normal file
16
vitest.config.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
include: ['tests/**/*.test.js'],
|
||||||
|
setupFiles: ['tests/setup.js'],
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'three/addons/controls/OrbitControls.js': resolve('tests/__mocks__/OrbitControls.js'),
|
||||||
|
'three': resolve('tests/__mocks__/three.js'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user