From cdd7346a717fcfbb1a26a71c1cc89bf583b01951 Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 6 Oct 2026 14:36:15 +0530 Subject: [PATCH 01/31] chore(editor): pin local plugins to package snapshots --- apps/editor/package.json | 7 +++-- bun.lock | 66 +++++++++++++++++++++++++++++++++++++--- 2 files changed, 67 insertions(+), 6 deletions(-) diff --git a/apps/editor/package.json b/apps/editor/package.json index b7c683db11..bf8a86254c 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -21,8 +21,11 @@ "@pascal-app/nodes": "*", "@pascal-app/plugin-bones": "github:pascalorg/plugin-bones#5679260261ee1c733656ff6dfb99e30bb24b58a7", "@pascal-app/plugin-environment": "github:AxiomeCG/environment#1aa0bc3cbed273dbf751e9af79e446f6de105c90", - "@pascal-app/plugin-pool": "github:sudhir9297/pool-pascal-plugin#483a080558293cd344170e66318230e2ac9b6ae8", "@pascal-app/plugin-streetscape": "github:wass08/streetscape-pascal-plugin#1d9fe5e442fb7c0048066f3450ec54ccd8dd1f0f", + "@pascal-app/plugin-bath-space": "github:sudhir9297/pascal-cluster#8f3c0db2be7a33b19f406ecb35e23417ff850dcc", + "@pascal-app/plugin-landscape": "github:sudhir9297/pascal-cluster#cb74244170f8982ed8bc7dc4d2920a0d6ff1452d", + "@pascal-app/plugin-pool": "github:sudhir9297/pascal-cluster#92cb88c3c049e4c299062c962d9f6a0e579cc69b", + "@pascal-app/plugin-streetscape-lab": "github:sudhir9297/pascal-cluster#b31fe375fa47c53b7f792fb5cac7671c09667bd3", "@pascal-app/plugin-trees": "github:pascalorg/plugin-trees#56d978cd9b409b716207b3f3d269455d3cd6f067", "@pascal-app/viewer": "*", "@radix-ui/react-tooltip": "^1.4.1", @@ -30,7 +33,7 @@ "@react-three/fiber": "^9.8.1", "@react-three/xr": "^6.6.30", "@tailwindcss/postcss": "^4.2.1", - "@webxr/plugin": "github:sudhir9297/webxr-pascal-plugin#31063230d0cd5b244b04eca12c6e31b5b757cb5e", + "@webxr/plugin": "github:sudhir9297/pascal-cluster#5e980bebba12c3459d562f3b0e8bf5c67e42c228", "clsx": "^2.1.1", "geist": "^1.7.2", "lucide-react": "^1.57.0", diff --git a/bun.lock b/bun.lock index 42927214ff..a04c56b756 100644 --- a/bun.lock +++ b/bun.lock @@ -36,10 +36,13 @@ "@pascal-app/editor": "*", "@pascal-app/mcp": "*", "@pascal-app/nodes": "*", + "@pascal-app/plugin-bath-space": "github:sudhir9297/pascal-cluster#8f3c0db2be7a33b19f406ecb35e23417ff850dcc", "@pascal-app/plugin-bones": "github:pascalorg/plugin-bones#5679260261ee1c733656ff6dfb99e30bb24b58a7", "@pascal-app/plugin-environment": "github:AxiomeCG/environment#1aa0bc3cbed273dbf751e9af79e446f6de105c90", - "@pascal-app/plugin-pool": "github:sudhir9297/pool-pascal-plugin#483a080558293cd344170e66318230e2ac9b6ae8", "@pascal-app/plugin-streetscape": "github:wass08/streetscape-pascal-plugin#1d9fe5e442fb7c0048066f3450ec54ccd8dd1f0f", + "@pascal-app/plugin-landscape": "github:sudhir9297/pascal-cluster#cb74244170f8982ed8bc7dc4d2920a0d6ff1452d", + "@pascal-app/plugin-pool": "github:sudhir9297/pascal-cluster#92cb88c3c049e4c299062c962d9f6a0e579cc69b", + "@pascal-app/plugin-streetscape-lab": "github:sudhir9297/pascal-cluster#b31fe375fa47c53b7f792fb5cac7671c09667bd3", "@pascal-app/plugin-trees": "github:pascalorg/plugin-trees#56d978cd9b409b716207b3f3d269455d3cd6f067", "@pascal-app/viewer": "*", "@radix-ui/react-tooltip": "^1.4.1", @@ -47,7 +50,7 @@ "@react-three/fiber": "^9.8.1", "@react-three/xr": "^6.6.30", "@tailwindcss/postcss": "^4.2.1", - "@webxr/plugin": "github:sudhir9297/webxr-pascal-plugin#31063230d0cd5b244b04eca12c6e31b5b757cb5e", + "@webxr/plugin": "github:sudhir9297/pascal-cluster#5e980bebba12c3459d562f3b0e8bf5c67e42c228", "clsx": "^2.1.1", "geist": "^1.7.2", "lucide-react": "^1.57.0", @@ -606,6 +609,24 @@ "@keyv/serialize": ["@keyv/serialize@1.1.1", "", {}, "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA=="], + "@mapbox/jsonlint-lines-primitives": ["@mapbox/jsonlint-lines-primitives@2.0.3", "", {}, "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg=="], + + "@mapbox/point-geometry": ["@mapbox/point-geometry@1.1.0", "", {}, "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ=="], + + "@mapbox/tiny-sdf": ["@mapbox/tiny-sdf@2.2.0", "", {}, "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg=="], + + "@mapbox/unitbezier": ["@mapbox/unitbezier@1.0.0", "", {}, "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ=="], + + "@mapbox/vector-tile": ["@mapbox/vector-tile@3.0.0", "", { "dependencies": { "@mapbox/point-geometry": "~1.1.0", "@types/geojson": "^7946.0.16", "pbf": "^5.0.0" } }, "sha512-Qf10S1uIHMk20ri/IVBnpS+esUEkVaR5Hftmz88jTInrpmWgPGJfPe3LVjjlE77trLx8tH6qjTG7uWH9hIq/0Q=="], + + "@maplibre/geojson-vt": ["@maplibre/geojson-vt@6.1.2", "", { "dependencies": { "kdbush": "^4.1.0" } }, "sha512-2ZYhobdAY+y4/P/z1xe0698165LywD0yDdWugdu3NRUrajgVWut+tLkcz7i/RJguMTURwK7gxwmwYMFJbgbQFw=="], + + "@maplibre/maplibre-gl-style-spec": ["@maplibre/maplibre-gl-style-spec@26.4.4", "", { "dependencies": { "@mapbox/jsonlint-lines-primitives": "^2.0.3", "@mapbox/unitbezier": "^1.0.0", "json-stringify-pretty-compact": "^4.0.0", "minimist": "^1.2.8", "quickselect": "^3.0.0", "tinyqueue": "^3.0.0" }, "bin": { "gl-style-format": "dist/gl-style-format.mjs", "gl-style-migrate": "dist/gl-style-migrate.mjs", "gl-style-validate": "dist/gl-style-validate.mjs" } }, "sha512-hG4RZR4PBd/+6A3ODdZ3AnQRF106ZQk1y3bhwDg8bH/A4Oz2tnRUJVVWs9BZa6gXZ1d3UKe89C+Mm+olGBELvA=="], + + "@maplibre/mlt": ["@maplibre/mlt@1.3.0", "", { "dependencies": { "@mapbox/point-geometry": "^1.1.0" } }, "sha512-7M2O7ABM80Oi8XPkfkrjKB8mOBIxGoVgkA52Mu4RMWoBvvMdagQ522+WxVYujZsMpqIr3BpzrTbXrm4k8lCzCQ=="], + + "@maplibre/vt-pbf": ["@maplibre/vt-pbf@4.3.2", "", { "dependencies": { "@mapbox/point-geometry": "^1.1.0", "@types/geojson": "^7946.0.16", "pbf": "^5.1.0" } }, "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw=="], + "@mediapipe/tasks-vision": ["@mediapipe/tasks-vision@0.10.17", "", {}, "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg=="], "@mint/pascal-plugin": ["@mint/pascal-plugin@github:mintdotgg/mint-pascal-plugin#902c546", { "peerDependencies": { "@pascal-app/core": ">=0.9.2 <1.0.0 || >=1.0.0-beta.1 <1.0.0", "@pascal-app/editor": ">=0.9.2 <1.0.0 || >=1.0.0-beta.1 <1.0.0", "@pascal-app/viewer": ">=0.9.2 <1.0.0 || >=1.0.0-beta.1 <1.0.0", "react": "^18 || ^19", "three": "^0.185" } }, "mintdotgg-mint-pascal-plugin-902c546", "sha512-/itUH9r9OIP8ZPrklW8iWe6B2SDOtlL1m8r9hlVM4Rw5josYtDdkDKQ37FbanvxxuUKcQnukHbtxWe3svRaCrQ=="], @@ -810,13 +831,18 @@ "@pascal-app/nodes": ["@pascal-app/nodes@workspace:packages/nodes"], + "@pascal-app/plugin-bath-space": ["@pascal-app/plugin-bath-space@github:sudhir9297/pascal-cluster#8f3c0db", { "dependencies": { "@tanstack/react-virtual": "^3.14.13" }, "peerDependencies": { "@pascal-app/core": "^1.0.1", "@pascal-app/editor": "^1.0.1", "@pascal-app/viewer": "^1.0.1", "@react-three/fiber": "^9", "react": "^18 || ^19", "react-dom": "^18 || ^19", "three": "^0.186.0", "zod": ">=4.5.4 <4.6" } }, "sudhir9297-pascal-cluster-8f3c0db", "sha512-B+z3V0Ok0xf8/WMYWqxvevPIWaEC3D4H8ZXaQ0lMUectIJVCUwURwlbQp57UsGqFhsJP5vswKEZwEVffNd3DaA=="], + "@pascal-app/plugin-bones": ["@pascal-app/plugin-bones@github:pascalorg/plugin-bones#5679260", { "peerDependencies": { "@pascal-app/core": ">=0.9.1 <1", "@pascal-app/editor": ">=0.9.1 <1", "@pascal-app/viewer": ">=0.9.1 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "pascalorg-plugin-bones-5679260", "sha512-uQkyHHOl/VuYx2+d/MmcJui/KEAQZ2UUnO4ywp1XaopmD2YfN+Yx/fR/lS/VL2joEoyyZeBLC3KaXK1Sx5qEvg=="], "@pascal-app/plugin-environment": ["@pascal-app/plugin-environment@github:AxiomeCG/environment#1aa0bc3", { "peerDependencies": { "@dgreenheck/ez-tree": "^1.1.0", "@pascal-app/core": ">=1.0.0-beta.6 <2", "@pascal-app/editor": ">=1.0.0-beta.6 <2", "@pascal-app/viewer": ">=1.0.0-beta.6 <2", "@radix-ui/react-tooltip": "^1.2.8", "@react-three/fiber": "^9", "lucide-react": "^1.7.0", "react": "^18 || ^19", "react-colorful": "^5.8.1", "three": "^0.186", "zod": "^4", "zustand": "^5" } }, "AxiomeCG-environment-1aa0bc3", "sha512-wvCYDEr62ItSwgWf8zOJL0z97zTyZ7TszrFwLOcIhX7LRYrmHhP/6LE7lB6lBBeXNPExg7jRk8eQZm+ri6GfaQ=="], - "@pascal-app/plugin-pool": ["@pascal-app/plugin-pool@github:sudhir9297/pool-pascal-plugin#483a080", { "dependencies": { "three-bvh-csg": "^0.0.18", "three-mesh-bvh": "^0.9.8" }, "peerDependencies": { "@pascal-app/core": ">=0.9.1 <1 || >=1.0.0-beta.0 <2", "@pascal-app/editor": ">=0.9.1 <1 || >=1.0.0-beta.0 <2", "@pascal-app/viewer": ">=0.9.1 <1 || >=1.0.0-beta.0 <2", "@react-three/fiber": "^9", "react": "^18 || ^19", "three": "^0.186.0", "zod": "^4", "zustand": "^5" } }, "sudhir9297-pool-pascal-plugin-483a080", "sha512-amY0DNHKXnjBGLyouD78sIKZ+AbQrX67gQEX2N21I4t9o1F07sObYnU77tLsCTc8TQdm/G7YNSWM1w1sEmwhVw=="], + "@pascal-app/plugin-landscape": ["@pascal-app/plugin-landscape@github:sudhir9297/pascal-cluster#cb74244", { "dependencies": { "clipper-lib": "^6.4.2", "polygon-clipping": "^0.15.7" }, "peerDependencies": { "@pascal-app/core": "^1.0.3", "@pascal-app/editor": "^1.0.3", "@pascal-app/nodes": "^1.0.1", "@pascal-app/viewer": "^1.0.3", "@react-three/drei": "^10", "@react-three/fiber": "^9", "react": "^18 || ^19", "three": "^0.186.0", "zod": ">=4.5.4 <4.6" } }, "sudhir9297-pascal-cluster-cb74244", "sha512-TTFqydUELfX9b2+La3zHHeDezFqvNyOCpM4I6UuQVmVtGMHOCxua6ZWBuySwotENoeoPubONtlwPv0WCGV2zpw=="], "@pascal-app/plugin-streetscape": ["@pascal-app/plugin-streetscape@github:wass08/streetscape-pascal-plugin#1d9fe5e", { "peerDependencies": { "@pascal-app/core": ">=0.9.1 <1", "@pascal-app/editor": ">=0.9.1 <1", "@pascal-app/viewer": ">=0.9.1 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "wass08-streetscape-pascal-plugin-1d9fe5e", "sha512-nohMdi+yTkbXLZpBgyT+X5P7+boSaKLE3rvEWCBLmHhmV0ybfKruKxq3AV0131an4S7MWqjbJkESVGRqMSJp+Q=="], + "@pascal-app/plugin-pool": ["@pascal-app/plugin-pool@github:sudhir9297/pascal-cluster#92cb88c", { "dependencies": { "three-bvh-csg": "^0.0.18", "three-mesh-bvh": "^0.9.8" }, "peerDependencies": { "@pascal-app/core": ">=0.9.1 <1 || >=1.0.0-beta.0 <2", "@pascal-app/editor": ">=0.9.1 <1 || >=1.0.0-beta.0 <2", "@pascal-app/viewer": ">=0.9.1 <1 || >=1.0.0-beta.0 <2", "@react-three/fiber": "^9", "react": "^18 || ^19", "react-dom": "^18 || ^19", "three": "^0.186.0", "zod": "^4", "zustand": "^5" } }, "sudhir9297-pascal-cluster-92cb88c", "sha512-OdmWc1q8ybrIDtrPL5fn4x+7NzQM5wDOLlNrGFRQhH/hQNm1sUwpCEJqKt2nmH5/6/sDIsK3QFkLRteT2k+bOQ=="], + + "@pascal-app/plugin-streetscape-lab": ["@pascal-app/plugin-streetscape-lab@github:sudhir9297/pascal-cluster#b31fe37", { "dependencies": { "lucide-react": "^1.41.0", "maplibre-gl": "^6.7.0" }, "peerDependencies": { "@pascal-app/core": ">=0.9.1 <1", "@pascal-app/editor": ">=0.9.1 <1", "@pascal-app/viewer": ">=0.9.1 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "react-dom": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "sudhir9297-pascal-cluster-b31fe37", "sha512-UdMGQ9eMn8TZfmbN5qxpjnCyB9J1WylzkRRWLDDMI4zb3UPA6NTwZ9U0pXR4PmAAR2VN2b5K4FHR2QAK9pTp0Q=="], "@pascal-app/plugin-trees": ["@pascal-app/plugin-trees@github:pascalorg/plugin-trees#56d978c", { "dependencies": { "@dgreenheck/ez-tree": "^1.1.0" }, "peerDependencies": { "@pascal-app/core": ">=0.9.1 <1", "@pascal-app/editor": ">=0.9.1 <1", "@pascal-app/viewer": ">=0.9.1 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "pascalorg-plugin-trees-56d978c", "sha512-16VzWot1oadvxCPqsRwMJbaP0a3u5FESFy7F7++pY5wAAFq9JTFwzHvKAsllrY5TZ5TJ7Y5vSqvbmQk8sy8HaA=="], @@ -972,6 +998,10 @@ "@tailwindcss/postcss": ["@tailwindcss/postcss@4.3.0", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.3.0", "@tailwindcss/oxide": "4.3.0", "postcss": "^8.5.10", "tailwindcss": "4.3.0" } }, "sha512-Jm05Tjx+9yCLGv5qw1c+84Psds8MnyrEQYCB+FFk2lgGiUjlRqdxke4mVTuYrj2xnVZqKim2Apr5ySuQRYAw/w=="], + "@tanstack/react-virtual": ["@tanstack/react-virtual@3.14.13", "", { "dependencies": { "@tanstack/virtual-core": "3.17.11" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-JbDTAwtzZ99aOeCrAfW5EsE5KSq5RWh6Af2dtFwyLIIk48Ja7vm6n4axu/43T3vfjFnEGJalxJ1wyYjSQD6bSg=="], + + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.11", "", {}, "sha512-+ILjvtHup6Y2hzQ6YzwMgX1Q+oQpxEGOXCEsCNaPoIP0VxMbizIBTmYTDtkerkIQS8/CbP1BRuyt8V/8BCsy1g=="], + "@turbo/darwin-64": ["@turbo/darwin-64@2.9.17", "", { "os": "darwin", "cpu": "x64" }, "sha512-io5jn5RDeU+9YV78rWhwG++HD/OZ/Lxg1sg93+jDGKQNP3UDxY6RX2dmarbCILhNxNuAM8FH3WgGMY9E96Mf8w=="], "@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.9.17", "", { "os": "darwin", "cpu": "arm64" }, "sha512-83YZTYmN2sxFWf2LTMOwqbOvR3qZMa/TSFwnB6BHVBbIWyoPPe+TAdSTd8KevEx8ml8KkycJ/9A70DFVReyUww=="], @@ -998,6 +1028,8 @@ "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + "@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="], + "@types/howler": ["@types/howler@2.2.13", "", {}, "sha512-40+EBjqIHHrC4VShlz/7i0lBUsE3QkgzZinQQji74Hd8sBkJZUBaT7LWFLK6rcabsDOOQpoMbEJvtaFQwxOu/g=="], "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], @@ -1112,7 +1144,7 @@ "@visual-json/react": ["@visual-json/react@0.4.0", "", { "dependencies": { "@visual-json/core": "0.4.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-qiDrXI7wu9VoTE0TaLoJp7kwRj8SQTpkS/wkts5sBPA8078zdrIKc+0u0Y8wAXGYd3udw9dNfD4kmpO48OEwXQ=="], - "@webxr/plugin": ["@webxr/plugin@github:sudhir9297/webxr-pascal-plugin#3106323", { "dependencies": { "@iwer/devui": "2.3.0", "iwer": "2.3.0", "lucide-react": "^1.7.0" }, "peerDependencies": { "@pascal-app/core": "^1.0.0-beta.5", "@pascal-app/editor": "^1.0.0-beta.5", "@pascal-app/viewer": "^1.0.0-beta.5", "@react-three/drei": "^10", "@react-three/fiber": "^9", "@react-three/xr": "^6", "react": "^18 || ^19", "react-dom": "^18 || ^19", "three": "^0.185 || ^0.186", "zustand": "^5" } }, "sudhir9297-webxr-pascal-plugin-3106323", "sha512-Kyfu6u8SeFtddqLcYy8iPXxQ+L2T6qIzhyDYCcy7kehhOf+qX//I7NLRVsO7dQYY+fEbErVbswj0pzdO8nYUFw=="], + "@webxr/plugin": ["@webxr/plugin@github:sudhir9297/pascal-cluster#5e980be", { "dependencies": { "@iwer/devui": "2.3.0", "iwer": "2.3.0", "lucide-react": "^1.7.0" }, "peerDependencies": { "@pascal-app/core": "^1.0.0-beta.5", "@pascal-app/editor": "^1.0.0-beta.5", "@pascal-app/viewer": "^1.0.0-beta.5", "@react-three/drei": "^10", "@react-three/fiber": "^9", "@react-three/xr": "^6", "react": "^18 || ^19", "react-dom": "^18 || ^19", "three": "^0.185 || ^0.186", "zustand": "^5" } }, "sudhir9297-pascal-cluster-5e980be", "sha512-HJfZKgG7W67vQeZd5EbwMgzGdHn1gJhODKGoD9ORmR4jdmrslGzwAFtdFYC5nNHo67CwN9LM/YxRrOI9v/ORLg=="], "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], @@ -1222,6 +1254,8 @@ "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], + "clipper-lib": ["clipper-lib@6.4.2", "", {}, "sha512-knglhjQX5ihNj/XCIs6zCHrTemdvHY3LPZP9XB2nq2/3igyYMFueFXtfp84baJvEE+f8pO1ZS4UVeEgmLnAprQ=="], + "clone": ["clone@2.1.2", "", {}, "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="], "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], @@ -1312,6 +1346,8 @@ "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + "earcut": ["earcut@3.2.4", "", {}, "sha512-FHc6yNpnTORB0tM5kmZ03iD2mDuNakDE2lvf4Hw1XkfxtEHIGVi01B/GqUSwx+NcC1WGjidAfpTXzeALtPtfpg=="], + "editor": ["editor@workspace:apps/editor"], "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], @@ -1630,6 +1666,8 @@ "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + "json-stringify-pretty-compact": ["json-stringify-pretty-compact@4.0.0", "", {}, "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q=="], + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], @@ -1638,6 +1676,8 @@ "keyv": ["keyv@5.6.0", "", { "dependencies": { "@keyv/serialize": "^1.1.1" } }, "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw=="], + "kdbush": ["kdbush@4.1.0", "", {}, "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ=="], + "kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], "ktx-parse": ["ktx-parse@1.1.0", "", {}, "sha512-mKp3y+FaYgR7mXWAbyyzpa/r1zDWeaunH+INJO4fou3hb45XuNSwar+7llrRyvpMWafxSIi99RNFJ05MHedaJQ=="], @@ -1694,6 +1734,8 @@ "manifold-3d": ["manifold-3d@3.5.4", "", { "dependencies": { "@gltf-transform/core": "^4.2.0", "@gltf-transform/extensions": "^4.2.0", "@gltf-transform/functions": "^4.2.0", "@jridgewell/resolve-uri": "^3.1.2", "@jridgewell/trace-mapping": "^0.3.31", "@jscadui/3mf-export": "^0.5.0", "commander": "^13.1.0", "convert-source-map": "^2.0.0", "fast-xml-parser": "^5.4.2", "fflate": "^0.8.0", "magic-string": "^0.30.21" }, "peerDependencies": { "esbuild-wasm": "^0.27.3" }, "bin": { "manifold-cad": "bin/manifold-cad" } }, "sha512-WWsme5HabwLHVcWPHT9m+s7mdmDVSEvCny+uXH1E7jEc8FbFUgM75C3Dh6NVw57Eeh3Ow7+xHCuXfxDMOemi9A=="], + "maplibre-gl": ["maplibre-gl@6.12.0", "", { "dependencies": { "@mapbox/point-geometry": "^1.1.0", "@mapbox/tiny-sdf": "^2.2.0", "@mapbox/unitbezier": "^1.0.0", "@mapbox/vector-tile": "^3.0.0", "@maplibre/geojson-vt": "^6.1.1", "@maplibre/maplibre-gl-style-spec": "^26.4.4", "@maplibre/mlt": "^1.3.0", "@maplibre/vt-pbf": "^4.3.2", "@types/geojson": "^7946.0.16", "bidi-js": "^1.1.0", "earcut": "^3.2.4", "gl-matrix": "^3.4.4", "kdbush": "^4.1.0", "murmurhash-js": "^1.0.0", "pbf": "^5.1.2", "potpack": "^2.1.0", "quickselect": "^3.0.0", "tinyqueue": "^3.0.0" } }, "sha512-DwgganVi2BhNxOpD7ob3lJC0dQQz4HfJfuQQV3XxCY3XdOzFrMqp9ylMXhRzryRsOnIezdZcklWXIIs/Q8JPjA=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], @@ -1740,6 +1782,8 @@ "nanoid": ["nanoid@6.0.2", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-hIvVOb9Qr1p/9CNNLOe0OlWvgx/ziTsVwKN/GDSsuArN2K0wDiafP3s0SWmsm7o2BdnyS8Z9Z64NFBUbn6ziBg=="], + "murmurhash-js": ["murmurhash-js@1.0.0", "", {}, "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw=="], + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], "ndarray": ["ndarray@1.0.19", "", { "dependencies": { "iota-array": "^1.0.0", "is-buffer": "^1.0.2" } }, "sha512-B4JHA4vdyZU30ELBw3g7/p9bZupyew5a7tX1Y/gGeF2hafrPaQZhgrGQfsvgfYbgdFZjYwuEcnaobeM/WMW+HQ=="], @@ -1826,6 +1870,8 @@ "pdfkit": ["pdfkit@0.20.2", "", { "dependencies": { "@noble/ciphers": "^1.3.0", "@noble/hashes": "^1.8.0", "fflate": "^0.8.3", "fontkit": "^2.0.4", "linebreak": "^1.1.0", "png-js": "^2.0.0" } }, "sha512-Q/w03ICAQyXfHNfTsg1udp0ADerdBN0s7a6XSPL7J7Ro6ABnafoBOCDBstIO9U02mvzHEV8HrvFIw5iV5ejIRA=="], + "pbf": ["pbf@5.1.2", "", { "dependencies": { "resolve-protobuf-schema": "^2.1.0" }, "bin": { "pbf": "bin/pbf" } }, "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], @@ -1856,6 +1902,8 @@ "property-graph": ["property-graph@4.1.0", "", {}, "sha512-AvPcP7XECNWy4LGmFQ77k7un4lSKM4eS29PTvW4ck95uYeLxXPWJM7hLuBqK91FaHqCcgJvIUCuNJjjxKE7VKQ=="], + "protocol-buffers-schema": ["protocol-buffers-schema@3.6.1", "", {}, "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ=="], + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], @@ -1868,6 +1916,8 @@ "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + "quickselect": ["quickselect@3.0.0", "", {}, "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g=="], + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], @@ -1906,6 +1956,8 @@ "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + "resolve-protobuf-schema": ["resolve-protobuf-schema@2.1.0", "", { "dependencies": { "protocol-buffers-schema": "^3.3.1" } }, "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ=="], + "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], "restructure": ["restructure@3.0.2", "", {}, "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw=="], @@ -2030,6 +2082,8 @@ "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + "tinyqueue": ["tinyqueue@3.0.0", "", {}, "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g=="], + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], @@ -2280,6 +2334,10 @@ "manifold-3d/commander": ["commander@13.1.0", "", {}, "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw=="], + + "maplibre-gl/bidi-js": ["bidi-js@1.1.0", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg=="], + + "maplibre-gl/potpack": ["potpack@2.1.0", "", {}, "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ=="], "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "ndarray-pixels/sharp": ["sharp@0.35.3", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.3", "@img/sharp-darwin-x64": "0.35.3", "@img/sharp-freebsd-wasm32": "0.35.3", "@img/sharp-libvips-darwin-arm64": "1.3.2", "@img/sharp-libvips-darwin-x64": "1.3.2", "@img/sharp-libvips-linux-arm": "1.3.2", "@img/sharp-libvips-linux-arm64": "1.3.2", "@img/sharp-libvips-linux-ppc64": "1.3.2", "@img/sharp-libvips-linux-riscv64": "1.3.2", "@img/sharp-libvips-linux-s390x": "1.3.2", "@img/sharp-libvips-linux-x64": "1.3.2", "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", "@img/sharp-libvips-linuxmusl-x64": "1.3.2", "@img/sharp-linux-arm": "0.35.3", "@img/sharp-linux-arm64": "0.35.3", "@img/sharp-linux-ppc64": "0.35.3", "@img/sharp-linux-riscv64": "0.35.3", "@img/sharp-linux-s390x": "0.35.3", "@img/sharp-linux-x64": "0.35.3", "@img/sharp-linuxmusl-arm64": "0.35.3", "@img/sharp-linuxmusl-x64": "0.35.3", "@img/sharp-webcontainers-wasm32": "0.35.3", "@img/sharp-win32-arm64": "0.35.3", "@img/sharp-win32-ia32": "0.35.3", "@img/sharp-win32-x64": "0.35.3" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q=="], From 9a1068e4c51dffa902169075af992e1d4ad0e3be Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 6 Oct 2026 14:40:25 +0530 Subject: [PATCH 02/31] feat(editor): integrate local plugins and deck railing workflows --- apps/editor/app/globals.css | 28 ++- .../editor/components/pool-section-banner.tsx | 11 ++ apps/editor/components/scene-loader.tsx | 2 + apps/editor/lib/bootstrap.ts | 8 +- apps/editor/next.config.ts | 33 +++- .../core/src/contracts/reference-inventory.ts | 13 ++ .../hooks/spatial-grid/support-host-patch.ts | 7 + packages/core/src/lib/room-graph.ts | 1 + packages/core/src/lib/room-topology-index.ts | 2 +- packages/core/src/lib/space-detection.test.ts | 12 ++ packages/core/src/lib/structure-kernel.ts | 1 + packages/core/src/registry/types.ts | 18 ++ packages/core/src/schema/nodes/fence.ts | 4 + packages/core/src/schema/nodes/stair.test.ts | 22 +++ packages/core/src/schema/nodes/stair.ts | 3 + .../src/services/port-connectivity.test.ts | 58 +++++++ .../core/src/services/port-connectivity.ts | 35 ++++ .../floorplan-registry-move-overlay.tsx | 12 +- .../renderers/floorplan-registry-layer.tsx | 9 +- .../src/components/editor/floorplan-panel.tsx | 22 +-- .../use-floorplan-background-placement.ts | 5 +- .../tools/item/use-placement-coordinator.tsx | 47 ++--- .../tools/registry/item-surface-move.ts | 2 + .../registry/move-registry-node-tool.tsx | 90 ++++++---- .../tools/stair/landscape-snap.test.ts | 106 ++++++++++++ .../components/tools/stair/landscape-snap.ts | 146 ++++++++++++++++ .../src/components/tools/stair/stair-tool.tsx | 144 ++++++++++++---- .../components/ui/panels/panel-wrapper.tsx | 71 +++++--- packages/editor/src/index.tsx | 18 ++ .../editor/src/lib/direct-manipulation.ts | 2 + .../editor/src/lib/fresh-planar-placement.ts | 5 +- packages/editor/src/lib/glb-export.ts | 5 +- packages/editor/src/lib/selection-routing.ts | 2 +- .../editor/src/lib/wall-placement-pointer.ts | 49 ++++++ .../src/store/use-stair-build-preview.ts | 1 - packages/nodes/package.json | 5 + packages/nodes/src/fence/drafting.ts | 57 ++++++ packages/nodes/src/fence/geometry.ts | 1 + packages/nodes/src/fence/lift.ts | 19 +- packages/nodes/src/fence/tool.tsx | 81 ++++++++- .../nodes/src/shared/distribution-public.ts | 19 ++ .../src/shared/distribution-run-tool.tsx | 4 +- packages/nodes/src/shared/move-roof-tool.tsx | 162 ++++++++++++++++-- .../nodes/src/stair/floorplan-move.test.ts | 116 +++++++++++++ packages/nodes/src/stair/floorplan-move.ts | 105 +++++++++++- packages/nodes/src/stair/floorplan.test.ts | 60 +++++++ packages/nodes/src/stair/floorplan.ts | 3 +- .../src/stair/landscape-move-preview.test.ts | 56 ++++++ .../nodes/src/stair/landscape-move-preview.ts | 58 +++++++ .../src/stair/landscape-transition.test.ts | 70 ++++++++ .../nodes/src/stair/landscape-transition.ts | 86 ++++++++++ packages/nodes/src/stair/panel.tsx | 19 ++ packages/nodes/src/stair/renderer.tsx | 6 +- packages/nodes/src/wall/tool.tsx | 9 +- .../viewer/src/lib/merged-outline-node.ts | 89 +++++++++- .../src/systems/stair/stair-system.test.tsx | 64 +++++++ 56 files changed, 1886 insertions(+), 197 deletions(-) create mode 100644 apps/editor/components/pool-section-banner.tsx create mode 100644 packages/core/src/schema/nodes/stair.test.ts create mode 100644 packages/editor/src/components/tools/stair/landscape-snap.test.ts create mode 100644 packages/editor/src/components/tools/stair/landscape-snap.ts create mode 100644 packages/editor/src/lib/wall-placement-pointer.ts create mode 100644 packages/nodes/src/shared/distribution-public.ts create mode 100644 packages/nodes/src/stair/floorplan-move.test.ts create mode 100644 packages/nodes/src/stair/landscape-move-preview.test.ts create mode 100644 packages/nodes/src/stair/landscape-move-preview.ts create mode 100644 packages/nodes/src/stair/landscape-transition.test.ts create mode 100644 packages/nodes/src/stair/landscape-transition.ts create mode 100644 packages/viewer/src/systems/stair/stair-system.test.tsx diff --git a/apps/editor/app/globals.css b/apps/editor/app/globals.css index 77fd6e1d02..85ebc5a59a 100644 --- a/apps/editor/app/globals.css +++ b/apps/editor/app/globals.css @@ -5,9 +5,14 @@ @source "../../../packages/nodes/src"; @source "../../../node_modules/@pascal-app/plugin-trees/src"; @source "../../../node_modules/@mint/pascal-plugin/src"; -@source "../../../node_modules/@pascal-app/plugin-streetscape/src"; +@source "../node_modules/@pascal-app/plugin-streetscape-lab/src"; @source "../../../node_modules/@pascal-app/plugin-environment/src"; +@source "../node_modules/@pascal-app/plugin-landscape/src"; +@source "../node_modules/@pascal-app/plugin-bath-space/src"; +@source "../node_modules/@pascal-app/plugin-pool/src"; +@source "../node_modules/@webxr/plugin/src"; + @custom-variant dark (&:is(.dark *)); @theme { @@ -171,6 +176,27 @@ scrollbar-width: none; /* Firefox */ } +* { + scrollbar-width: thin; + scrollbar-color: gray transparent; +} + +*::-webkit-scrollbar { + width: 8px; + height: 8px; + background: transparent; +} + +*::-webkit-scrollbar-track, +*::-webkit-scrollbar-track-piece, +*::-webkit-scrollbar-corner { + background: transparent; +} + +*::-webkit-scrollbar-thumb { + background-color: gray; +} + @media (prefers-reduced-motion: reduce) { *, *::before, diff --git a/apps/editor/components/pool-section-banner.tsx b/apps/editor/components/pool-section-banner.tsx new file mode 100644 index 0000000000..6e249de9f6 --- /dev/null +++ b/apps/editor/components/pool-section-banner.tsx @@ -0,0 +1,11 @@ +'use client' + +import { PoolSectionBar } from '@pascal-app/plugin-pool' + +export function PoolSectionBanner() { + return ( +
+ +
+ ) +} diff --git a/apps/editor/components/scene-loader.tsx b/apps/editor/components/scene-loader.tsx index 251c1bb030..06654c6c06 100644 --- a/apps/editor/components/scene-loader.tsx +++ b/apps/editor/components/scene-loader.tsx @@ -15,6 +15,7 @@ import { Hammer, Layers, Package, Palette, Settings } from 'lucide-react' import Image from 'next/image' import { useRouter, useSearchParams } from 'next/navigation' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { PoolSectionBanner } from '@/components/pool-section-banner' import { countGraphNodes, isEmptyGraphOverwrite } from '@/lib/empty-graph-guard' import { useLocalArtifactStore } from '@/lib/local-artifact-store' import { type PersistedSceneGraph, sceneGraphSignature } from '@/lib/scene-signature' @@ -379,6 +380,7 @@ function SceneEditor({ initialScene, meta: initialMeta }: SceneLoaderProps) { projectId={meta.projectId ?? 'default'} projectName={meta.name} sidebarTabs={SIDEBAR_TABS} + viewerBanner={} viewerToolbarLeft={} viewerToolbarRight={ [mintPlugin]) registerEditorHostPanel(mintHostPanel) extendPluginDiscovery(async () => [poolPlugin]) registerEditorHostPanel(poolHostPanel) +extendPluginDiscovery(async () => [landscapePlugin]) +registerEditorHostPanel(landscapeHostPanel) +extendPluginDiscovery(async () => [bathSpacePlugin]) +registerEditorHostPanel(bathSpaceHostPanel) extendPluginDiscovery(async () => [streetscapePlugin]) // The upstream manifest still names 'Pascal' as creator; credit the author. registerEditorHostPanel({ diff --git a/apps/editor/next.config.ts b/apps/editor/next.config.ts index 741c8ec93c..cf0592a59b 100644 --- a/apps/editor/next.config.ts +++ b/apps/editor/next.config.ts @@ -1,9 +1,37 @@ +import { readFileSync } from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' import type { NextConfig } from 'next' const appDirectory = path.dirname(fileURLToPath(import.meta.url)) const portableBuild = process.env.PASCAL_PORTABLE_BUILD === '1' +const appPackageJson = JSON.parse( + readFileSync(path.join(appDirectory, 'package.json'), 'utf8'), +) as { + dependencies?: Record + devDependencies?: Record +} +const localPluginPaths = Object.values({ + ...appPackageJson.dependencies, + ...appPackageJson.devDependencies, +}) + .filter((dependency) => dependency.startsWith('file:')) + .map((dependency) => path.resolve(appDirectory, dependency.slice('file:'.length))) +const turbopackRoot = [path.resolve(appDirectory, '../..'), ...localPluginPaths].reduce( + (commonRoot, candidate) => { + const commonParts = commonRoot.split(path.sep) + const candidateParts = candidate.split(path.sep) + let sharedParts = 0 + while ( + sharedParts < commonParts.length && + sharedParts < candidateParts.length && + commonParts[sharedParts] === candidateParts[sharedParts] + ) { + sharedParts += 1 + } + return commonParts.slice(0, sharedParts).join(path.sep) || path.parse(commonRoot).root + }, +) const nextConfig: NextConfig = { ...(portableBuild @@ -44,7 +72,9 @@ const nextConfig: NextConfig = { '@pascal-app/editor', '@pascal-app/mcp', '@pascal-app/plugin-pool', - '@pascal-app/plugin-streetscape', + '@pascal-app/plugin-bath-space', + '@pascal-app/plugin-landscape', + '@pascal-app/plugin-streetscape-lab', '@pascal-app/plugin-trees', '@mint/pascal-plugin', '@pascal-app/plugin-bones', @@ -53,6 +83,7 @@ const nextConfig: NextConfig = { '@dgreenheck/ez-tree', ], turbopack: { + root: turbopackRoot, resolveAlias: { react: './node_modules/react', three: './node_modules/three', diff --git a/packages/core/src/contracts/reference-inventory.ts b/packages/core/src/contracts/reference-inventory.ts index 971866fda5..505223f588 100644 --- a/packages/core/src/contracts/reference-inventory.ts +++ b/packages/core/src/contracts/reference-inventory.ts @@ -239,6 +239,19 @@ export const EXISTING_REFERENCES: readonly ExistingReference[] = [ targetKinds: ['slab'], remaps: [...CLONES, 'delete-nodes'], }), + row({ + kind: 'fence', + path: 'supportSurfaceId', + ...policy('node', 'host', 'drop', 'strip'), + remaps: [...CLONES, 'delete-nodes'], + note: 'A registered walking surface supporting a railing.', + }), + row({ + kind: 'stair', + path: 'landscapeSurfaceId', + ...policy('node', 'host', 'drop', 'strip'), + note: 'An external landscape plugin surface; the built-in schema cannot enumerate its kinds.', + }), row({ kind: 'roof', path: 'support.roofSegmentId', diff --git a/packages/core/src/hooks/spatial-grid/support-host-patch.ts b/packages/core/src/hooks/spatial-grid/support-host-patch.ts index c77effaf6b..caeabd7032 100644 --- a/packages/core/src/hooks/spatial-grid/support-host-patch.ts +++ b/packages/core/src/hooks/spatial-grid/support-host-patch.ts @@ -386,6 +386,13 @@ export function resolveFenceConstructionSupport( supportSurfaceNodeId: options.supportSurfaceNodeId, } } + const surface = fence.supportSurfaceId ? nodes[fence.supportSurfaceId] : null + if ( + surface?.parentId === levelId && + nodeRegistry.get(surface.type)?.capabilities?.surfaces?.top + ) { + return { ...fence, supportSlabId: undefined } + } const supportPatch = resolveFenceSupportSlabPatch({ ...fence, parentId: levelId }, nodes, { maxElevation: options?.supportCap ?? null, preferredSlabId: options?.preferredSupportSlabId ?? null, diff --git a/packages/core/src/lib/room-graph.ts b/packages/core/src/lib/room-graph.ts index 2b7a2dbd7a..bd4d82142d 100644 --- a/packages/core/src/lib/room-graph.ts +++ b/packages/core/src/lib/room-graph.ts @@ -988,6 +988,7 @@ export function extractRooms( boundaries: BoundaryNode[], options: { includeHoles?: boolean } = {}, ): ExtractedRoom[] { + boundaries = boundaries.filter((boundary) => boundary.metadata?.roomBoundary !== false) const joined = extractRoomGraph(boundaries, options, true) if (!joined.bodyJoints) return joined.rooms const plain = extractRoomGraph(boundaries, options, false).rooms diff --git a/packages/core/src/lib/room-topology-index.ts b/packages/core/src/lib/room-topology-index.ts index 5e7638ead8..a3a8960061 100644 --- a/packages/core/src/lib/room-topology-index.ts +++ b/packages/core/src/lib/room-topology-index.ts @@ -255,7 +255,7 @@ export class RoomTopologyIndex { private isBoundary(node: any): node is BoundaryNode { return ( - node?.type === 'wall' || + (node?.type === 'wall' && node.metadata?.roomBoundary !== false) || (this.options.includeSeparators !== false && node?.type === 'separator') ) } diff --git a/packages/core/src/lib/space-detection.test.ts b/packages/core/src/lib/space-detection.test.ts index f81d621e6d..1570d3f582 100644 --- a/packages/core/src/lib/space-detection.test.ts +++ b/packages/core/src/lib/space-detection.test.ts @@ -2184,6 +2184,18 @@ describe('joints never cost a room the plain rules found', () => { }) describe('wallClosesRoom', () => { + test('retaining walls do not enclose rooms, even in a closed loop', () => { + const walls = squareWalls().map((wall) => + WallNode.parse({ + ...wall, + metadata: { ...wall.metadata, landscapeRetainingWall: true, roomBoundary: false }, + }), + ) + expect(detectSpacesForLevel('level-1', walls).rooms).toHaveLength(0) + expect(wallClosesRoom(walls, walls[3]!)).toBe(false) + expect(detectSpacesForLevel('level-1', squareWalls()).rooms).toHaveLength(1) + }) + test('is false while a chain is still open, true once it encloses a room', () => { const open = [ WallNode.parse({ start: [0, 0], end: [4, 0] }), diff --git a/packages/core/src/lib/structure-kernel.ts b/packages/core/src/lib/structure-kernel.ts index 81c1b12b3b..fc2c96324f 100644 --- a/packages/core/src/lib/structure-kernel.ts +++ b/packages/core/src/lib/structure-kernel.ts @@ -172,6 +172,7 @@ export function classifyWallSides( spans: readonly BoundarySpan[], outdoor: ReadonlySet = new Set(), ): Pick { + if (wall.metadata?.roomBoundary === false) return { frontSide: 'unknown', backSide: 'unknown' } const boundary = spans.filter((span) => span.boundaryId === wall.id) if (!boundary.length) return { frontSide: wall.frontSide, backSide: wall.backSide } const indoor = (face: BoundarySpan['face']) => diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index 5b43f29d03..6132520456 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -583,6 +583,8 @@ export type FloorplanGeometry = * affordances (rotation, scale) without expanding the union. */ variant?: 'endpoint' | 'curve' + /** Keep the drag target the same size in pixels when zooming out. */ + screenSized?: boolean affordance: string payload: unknown } @@ -982,6 +984,9 @@ export type FloorplanMoveTarget = (args: { * * Extensions surface only when the contributing plugin is installed in * the project (same `installedPlugins` gate as panels and node kinds). + * An extension with `primaryWhen` replaces the regular controls for + * matching nodes on desktop and mobile. Its component supplies its own + * `PanelSection` groups. */ export type InspectorExtension = { /** Globally unique id, e.g. `pascal:bones:wall-engineering`. */ @@ -996,6 +1001,8 @@ export type InspectorExtension = { title: string /** Lazy section body; receives `{ node }` (the selected node). */ component: LazyComponent + /** When true for the selected node, this extension owns the inspector body. */ + primaryWhen?: (node: AnyNode) => boolean } export type Plugin = { @@ -1097,6 +1104,11 @@ export type NodeDefinition> = { * Has no effect when `distributionRole` is not `'fitting'`. */ portConnectivityFollow?: boolean + connectedMove?: ( + node: z.infer, + preview: z.infer, + nodes: Readonly>, + ) => { id: AnyNodeId; data: Partial }[] defaults: () => Omit, 'id' | 'type'> @@ -2196,6 +2208,8 @@ export type CutsContext = { nodes: Readonly> } export type MovableConfig = { axes: ReadonlyArray<'x' | 'y' | 'z'> gridSnap?: boolean + /** Keep an existing floor-placed node on its current level and visual elevation while dragging. */ + preserveLevelAndElevation?: boolean /** Allow an ordinary primary-button body drag to enter the move tool. */ directDrag?: boolean /** @@ -2265,6 +2279,8 @@ export type MovableConfig = { } export type MovableParentFrame = { + /** Keep selection and manipulation on the child instead of promoting its parent. */ + independent?: boolean /** The parent node owning the local frame; `null` → move in plan frame. */ resolveParent: (node: AnyNode, nodes: Readonly>) => AnyNode | null /** Parent's Y rotation, composed onto the child's preview rotation. */ @@ -2412,6 +2428,8 @@ export type SurfacesConfig = { z: number, context: { nodes: Readonly> }, ) => number | null + /** Closed walking-surface perimeter in the host's parent XZ frame; optional inward offset in metres. */ + boundary?: (n: AnyNode, inset?: number) => ReadonlyArray } sides?: { faces: 'all' | ReadonlyArray } custom?: SurfaceQuery diff --git a/packages/core/src/schema/nodes/fence.ts b/packages/core/src/schema/nodes/fence.ts index f689470995..a336296f4d 100644 --- a/packages/core/src/schema/nodes/fence.ts +++ b/packages/core/src/schema/nodes/fence.ts @@ -82,6 +82,10 @@ export const FenceNode = BaseNode.extend({ // is resolved again for both preview and committed geometry. supportSurfaceNodeId: z.string().optional(), // Manual vertical offset from the elected slab, shaped surface, or level base. + // Registered top surface supporting a drawn railing on the same level. + supportSurfaceId: z.string().optional(), + // Manual vertical offset from the elected slab or level support. This moves + // the complete fence body without changing its height. supportOffset: z.number().finite().optional(), surfaceMode: FenceSurfaceMode.default('auto'), transitionMode: FenceTransitionMode.default('slope'), diff --git a/packages/core/src/schema/nodes/stair.test.ts b/packages/core/src/schema/nodes/stair.test.ts new file mode 100644 index 0000000000..e77d062afc --- /dev/null +++ b/packages/core/src/schema/nodes/stair.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from 'bun:test' +import { StairNode } from './stair' + +describe('StairNode landscape connection', () => { + test('defaults magnetic landscape snapping on and preserves its surface link', () => { + const legacy = StairNode.parse({ id: 'stair_legacy', type: 'stair' }) + expect(legacy.autoLandscapeSnap).toBeUndefined() + expect(legacy.landscapeSurfaceId).toBeUndefined() + + const attached = StairNode.parse({ + ...legacy, + autoLandscapeSnap: false, + landscapeSurfaceId: 'patio_1', + }) + expect(attached.autoLandscapeSnap).toBe(false) + expect(attached.landscapeSurfaceId).toBe('patio_1') + expect(StairNode.parse(JSON.parse(JSON.stringify(attached)))).toMatchObject({ + autoLandscapeSnap: false, + landscapeSurfaceId: 'patio_1', + }) + }) +}) diff --git a/packages/core/src/schema/nodes/stair.ts b/packages/core/src/schema/nodes/stair.ts index 79e875a3f5..0eb5e3a9b5 100644 --- a/packages/core/src/schema/nodes/stair.ts +++ b/packages/core/src/schema/nodes/stair.ts @@ -64,6 +64,8 @@ export const StairNode = BaseNode.extend({ designTargets: StairDesignTargets.optional(), uniformRisers: z.boolean().optional(), stepCount: z.number().default(10), + autoLandscapeSnap: z.boolean().optional(), + landscapeSurfaceId: z.string().optional(), thickness: z.number().default(0.25), fillToFloor: z.boolean().default(true), construction: StairConstruction.optional(), @@ -112,6 +114,7 @@ export const StairNode = BaseNode.extend({ - totalRise: total stair height - stepCount: number of visible steps - construction: optional explicit construction and finish details, inherited by child segments; absence preserves legacy bodies + - autoLandscapeSnap: magnetically connect straight flights to nearby landscape surfaces - thickness: stair slab / tread thickness - fillToFloor: whether the stair mass fills down to the floor or uses tread thickness only - innerRadius: inner curve radius for curved stairs diff --git a/packages/core/src/services/port-connectivity.test.ts b/packages/core/src/services/port-connectivity.test.ts index 7d9cc1ea5c..01110b1392 100644 --- a/packages/core/src/services/port-connectivity.test.ts +++ b/packages/core/src/services/port-connectivity.test.ts @@ -347,3 +347,61 @@ describe('port connectivity — joint follow (stretch vs translate)', () => { expect(connectivity.connections.find((c) => c.nodeId === distant.id)).toBeUndefined() }) }) + +test('kind-owned connectivity keeps fixed neighbors and snapshots custom move followers', () => { + registerNode({ + kind: 'test:connected-equipment', + schemaVersion: 1, + schema: {}, + category: 'utility', + defaults: () => ({}), + connectedMove: (_node: AnyNode, preview: AnyNode, nodes: Record) => { + const position = (preview as unknown as { position: Point }).position + const path = (nodes['custom_run'] as unknown as { path: Point[] }).path + return [{ id: 'custom_run' as AnyNodeId, data: { path: [position, path[1]] } }] + }, + } as unknown as AnyNodeDefinition) + const equipment = { + id: 'equipment', + type: 'test:connected-equipment', + position: [0, 0, 0], + } as unknown as AnyNode + const run = { + id: 'custom_run', + type: 'duct-segment', + path: [ + [0, 0, 0], + [5, 0, 0], + ], + } as unknown as AnyNode + const snapshot = analyzePortConnectivity(equipment, { [equipment.id]: equipment, [run.id]: run }) + expect(snapshot.connections).toEqual([ + { + kind: 'run', + nodeId: 'custom_run', + startPath: [ + [0, 0, 0], + [5, 0, 0], + ], + }, + ]) + const updated = resolveConnectivityUpdates(snapshot, { + ...equipment, + position: [2, 0, 3], + } as unknown as AnyNode) + expect(updated).toEqual([ + { + id: 'custom_run', + data: { + path: [ + [2, 0, 3], + [5, 0, 0], + ], + }, + }, + ]) + expect((run as unknown as { path: Point[] }).path).toEqual([ + [0, 0, 0], + [5, 0, 0], + ]) +}) diff --git a/packages/core/src/services/port-connectivity.ts b/packages/core/src/services/port-connectivity.ts index 57c0b0d459..f44160e9c8 100644 --- a/packages/core/src/services/port-connectivity.ts +++ b/packages/core/src/services/port-connectivity.ts @@ -84,6 +84,7 @@ type GraphNode = { type Adjacency = Record>> export type PortConnectivity = { + customResolver?: (preview: AnyNode) => { id: AnyNodeId; data: Partial }[] movedNodeId: AnyNodeId /** The moved node's port world positions at edit-start, keyed by port id — * the reference each frame's delta is measured from. */ @@ -144,6 +145,39 @@ export function analyzePortConnectivity( movedNode: AnyNode, nodes: Record, ): PortConnectivity { + const custom = nodeRegistry.get(movedNode.type)?.connectedMove + if (custom) { + const resolve = (preview: AnyNode) => custom(movedNode, preview, nodes) + const initial = resolve(movedNode) + return { + movedNodeId: movedNode.id as AnyNodeId, + customResolver: resolve, + startMovedPorts: {}, + graph: {}, + adjacency: {}, + connections: initial.flatMap((update) => { + const path = (nodes[update.id] as unknown as { path?: Point[] })?.path + if (path) + return [ + { + kind: 'run' as const, + nodeId: update.id, + startPath: path.map((p) => [...p] as Point), + }, + ] as PortConnection[] + const position = (nodes[update.id] as unknown as { position?: Point })?.position + return position + ? [ + { + kind: 'rigid-node' as const, + nodeId: update.id, + startPosition: [...position] as Point, + }, + ] + : [] + }), + } + } const epsSq = COINCIDENT_EPS_M * COINCIDENT_EPS_M const movedPorts = portsOf(movedNode) ?? [] @@ -353,6 +387,7 @@ export function resolveConnectivityUpdates( connectivity: PortConnectivity, previewNode: AnyNode, ): { id: AnyNodeId; data: Partial }[] { + if (connectivity.customResolver) return connectivity.customResolver(previewNode) const { graph, adjacency, startMovedPorts, movedNodeId } = connectivity if (Object.keys(graph).length === 0) return [] diff --git a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx index ef1de406d7..2fb8cb70cf 100644 --- a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx @@ -82,12 +82,15 @@ const ALIGNMENT_THRESHOLD_M = 0.08 */ export function FloorplanRegistryMoveOverlay() { const source = useMovingNode() + const currentStair = useScene((state) => + source?.type === 'stair' ? state.nodes[source.id] : undefined, + ) const movingNode = useMemo( () => - source && isInteractionSubtreeDraft(source.id) - ? (useScene.getState().nodes[source.id] ?? source) + source && (source.type === 'stair' || isInteractionSubtreeDraft(source.id)) + ? (currentStair ?? useScene.getState().nodes[source.id] ?? source) : source, - [source], + [source, currentStair], ) const setMovingNode = useEditor((s) => s.setMovingNode) const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin) @@ -949,6 +952,9 @@ export function FloorplanRegistryMoveOverlay() { { position: [sx, oldY, sz], ...rotationPatch, + ...(movingNode.type === 'stair' && movingNode.landscapeSurfaceId + ? { landscapeSurfaceId: undefined } + : {}), } as Partial, ) } diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx index 0b2fb6c0e8..f23e2a8c79 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx @@ -2690,6 +2690,7 @@ export const InteractiveGeometry = memo(function InteractiveGeometry({ } case 'endpoint-handle': { if (!palette) return <> + const endpointUnitsPerPixel = g.screenSized ? unitsPerPixel : handleUnitsPerPixel const handleId = makeHandleId(nodeId, g.payload) const doubleClickAffordance = floorplanHandleDoubleClickAffordance(g) const isHovered = hoveredHandleId === handleId @@ -2715,10 +2716,10 @@ export const InteractiveGeometry = memo(function InteractiveGeometry({ : palette.endpointHandleFill const outerRadius = (isActive ? ENDPOINT_HANDLE_ACTIVE_RADIUS_PX : ENDPOINT_HANDLE_SELECTED_RADIUS_PX) * - handleUnitsPerPixel + endpointUnitsPerPixel const dotRadius = (isActive ? ENDPOINT_HANDLE_ACTIVE_DOT_RADIUS_PX : ENDPOINT_HANDLE_DOT_RADIUS_PX) * - handleUnitsPerPixel + endpointUnitsPerPixel return ( @@ -2746,7 +2747,7 @@ export const InteractiveGeometry = memo(function InteractiveGeometry({ r={outerRadius} stroke={hoverStroke} strokeOpacity={isActive ? 0.72 : 0.52} - strokeWidth={ENDPOINT_HOVER_RING_STROKE_WIDTH_PX * handleUnitsPerPixel} + strokeWidth={ENDPOINT_HOVER_RING_STROKE_WIDTH_PX * endpointUnitsPerPixel} style={{ opacity: isHovered || isActive ? 1 : 0, transition: HOVER_TRANSITION }} vectorEffect="non-scaling-stroke" /> diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 6d35980051..9733a05b1d 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -19,7 +19,6 @@ import { type FenceNode, type FloorplanGeometry, type GeometryContext, - type GridEvent, type GuideNode, getRenderableSlabPolygon, getWallChordFrame, @@ -8050,22 +8049,6 @@ export function FloorplanPanel({ useStairBuildPreview.getState().reset() return } - - const handleGridMove = (event: GridEvent) => { - // Publish to the dedicated store (deduped on the snapped point), NOT panel - // state: the stair preview lives in `FloorplanStairBuildPreviewLayer`, so a - // per-move update re-renders only that tiny leaf instead of this entire - // (~200ms) panel — the same pattern that keeps column/elevator smooth. - useStairBuildPreview - .getState() - .setPoint(getSnappedFloorplanPoint([event.localPosition[0], event.localPosition[2]])) - } - - emitter.on('grid:move', handleGridMove) - - return () => { - emitter.off('grid:move', handleGridMove) - } }, [isStairBuildActive]) useEffect(() => { @@ -9338,7 +9321,10 @@ export function FloorplanPanel({ ) const fenceGridBase = snapWallPointToGrid(planPoint) const fenceLocked = - fenceSnapped[0] !== fenceGridBase[0] || fenceSnapped[1] !== fenceGridBase[1] + getFenceDrawingSurface() !== null || + event.altKey || + fenceSnapped[0] !== fenceGridBase[0] || + fenceSnapped[1] !== fenceGridBase[1] let snappedPoint = fenceSnapped if (fenceLocked) useAlignmentGuides.getState().clear() // Alignment lines show in every mode; the pull applies only when diff --git a/packages/editor/src/components/editor/use-floorplan-background-placement.ts b/packages/editor/src/components/editor/use-floorplan-background-placement.ts index ccc75a787e..63c6a15979 100644 --- a/packages/editor/src/components/editor/use-floorplan-background-placement.ts +++ b/packages/editor/src/components/editor/use-floorplan-background-placement.ts @@ -274,7 +274,10 @@ export function useFloorplanBackgroundPlacement({ ) const fenceGridBase = worldGridSnap(planPoint, fenceStep) const fenceLocked = - fenceSnapped[0] !== fenceGridBase[0] || fenceSnapped[1] !== fenceGridBase[1] + getFenceDrawingSurface() !== null || + event.altKey || + fenceSnapped[0] !== fenceGridBase[0] || + fenceSnapped[1] !== fenceGridBase[1] const snappedPoint = fenceLocked ? fenceSnapped : alignFloorplanDraftPoint(fenceSnapped, { diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx index 89102fc5bb..2e777c976f 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -60,6 +60,7 @@ import { updateSurfaceNode as writeSurfaceNode, } from '../../../lib/surface-attachment' import { isTypingTarget } from '../../../lib/typing-target' +import { createWallPointerTracker } from '../../../lib/wall-placement-pointer' import { projectAlignmentGuidesWorldToActiveBuildingLocal, resolveAlignmentForActiveBuilding, @@ -103,6 +104,7 @@ import { import { getDetachedAttachmentPreviewLift, getGridAlignedDimensions, + getSideFromNormal, snapToGrid, snapToHalf, snapUpToGridStep, @@ -949,15 +951,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // for a given host, then offsets every later move by // `start + (raw - anchor)` — mirroring the floor path and the door/window // move tools so the item tracks the grabbed point instead of teleporting - // its origin under the cursor. Reset on host change (re-seeded from the - // item's then-current position) by the surface leave handlers. - let wallDragAnchor: { - wallId: string - rawX: number - rawY: number - startX: number - startY: number - } | null = null + // its origin under the cursor. Changing hosts or faces clears the offset. + const wallPointer = createWallPointerTracker( + preserveDragOffset && grabStartPosition + ? { wallId: grabWallId, side: draftNode.current?.side, position: grabStartPosition } + : undefined, + ) let ceilingDragAnchor: { ceilingId: string rawX: number @@ -1394,28 +1393,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea let wallMoveEvent = event if (preserveDragOffset && draftNode.current) { - const rawX = event.localPosition[0] - const rawY = event.localPosition[1] - if (!wallDragAnchor || wallDragAnchor.wallId !== event.node.id) { - // Preserve the grab offset only on the wall the item was grabbed - // from (no teleport under the pointer at grab time). Any OTHER host - // centers the item under the cursor — a host change is already a - // jump, so tracking the pointer exactly is the expected feel, while - // re-seeding from the carried-over position (the old behavior) - // baked an arbitrary along-wall offset into the whole stay on that - // wall. Once anchored elsewhere the grab is forgotten for good, so - // re-entering the original wall centers under the cursor too. - const preserveGrab = preserveGrabOn(event.node.id, grabWallId) - wallDragAnchor = { - wallId: event.node.id, - rawX, - rawY, - startX: preserveGrab && grabStartPosition ? grabStartPosition[0] : rawX, - startY: preserveGrab && grabStartPosition ? grabStartPosition[1] : rawY, - } - } - const correctedX = wallDragAnchor.startX + (rawX - wallDragAnchor.rawX) - const correctedY = wallDragAnchor.startY + (rawY - wallDragAnchor.rawY) + preserveGrabOn(event.node.id, grabWallId) + const [correctedX, correctedY] = wallPointer.resolve( + event.node.id, + getSideFromNormal(event.normal), + [event.localPosition[0], event.localPosition[1]], + ) wallMoveEvent = { ...event, localPosition: [correctedX, correctedY, event.localPosition[2]], @@ -1518,7 +1501,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } const onWallLeave = (event: WallEvent) => { - wallDragAnchor = null + wallPointer.leave() const result = wallStrategy.leave(getContext()) if (!result) return diff --git a/packages/editor/src/components/tools/registry/item-surface-move.ts b/packages/editor/src/components/tools/registry/item-surface-move.ts index 134ae3ccd9..f624155004 100644 --- a/packages/editor/src/components/tools/registry/item-surface-move.ts +++ b/packages/editor/src/components/tools/registry/item-surface-move.ts @@ -105,6 +105,8 @@ function parentWorldYaw(parentId: string | null | undefined): number { export function createRegistryItemSurfaceMove(node: AnyNode) { const capabilities = nodeRegistry.get(node.type)?.capabilities const floorPlaced = capabilities?.floorPlaced + if (capabilities?.movable?.preserveLevelAndElevation && !isFreshPlacementMetadata(node.metadata)) + return null if (!floorPlaced || (floorPlaced.applies && !floorPlaced.applies(node))) return null const original = node as AnyNode & { diff --git a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx index e8c281089f..e6fd5c8460 100644 --- a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx +++ b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx @@ -17,6 +17,7 @@ import { footprintAABBFrom, type GridEvent, type GroupMoveSnapResult, + getFloorPlacedElevation, getFloorPlacedFootprints, type MovableConfig, movingFootprintAnchors, @@ -417,6 +418,9 @@ export function MoveRegistryNodeTool({ node: source }: { node: AnyNode }) { const [cursorRotationY, setCursorRotationY] = useState(() => previewRotationY(originalRotationY)) const { isFreshPlacement, previewVisible, revealFreshPlacement, useAbsoluteCursorPlacement } = useFreshPlacementVisibility({ node }) + const preserveLevelAndElevation = + !isFreshPlacement && + nodeRegistry.get(node.type)?.capabilities?.movable?.preserveLevelAndElevation === true // Kinds that declare `movable.cursorAttached` (duct fittings) pin to the // cursor instead of preserving the grab offset — small connector-like // nodes read an offset drag as "lagging behind the mouse". @@ -480,7 +484,24 @@ export function MoveRegistryNodeTool({ node: source }: { node: AnyNode }) { return Array.isArray(rotation) ? [rotation[0] ?? 0, y, rotation[2] ?? 0] : y } + const keepStartingElevation = (position: [number, number, number], yaw: number) => { + if (!preserveLevelAndElevation) return position + const rotation = toCommitRotation(yaw) + const currentNodes = useScene.getState().nodes + const effectiveNode = { ...node, position, rotation } as AnyNode + const lift = getFloorPlacedElevation({ + node: effectiveNode, + nodes: { ...currentNodes, [node.id]: effectiveNode }, + position, + rotation, + }) + return [position[0], originalPlanPosition[1] - lift, position[2]] as [number, number, number] + } + const currentLevelId = () => + (preserveLevelAndElevation + ? findLevelAncestorId(node.id, useScene.getState().nodes) + : null) ?? useViewer.getState().selection.levelId ?? (itemSurfaceMove ? findLevelAncestorId(node.id, useScene.getState().nodes) : node.parentId) @@ -806,10 +827,14 @@ export function MoveRegistryNodeTool({ node: source }: { node: AnyNode }) { // test are plane-height independent, so the elected surface is a // single fixed point per pointer ray. const pointed = resolvePointerSupportSurface(cameraRef.current, event.position) - supportCapRef.current = pointed?.elevation ?? null - supportSurfaceRef.current = pointed - const rawX = pointed?.localPoint?.[0] ?? event.localPosition[0] - const rawZ = pointed?.localPoint?.[2] ?? event.localPosition[2] + supportCapRef.current = preserveLevelAndElevation ? null : (pointed?.elevation ?? null) + supportSurfaceRef.current = preserveLevelAndElevation ? null : pointed + const rawX = preserveLevelAndElevation + ? event.localPosition[0] + : (pointed?.localPoint?.[0] ?? event.localPosition[0]) + const rawZ = preserveLevelAndElevation + ? event.localPosition[2] + : (pointed?.localPoint?.[2] ?? event.localPosition[2]) revealFreshPlacement() const magnetic = isMagneticSnapActive() @@ -983,7 +1008,7 @@ export function MoveRegistryNodeTool({ node: source }: { node: AnyNode }) { if (guides.length > 0) useAlignmentGuides.getState().set(guides) } } - if (!parentFrame && pointed?.sourceNodeId) { + if (!preserveLevelAndElevation && !parentFrame && pointed?.sourceNodeId) { const rotation = toCommitRotation(rotationRef.current) const effectiveNode = { ...((useScene.getState().nodes[node.id] ?? node) as Record), @@ -1001,6 +1026,7 @@ export function MoveRegistryNodeTool({ node: source }: { node: AnyNode }) { }, ).position } + position = keepStartingElevation(position, rotationRef.current) const visualPosition = getVisualPosition(position) hasMovedRef.current = true setCursorPosition(visualPosition) @@ -1108,18 +1134,20 @@ export function MoveRegistryNodeTool({ node: source }: { node: AnyNode }) { // The pointer cap makes the persisted host reproduce the capped // election — a drop under a deck stores the aimed-at lower slab // (or the ground), not the deck hanging above. - ...resolveSupportSlabPatch( - effectiveNode, - { - ...useScene.getState().nodes, - [node.id]: effectiveNode, - }, - { - maxElevation: supportCapRef.current, - preferredSlabId: supportSurfaceRef.current?.supportSlabId, - pinSupport: supportSurfaceRef.current?.sourceNodeId != null, - }, - ), + ...(preserveLevelAndElevation + ? {} + : resolveSupportSlabPatch( + effectiveNode, + { + ...useScene.getState().nodes, + [node.id]: effectiveNode, + }, + { + maxElevation: supportCapRef.current, + preferredSlabId: supportSurfaceRef.current?.supportSlabId, + pinSupport: supportSurfaceRef.current?.sourceNodeId != null, + }, + )), ...(isNew ? { metadata: stripPlacementMetadataFlags(node.metadata), @@ -1192,18 +1220,20 @@ export function MoveRegistryNodeTool({ node: source }: { node: AnyNode }) { const committedNode = def.schema.parse({ ...reparsed, parentId: node.parentId, - ...resolveSupportSlabPatch( - { ...reparsed, parentId: node.parentId } as AnyNode, - { - ...useScene.getState().nodes, - [reparsed.id]: reparsed, - }, - { - maxElevation: supportCapRef.current, - preferredSlabId: supportSurfaceRef.current?.supportSlabId, - pinSupport: supportSurfaceRef.current?.sourceNodeId != null, - }, - ), + ...(preserveLevelAndElevation + ? {} + : resolveSupportSlabPatch( + { ...reparsed, parentId: node.parentId } as AnyNode, + { + ...useScene.getState().nodes, + [reparsed.id]: reparsed, + }, + { + maxElevation: supportCapRef.current, + preferredSlabId: supportSurfaceRef.current?.supportSlabId, + pinSupport: supportSurfaceRef.current?.sourceNodeId != null, + }, + )), }) as AnyNode useScene.temporal.getState().resume() useScene.getState().createNode(committedNode, node.parentId as AnyNodeId) @@ -1289,6 +1319,7 @@ export function MoveRegistryNodeTool({ node: source }: { node: AnyNode }) { ) position = canonicalPositionFromPlan(planOrigin[0], position[1], planOrigin[2]) } + position = keepStartingElevation(position, hostedPose?.rotationY ?? nextFreeRotation) lastCursorRef.current = position freeRotationRef.current = hostedPose?.rotationY ?? nextFreeRotation rotationRef.current = freeRotationRef.current @@ -1425,6 +1456,7 @@ export function MoveRegistryNodeTool({ node: source }: { node: AnyNode }) { originalPosition, originalPlanPosition, originalRotationY, + preserveLevelAndElevation, previewRotationY, revealFreshPlacement, useAbsoluteCursorPlacement, diff --git a/packages/editor/src/components/tools/stair/landscape-snap.test.ts b/packages/editor/src/components/tools/stair/landscape-snap.test.ts new file mode 100644 index 0000000000..54cbe312df --- /dev/null +++ b/packages/editor/src/components/tools/stair/landscape-snap.test.ts @@ -0,0 +1,106 @@ +import { expect, test } from 'bun:test' +import { type AnyNode, StairNode } from '@pascal-app/core' +import { resolveLandscapeStairSnap } from './landscape-snap' + +const stair = StairNode.parse({ parentId: 'level_1', position: [0, 0, -2], width: 1 }) +const surface = (type: string, extra: Record = {}) => + ({ + id: `${type}_1`, + type, + parentId: 'level_1', + position: [0, 0, 0], + rotation: [0, 0, 0], + width: 4, + depth: 3, + thickness: 0.35, + shape: 'rectangle', + ...extra, + }) as unknown as AnyNode + +test('connects native stairs to a deck and sizes the flight', () => { + const deck = surface('landscape:deck') + const result = resolveLandscapeStairSnap(stair, { [deck.id]: deck }, [0, 0, -2], 3) + expect(result).not.toBeNull() + expect(result!.position[2] + result!.length).toBeCloseTo(-1.5) + expect(result!.rotation).toBeCloseTo(0) + expect(result!.totalRise).toBeCloseTo(0.35) + expect(result!.stepCount).toBe(2) + expect(result!.length).toBeGreaterThanOrEqual(0.56) +}) + +test('reads patio elevation and rotates toward a landing', () => { + const patio = surface('landscape:patio', { thickness: 0.12, elevation: 0.2 }) + expect( + resolveLandscapeStairSnap(stair, { [patio.id]: patio }, [0, 0, -2], 3)?.totalRise, + ).toBeCloseTo(0.36) + const landing = surface('landscape:landing', { rotation: [0, Math.PI / 2, 0] }) + const result = resolveLandscapeStairSnap(stair, { [landing.id]: landing }, [-2, 0, 0], 3) + expect(result?.rotation).toBeCloseTo(Math.PI / 2) + expect(result!.position[0] + result!.length).toBeCloseTo(-1.5) +}) + +test('does not pull stairs across the scene', () => { + const deck = surface('landscape:deck') + expect(resolveLandscapeStairSnap(stair, { [deck.id]: deck }, [20, 0, 20], 3)).toBeNull() +}) + +test('connects to a concrete slab and accepts a cursor on its edge', () => { + const slab = surface('landscape:concrete-slab', { thickness: 0.18 }) + const result = resolveLandscapeStairSnap(stair, { [slab.id]: slab }, [0, 0, -1.5], 3) + expect(result?.totalRise).toBeCloseTo(0.18) + expect(result!.position[2] + result!.length).toBeCloseTo(-1.5) +}) + +test('per-stair switch disables magnetic attachment', () => { + const deck = surface('landscape:deck') + const disabled = StairNode.parse({ ...stair, autoLandscapeSnap: false }) + expect(resolveLandscapeStairSnap(disabled, { [deck.id]: deck }, [0, 0, -2], 3)).toBeNull() + expect(StairNode.parse({}).autoLandscapeSnap).toBeUndefined() +}) + +test('reconnects a placed stair after its deck height changes', () => { + const deck = surface('landscape:deck', { thickness: 0.35 }) + const first = resolveLandscapeStairSnap(stair, { [deck.id]: deck }, [0, 0, -2], 3)! + const placed = StairNode.parse({ + ...stair, + position: first.position, + rotation: first.rotation, + landscapeSurfaceId: deck.id, + totalRise: first.totalRise, + stepCount: first.stepCount, + }) + const tallerDeck = surface('landscape:deck', { thickness: 1.2 }) + const result = resolveLandscapeStairSnap( + placed, + { [tallerDeck.id]: tallerDeck }, + [first.position[0], 0, first.position[2] - 0.4], + first.length, + ) + expect(result?.totalRise).toBeCloseTo(1.2) + expect(result?.stepCount).toBe(8) + expect(result?.length).toBeGreaterThan(first.length) + expect(result!.position[2] + result!.length).toBeCloseTo(-1.5) +}) + +test('reconnects by the rotated high end of an existing stair', () => { + const deck = surface('landscape:deck', { thickness: 0.8 }) + const rotated = StairNode.parse({ ...stair, rotation: Math.PI / 2, landscapeSurfaceId: deck.id }) + const result = resolveLandscapeStairSnap(rotated, { [deck.id]: deck }, [-2.3, 0, 0], 0.8) + expect(result?.totalRise).toBeCloseTo(0.8) + expect(result?.rotation).toBeCloseTo(Math.PI / 2) +}) + +test('fits from the supported stair base when the deck height changes', () => { + const deck = surface('landscape:deck', { thickness: 1.2 }) + const result = resolveLandscapeStairSnap(stair, { [deck.id]: deck }, [0, 0, -2], 3, 0.2) + expect(result?.totalRise).toBeCloseTo(1) + expect(result?.stepCount).toBe(6) +}) + +test('aligns to the actual curved edge of a circular patio', () => { + const patio = surface('landscape:patio', { shape: 'circle', width: 4, depth: 4 }) + const result = resolveLandscapeStairSnap(stair, { [patio.id]: patio }, [0, 0, -2.2], 1) + expect(result).not.toBeNull() + expect(result!.position[2] + result!.length).toBeCloseTo(-2, 2) + expect(result!.rotation).toBeCloseTo(0, 1) +}) diff --git a/packages/editor/src/components/tools/stair/landscape-snap.ts b/packages/editor/src/components/tools/stair/landscape-snap.ts new file mode 100644 index 0000000000..9064e81537 --- /dev/null +++ b/packages/editor/src/components/tools/stair/landscape-snap.ts @@ -0,0 +1,146 @@ +import type { AnyNode, StairNode } from '@pascal-app/core' + +type Point = [number, number] +const surfaceKinds = new Set([ + 'landscape:deck', + 'landscape:patio', + 'landscape:concrete-slab', + 'landscape:landing', +]) +const SNAP_DISTANCE = 0.75 + +export type LandscapeStairSnap = { + position: [number, number, number] + rotation: number + totalRise: number + stepCount: number + length: number + surfaceId: string +} + +/** Align a straight flight's high end to a nearby landscape surface edge. */ +export function resolveLandscapeStairSnap( + stair: StairNode, + nodes: Readonly>, + candidatePosition: [number, number, number], + currentLength: number, + baseElevation = candidatePosition[1], +): LandscapeStairSnap | null { + if (stair.stairType !== 'straight' || stair.autoLandscapeSnap === false) return null + const candidate: Point = [candidatePosition[0], candidatePosition[2]] + const highEnd: Point = [ + candidate[0] + Math.sin(stair.rotation) * currentLength, + candidate[1] + Math.cos(stair.rotation) * currentLength, + ] + let closest: { distance: number; snap: LandscapeStairSnap } | null = null + for (const raw of Object.values(nodes)) { + if (!surfaceKinds.has(raw.type as string) || raw.parentId !== stair.parentId) continue + const surface = raw as unknown as { + id: string + position?: [number, number, number] + rotation?: [number, number, number] + width?: number + depth?: number + thickness?: number + elevation?: number + slopePercent?: number + drainDirection?: string + shape?: string + outline?: Point[] + } + if (!surface.position || !surface.width || !surface.depth || !surface.thickness) continue + const width = surface.width, + depth = surface.depth + const angle = surface.rotation?.[1] ?? 0 + const cos = Math.cos(angle), + sin = Math.sin(angle) + const local: Point[] = + surface.shape === 'circle' || surface.shape === 'oval' + ? Array.from({ length: 128 }, (_, index): Point => { + const theta = (index * Math.PI * 2) / 128 + return [ + (Math.cos(theta) * width) / 2, + (Math.sin(theta) * (surface.shape === 'circle' ? width : depth)) / 2, + ] + }) + : surface.shape !== 'rectangle' && surface.outline && surface.outline.length >= 3 + ? surface.outline.map(([x, z]) => [x * width, z * depth]) + : [ + [-width / 2, -depth / 2], + [width / 2, -depth / 2], + [width / 2, depth / 2], + [-width / 2, depth / 2], + ] + const outline = local.map( + ([x, z]): Point => [ + surface.position![0] + x * cos + z * sin, + surface.position![2] - x * sin + z * cos, + ], + ) + const area = outline.reduce((sum, p, index) => { + const next = outline[(index + 1) % outline.length]! + return sum + p[0] * next[1] - next[0] * p[1] + }, 0) + for (let index = 0; index < outline.length; index++) { + const a = outline[index]!, + b = outline[(index + 1) % outline.length]! + const dx = b[0] - a[0], + dz = b[1] - a[1] + const size = Math.hypot(dx, dz) + if (size < 1e-4) continue + const project = (point: Point) => + Math.max(0, Math.min(1, ((point[0] - a[0]) * dx + (point[1] - a[1]) * dz) / (size * size))) + const lowT = project(candidate), + highT = project(highEnd) + const lowEdge: Point = [a[0] + dx * lowT, a[1] + dz * lowT] + const highEdge: Point = [a[0] + dx * highT, a[1] + dz * highT] + const lowDistance = Math.hypot(candidate[0] - lowEdge[0], candidate[1] - lowEdge[1]) + const highDistance = Math.hypot(highEnd[0] - highEdge[0], highEnd[1] - highEdge[1]) + const t = highDistance < lowDistance ? highT : lowT + const edge: Point = [a[0] + dx * t, a[1] + dz * t] + const direction = area >= 0 ? 1 : -1 + const nx = (-dz / size) * direction, + nz = (dx / size) * direction + const outside = -((candidate[0] - edge[0]) * nx + (candidate[1] - edge[1]) * nz) + if (outside < -SNAP_DISTANCE || outside > 5) continue + const distance = Math.min(lowDistance, highDistance) + const catchDistance = stair.landscapeSurfaceId === surface.id ? 1.25 : SNAP_DISTANCE + if (distance > catchDistance || (closest && distance >= closest.distance)) continue + const patio = (raw.type as string) === 'landscape:patio' + const localX = (edge[0] - surface.position[0]) * cos - (edge[1] - surface.position[2]) * sin + const localZ = (edge[0] - surface.position[0]) * sin + (edge[1] - surface.position[2]) * cos + const gradient = (surface.slopePercent ?? 0) / 100 + const slopeRise = + surface.drainDirection === 'front' + ? gradient * localZ + : surface.drainDirection === 'back' + ? -gradient * localZ + : surface.drainDirection === 'left' + ? gradient * localX + : surface.drainDirection === 'right' + ? -gradient * localX + : 0 + const top = + surface.position[1] + + surface.thickness + + (patio ? (surface.elevation ?? 0) + Math.min(0.045, surface.thickness / 3) + slopeRise : 0) + const rise = top - baseElevation + if (!Number.isFinite(rise) || rise <= 0.01) continue + const stepCount = Math.max(1, Math.round(rise / 0.16)) + const minRun = stepCount * 0.28 + const length = Math.max(minRun, outside, 0.4) + closest = { + distance, + snap: { + position: [edge[0] - nx * length, candidatePosition[1], edge[1] - nz * length], + rotation: Math.atan2(nx, nz), + totalRise: rise, + stepCount, + length, + surfaceId: surface.id, + }, + } + } + } + return closest?.snap ?? null +} diff --git a/packages/editor/src/components/tools/stair/stair-tool.tsx b/packages/editor/src/components/tools/stair/stair-tool.tsx index 8154ad22cd..8abe4b5f50 100644 --- a/packages/editor/src/components/tools/stair/stair-tool.tsx +++ b/packages/editor/src/components/tools/stair/stair-tool.tsx @@ -23,7 +23,7 @@ import { } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { useThree } from '@react-three/fiber' -import { useEffect, useMemo, useRef } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import * as THREE from 'three' import { EDITOR_LAYER } from '../../../lib/constants' import { sfxEmitter } from '../../../lib/sfx-bus' @@ -46,6 +46,7 @@ import { type PointerSupportSurface, resolvePointerSupportSurface, } from '../shared/pointer-support-cap' +import { type LandscapeStairSnap, resolveLandscapeStairSnap } from './landscape-snap' import { createStairCommitGate, swallowFollowUpBrowserClick } from './stair-click-guard' import { DEFAULT_CURVED_STAIR_INNER_RADIUS, @@ -152,6 +153,7 @@ function createDefaultStairNode({ rotation: number segmentId: StairSegmentNode['id'] }) { + const railingMode = useEditor.getState().toolDefaults.stair?.railingMode return StairNode.parse({ name, parentId: levelId, @@ -173,7 +175,13 @@ function createDefaultStairNode({ showCenterColumn: DEFAULT_SPIRAL_SHOW_CENTER_COLUMN, showStepSupports: DEFAULT_SPIRAL_SHOW_STEP_SUPPORTS, railingHeight: DEFAULT_STAIR_RAILING_HEIGHT, - railingMode: DEFAULT_STAIR_RAILING_MODE, + railingMode: + railingMode === 'none' || + railingMode === 'left' || + railingMode === 'right' || + railingMode === 'both' + ? railingMode + : DEFAULT_STAIR_RAILING_MODE, children: [segmentId], }) } @@ -186,6 +194,7 @@ function commitStairPlacement( position: [number, number, number], rotation: number, supportSurface: PointerSupportSurface | null, + landscapeSnap: LandscapeStairSnap | null, ): void { const { applyNodeChanges, nodes } = useScene.getState() const placementLevelId = resolveStairPlacementLevelId( @@ -201,11 +210,13 @@ function commitStairPlacement( if (!(storeyRise > 0)) return const seed = createSeedStairSegment(storeyRise) - const destinationPlan = resolveStairDestinationLevel({ - createMissing: true, - fromLevelId: placementLevelId, - nodes, - }) + const destinationPlan = landscapeSnap + ? null + : resolveStairDestinationLevel({ + createMissing: true, + fromLevelId: placementLevelId, + nodes, + }) const nextLevelId = destinationPlan?.toLevel.id ?? placementLevelId const stair = StairNode.parse({ @@ -218,6 +229,15 @@ function commitStairPlacement( segmentId: seed.id, }), parentId: placementLevelId, + ...(landscapeSnap + ? { + totalRise: landscapeSnap.totalRise, + stepCount: landscapeSnap.stepCount, + slabOpeningMode: 'none' as const, + landscapeSurfaceId: landscapeSnap.surfaceId, + railingMode: 'none' as const, + } + : {}), }) const base = resolvePlacedStairBase(nodes, placementLevelId, stair, supportSurface, seed) let segment: StairSegmentNode @@ -246,19 +266,21 @@ function commitStairPlacement( [stair.id]: stair, [segment.id]: { ...segment, parentId: stair.id }, } as Record - const placementPatch = supportSurface?.sourceNodeId - ? resolveFrozenFloorPlacementPatch(stair, prospectiveNodes, { - position, - rotation, - elevation: supportSurface.elevation, - preferredSlabId: supportSurface.supportSlabId, - }) - : { - position, - ...resolveSupportSlabPatch(stair, prospectiveNodes, { - maxElevation: supportSurface?.elevation, - }), - } + const placementPatch = landscapeSnap + ? { position } + : supportSurface?.sourceNodeId + ? resolveFrozenFloorPlacementPatch(stair, prospectiveNodes, { + position, + rotation, + elevation: supportSurface.elevation, + preferredSlabId: supportSurface.supportSlabId, + }) + : { + position, + ...resolveSupportSlabPatch(stair, prospectiveNodes, { + maxElevation: supportSurface?.elevation, + }), + } const committedStair = StairNode.parse({ ...stair, ...placementPatch, @@ -291,6 +313,7 @@ export const StairTool: React.FC = () => { const previewMeshRef = useRef(null) const rotationRef = useRef(0) const supportSurfaceRef = useRef(null) + const landscapeSnapRef = useRef(null) const previousGridPosRef = useRef<[number, number] | null>(null) const lastCanonicalPositionRef = useRef<[number, number, number] | null>(null) const currentLevelId = useViewer((state) => state.selection.levelId) @@ -322,11 +345,13 @@ export const StairTool: React.FC = () => { } lastCanonicalPositionRef.current = null supportSurfaceRef.current = null + landscapeSnapRef.current = null const buildPreviewScene = ( position: [number, number, number], rotation: number, supportSurface: PointerSupportSurface | null, + landscapeSnap: LandscapeStairSnap | null, ) => { const nodes = useScene.getState().nodes const placementLevelId = resolveStairPlacementLevelId( @@ -336,11 +361,13 @@ export const StairTool: React.FC = () => { ) if (!placementLevelId) return null - const destinationPlan = resolveStairDestinationLevel({ - createMissing: true, - fromLevelId: placementLevelId, - nodes, - }) + const destinationPlan = landscapeSnap + ? null + : resolveStairDestinationLevel({ + createMissing: true, + fromLevelId: placementLevelId, + nodes, + }) const nextLevelId = destinationPlan?.toLevel.id ?? placementLevelId const storeyRise = getLevelFloorToFloorHeight(placementLevelId, nodes) if (!(storeyRise > 0)) return null @@ -380,11 +407,11 @@ export const StairTool: React.FC = () => { ...(destinationPlan?.createdLevel ? { [destinationPlan.createdLevel.id]: destinationPlan.createdLevel } : {}), - [stair.id]: { ...stair, parentId: placementLevelId }, + [stair.id]: { ...previewStair, parentId: placementLevelId }, [segment.id]: { ...segment, parentId: stair.id }, } as Record - return { placementLevelId, previewNodes, stair, rise: segment.height } + return { placementLevelId, previewNodes, stair: previewStair, rise: segment.height } } // The preview rebuild (full-scene copy + destination-level resolution + @@ -409,7 +436,7 @@ export const StairTool: React.FC = () => { .getState() .setPreview(preview ? [position[0], position[2]] : null, rotation, preview?.rise ?? null) const frozenPatch = - preview && supportSurface?.sourceNodeId + preview && supportSurface?.sourceNodeId && !landscapeSnap ? resolveFrozenFloorPlacementPatch(preview.stair, preview.previewNodes, { position, rotation, @@ -421,8 +448,9 @@ export const StairTool: React.FC = () => { const previewStair = frozenPatch ? ({ ...preview?.stair, ...frozenPatch } as AnyNode) : preview?.stair - const visualPosition = - preview && previewStair + const visualPosition = landscapeSnap + ? previewPosition + : preview && previewStair ? getFloorStackPreviewPosition({ node: previewStair, position: previewPosition, @@ -486,7 +514,12 @@ export const StairTool: React.FC = () => { z: number, rotation: number, ): ReturnType | null => { - const preview = buildPreviewScene([x, 0, z], rotation, supportSurfaceRef.current) + const preview = buildPreviewScene( + [x, 0, z], + rotation, + supportSurfaceRef.current, + landscapeSnapRef.current, + ) const moving = preview ? movingAlignmentAnchors(preview.stair, preview.previewNodes, x, z, rotation) : [] @@ -547,16 +580,48 @@ export const StairTool: React.FC = () => { const rawZ = pointed?.localPoint?.[2] ?? fallbackPosition![2] // Grid snap follows the global mode (live step so the HUD chip is // honest); Off keeps the raw cursor. Shift cycles the mode centrally. + const bypassSnap = event.nativeEvent?.altKey === true const step = useEditor.getState().gridSnapStep const [gridX, gridZ] = alignPoint( - isGridSnapActive() ? Math.round(rawX / step) * step : rawX, - isGridSnapActive() ? Math.round(rawZ / step) * step : rawZ, + !bypassSnap && isGridSnapActive() ? Math.round(rawX / step) * step : rawX, + !bypassSnap && isGridSnapActive() ? Math.round(rawZ / step) * step : rawZ, rawX, rawZ, !isAlignmentGuideActive(), - isMagneticSnapActive(), + !bypassSnap && isMagneticSnapActive(), ) - return [gridX, 0, gridZ] + const nodes = useScene.getState().nodes + const levelId = resolveStairPlacementLevelId( + nodes, + currentLevelId, + useViewer.getState().selection.buildingId, + ) + const candidate: [number, number, number] = [gridX, 0, gridZ] + const draft = StairNode.parse({ + parentId: levelId, + position: candidate, + width: DEFAULT_STAIR_WIDTH, + stairType: 'straight', + rotation: rotationRef.current, + }) + const baseElevation = levelId + ? getFloorStackedPosition({ + node: draft, + nodes, + position: candidate, + rotation: rotationRef.current, + levelId, + })[1] + : candidate[1] + const snap = event.nativeEvent?.altKey + ? null + : resolveLandscapeStairSnap(draft, nodes, candidate, DEFAULT_STAIR_LENGTH, baseElevation) + landscapeSnapRef.current = snap + if (snap) { + rotationRef.current = snap.rotation + return snap.position + } + return candidate } const onPointerMove = (event: MoveTriggerEvent) => { @@ -598,7 +663,13 @@ export const StairTool: React.FC = () => { const position = resolveStairPosition(event) if (!position) return - commitStairPlacement(currentLevelId, position, rotationRef.current, supportSurfaceRef.current) + commitStairPlacement( + currentLevelId, + position, + rotationRef.current, + supportSurfaceRef.current, + landscapeSnapRef.current, + ) openingPreview.clear() // Commit cleared the opening preview, so force the next hover (even on the // same cell) to rebuild rather than dedupe against the just-placed key. @@ -662,6 +733,7 @@ export const StairTool: React.FC = () => { openingPreview.clear() useFacingPose.getState().clear() useStairBuildPreview.getState().reset() + useEditor.getState().setToolDefaults('stair', null) } }, [currentLevelId]) diff --git a/packages/editor/src/components/ui/panels/panel-wrapper.tsx b/packages/editor/src/components/ui/panels/panel-wrapper.tsx index b2c5800188..c384123886 100644 --- a/packages/editor/src/components/ui/panels/panel-wrapper.tsx +++ b/packages/editor/src/components/ui/panels/panel-wrapper.tsx @@ -138,7 +138,8 @@ export function PanelWrapper({ ) as AnyNodeId | undefined // Subscribe to the selected node's *type* only — a string primitive that // doesn't change as fields are edited (same trick as ParametricInspector). - const selectedType = useScene((s) => (selectedId ? (s.nodes[selectedId]?.type ?? null) : null)) + const selectedNode = useScene((s) => (selectedId ? s.nodes[selectedId] : undefined)) + const selectedType = selectedNode?.type ?? null const installedPlugins = useScene((s) => s.installedPlugins) const extensions = useMemo(() => { // re-derive when plugin extensions register after mount (async plugin load) @@ -155,7 +156,10 @@ export function PanelWrapper({ // See `lib/inspector-card-mode.ts` for the transition table. const [activeExtensionId, setActiveExtensionId] = useState(null) // Stale ids (kind changed, plugin gated off) fall back to regular mode. - const activeExtension = resolveActiveExtension(activeExtensionId, extensions) + const primaryExtension = selectedNode + ? extensions.find((extension) => extension.primaryWhen?.(selectedNode)) + : undefined + const activeExtension = primaryExtension ?? resolveActiveExtension(activeExtensionId, extensions) // The panel is collapsed to just its header until the user expands it. The // choice is one editor preference for every inspector (room, wall, item…), @@ -165,6 +169,14 @@ export function PanelWrapper({ useInspectorExpanded.getState().setExpanded(!next) }, []) + const landscapeSelection = Boolean(selectedNode && ( + (selectedNode.type as string).startsWith('landscape:') || + (selectedNode.type === 'wall' && selectedNode.metadata?.landscapeRetainingWall === true) + )) + useEffect(() => { + if (landscapeSelection) setCollapsed(false) + }, [landscapeSelection, selectedId, setCollapsed]) + const applyMode = useCallback( (next: { collapsed: boolean; activeExtensionId: string | null }) => { setCollapsed(next.collapsed) @@ -176,8 +188,12 @@ export function PanelWrapper({ // Chevron / header press — collapsed → regular, regular → collapsed, // extension mode → regular (exit the extension first, stay expanded). const handleCardToggle = useCallback(() => { + if (primaryExtension) { + setCollapsed(!collapsed) + return + } applyMode(toggleCard({ collapsed, activeExtensionId })) - }, [applyMode, collapsed, activeExtensionId]) + }, [applyMode, collapsed, activeExtensionId, primaryExtension, setCollapsed]) // Folding the card forgets the active extension — extension mode is a // one-shot affordance of the header icon, not sticky panel state. @@ -282,6 +298,7 @@ export function PanelWrapper({ return (
)} - {icon && + {primaryExtension ? renderExtensionIcon(primaryExtension.icon) : icon && (typeof icon === 'string' ? ( - {title} + {primaryExtension?.title ?? title} )}
) : (

- {title} + {primaryExtension?.title ?? title}

)} @@ -386,7 +403,7 @@ export function PanelWrapper({ ONLY that extension's content (either/or with the regular controls); the active icon (highlighted) or the chevron returns to the regular controls. */} - {extensions.map((extension) => { + {extensions.filter((extension) => extension !== primaryExtension).map((extension) => { const isActive = !collapsed && activeExtensionId === extension.id return ( + ) : null} {notice ? : null} {actionHints.length > 0 && (hasChips || escHints.length > 0) ? (
diff --git a/packages/editor/src/components/ui/primitives/sidebar.tsx b/packages/editor/src/components/ui/primitives/sidebar.tsx index ccfc45ac02..29549ead60 100644 --- a/packages/editor/src/components/ui/primitives/sidebar.tsx +++ b/packages/editor/src/components/ui/primitives/sidebar.tsx @@ -48,7 +48,7 @@ type SidebarStore = { export const useSidebarStore = create()( persist( (set) => ({ - width: 288, // 18rem = 288px + width: 360, setWidth: (width) => { if (width < SIDEBAR_COLLAPSE_THRESHOLD) { set({ isCollapsed: true }) From 5fdaf6d66c88a64a10e58c1f05f686cd10a44e52 Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 13 Oct 2026 13:23:55 +0530 Subject: [PATCH 24/31] test(editor): cover collapsed and active HUD shortcuts --- .../ui/helpers/helper-manager.test.tsx | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/editor/src/components/ui/helpers/helper-manager.test.tsx b/packages/editor/src/components/ui/helpers/helper-manager.test.tsx index 372bce4630..efbbe8fb7a 100644 --- a/packages/editor/src/components/ui/helpers/helper-manager.test.tsx +++ b/packages/editor/src/components/ui/helpers/helper-manager.test.tsx @@ -18,7 +18,7 @@ describe('Divide HUD', () => { ).toBe('wall') }) - test('reads like the wall draft: snapping chips and the path keys', () => { + test('shows snapping controls and the primary gesture with expandable secondary shortcuts', () => { // Server rendering reads each store's initial state: the wall context starts // on Grid with a 0.5 m step. const html = renderToStaticMarkup( @@ -28,8 +28,11 @@ describe('Divide HUD', () => { ) expect(html).toContain('Snapping: Grid') expect(html).toContain('Grid: 0.50 m') - for (const label of ['Add point', 'Remove last point', 'Finish at the edge', 'Cancel']) + for (const label of ['Add point', 'More shortcuts', 'Cancel']) expect(html).toContain(label) + expect(html).toContain('aria-expanded="false"') + for (const label of ['Remove last point', 'Finish at the edge']) + expect(html).not.toContain(label) expect(ROOM_DIVIDE_HINTS.map((hint) => hint.keys)).toEqual([ ['Left click'], ['Backspace'], @@ -37,6 +40,24 @@ describe('Divide HUD', () => { ['Esc'], ]) }) + + test('keeps active secondary gestures visible while shortcuts are collapsed', () => { + const html = renderToStaticMarkup( + + ({ + ...hint, + active: hint.keys.includes('Enter'), + }))} + snapContext="wall" + /> + , + ) + for (const label of ['Add point', 'Finish at the edge', 'Cancel']) + expect(html).toContain(label) + expect(html).not.toContain('Remove last point') + expect(html).toContain('aria-expanded="false"') + }) }) describe('HUD header', () => { From 16d45db4eaebdd87c19c256a31e90e6fc9ea73c9 Mon Sep 17 00:00:00 2001 From: sudhir Date: Wed, 14 Oct 2026 12:15:55 +0530 Subject: [PATCH 25/31] feat(editor): pin Bath Space category picker and support local development --- apps/editor/next.config.ts | 8 ++++++++ apps/editor/package.json | 2 +- bun.lock | 4 ++-- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/editor/next.config.ts b/apps/editor/next.config.ts index 91e204455f..edde012dc1 100644 --- a/apps/editor/next.config.ts +++ b/apps/editor/next.config.ts @@ -4,6 +4,7 @@ import type { NextConfig } from 'next' const appDirectory = path.dirname(fileURLToPath(import.meta.url)) const portableBuild = process.env.PASCAL_PORTABLE_BUILD === '1' +const localBathSpaceRoot = portableBuild ? undefined : process.env.PASCAL_LOCAL_BATH_SPACE_ROOT const nextConfig: NextConfig = { ...(portableBuild ? { output: 'standalone' as const, outputFileTracingRoot: path.join(appDirectory, '../..') } @@ -73,6 +74,13 @@ const nextConfig: NextConfig = { }, }, experimental: { + ...(localBathSpaceRoot + ? { + turbopackAdditionalRoots: { + bathSpace: { path: path.resolve(localBathSpaceRoot, '../..') }, + }, + } + : {}), serverActions: { bodySizeLimit: '100mb', }, diff --git a/apps/editor/package.json b/apps/editor/package.json index 22c04591de..147f54fd11 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -21,7 +21,7 @@ "@pascal-app/nodes": "*", "@pascal-app/plugin-bones": "github:pascalorg/plugin-bones#5679260261ee1c733656ff6dfb99e30bb24b58a7", "@pascal-app/plugin-environment": "github:AxiomeCG/environment#1aa0bc3cbed273dbf751e9af79e446f6de105c90", - "@pascal-app/plugin-bath-space": "github:sudhir9297/pascal-cluster#685f2f8542348925e43947ee6cb566cd19144b49", + "@pascal-app/plugin-bath-space": "github:sudhir9297/pascal-cluster#70cffa9a6878122acf6e3fdbde5725db4908f737", "@pascal-app/plugin-landscape": "github:sudhir9297/pascal-cluster#c7f555a89863fae67400a915d25c74e132f17472", "@pascal-app/plugin-pool": "github:sudhir9297/pool-pascal-plugin#b8e408121feae51d91c89f7efe16f9d8f795cb49", "@pascal-app/plugin-streetscape-lab": "github:sudhir9297/pascal-cluster#54095f28938a23f57c538c72eb8f94808fb2569c", diff --git a/bun.lock b/bun.lock index 0c8ed7860c..bcc295ab4c 100644 --- a/bun.lock +++ b/bun.lock @@ -36,7 +36,7 @@ "@pascal-app/editor": "*", "@pascal-app/mcp": "*", "@pascal-app/nodes": "*", - "@pascal-app/plugin-bath-space": "github:sudhir9297/pascal-cluster#685f2f8542348925e43947ee6cb566cd19144b49", + "@pascal-app/plugin-bath-space": "github:sudhir9297/pascal-cluster#70cffa9a6878122acf6e3fdbde5725db4908f737", "@pascal-app/plugin-bones": "github:pascalorg/plugin-bones#5679260261ee1c733656ff6dfb99e30bb24b58a7", "@pascal-app/plugin-environment": "github:AxiomeCG/environment#1aa0bc3cbed273dbf751e9af79e446f6de105c90", "@pascal-app/plugin-landscape": "github:sudhir9297/pascal-cluster#c7f555a89863fae67400a915d25c74e132f17472", @@ -830,7 +830,7 @@ "@pascal-app/nodes": ["@pascal-app/nodes@workspace:packages/nodes"], - "@pascal-app/plugin-bath-space": ["@pascal-app/plugin-bath-space@github:sudhir9297/pascal-cluster#685f2f8", { "dependencies": { "@tanstack/react-virtual": "^3.14.13" }, "peerDependencies": { "@pascal-app/core": "^1.0.1", "@pascal-app/editor": "^1.0.1", "@pascal-app/viewer": "^1.0.1", "@react-three/fiber": "^9", "react": "^18 || ^19", "react-dom": "^18 || ^19", "three": "^0.186.0", "zod": ">=4.5.4 <4.6" } }, "sudhir9297-pascal-cluster-685f2f8", "sha512-2j9wdq3Hm/5TOSfiDNJUV4kWvKqVjkH+xl/zf2h00/AfbxCbLsYhEeY+hkDQ9Hfdp+02AQ7XFFni4azLQyU68w=="], + "@pascal-app/plugin-bath-space": ["@pascal-app/plugin-bath-space@github:sudhir9297/pascal-cluster#70cffa9", { "dependencies": { "@tanstack/react-virtual": "^3.14.13" }, "peerDependencies": { "@pascal-app/core": "^1.0.1", "@pascal-app/editor": "^1.0.1", "@pascal-app/viewer": "^1.0.1", "@react-three/fiber": "^9", "react": "^18 || ^19", "react-dom": "^18 || ^19", "three": "^0.186.0", "zod": ">=4.5.4 <4.6" } }, "sudhir9297-pascal-cluster-70cffa9", "sha512-TtSJe5zxn8hfHgguCMx+3eFMR+NoROkdQXfWduh9pWl7KE4JVC4Qs4IPiDjpH7aUzxtr505O9hwY4ChLXzubCA=="], "@pascal-app/plugin-bones": ["@pascal-app/plugin-bones@github:pascalorg/plugin-bones#5679260", { "peerDependencies": { "@pascal-app/core": ">=0.9.1 <1", "@pascal-app/editor": ">=0.9.1 <1", "@pascal-app/viewer": ">=0.9.1 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "pascalorg-plugin-bones-5679260", "sha512-uQkyHHOl/VuYx2+d/MmcJui/KEAQZ2UUnO4ywp1XaopmD2YfN+Yx/fR/lS/VL2joEoyyZeBLC3KaXK1Sx5qEvg=="], From b8f7aaf3f00a2306b9a18a368e5057339c1f1f35 Mon Sep 17 00:00:00 2001 From: sudhir Date: Wed, 14 Oct 2026 12:56:13 +0530 Subject: [PATCH 26/31] chore(editor): remove local Bath Space development override --- apps/editor/next.config.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/apps/editor/next.config.ts b/apps/editor/next.config.ts index edde012dc1..91e204455f 100644 --- a/apps/editor/next.config.ts +++ b/apps/editor/next.config.ts @@ -4,7 +4,6 @@ import type { NextConfig } from 'next' const appDirectory = path.dirname(fileURLToPath(import.meta.url)) const portableBuild = process.env.PASCAL_PORTABLE_BUILD === '1' -const localBathSpaceRoot = portableBuild ? undefined : process.env.PASCAL_LOCAL_BATH_SPACE_ROOT const nextConfig: NextConfig = { ...(portableBuild ? { output: 'standalone' as const, outputFileTracingRoot: path.join(appDirectory, '../..') } @@ -74,13 +73,6 @@ const nextConfig: NextConfig = { }, }, experimental: { - ...(localBathSpaceRoot - ? { - turbopackAdditionalRoots: { - bathSpace: { path: path.resolve(localBathSpaceRoot, '../..') }, - }, - } - : {}), serverActions: { bodySizeLimit: '100mb', }, From eb773a231fd5d069c24009c7856c1f97e04cccdd Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Thu, 15 Oct 2026 14:32:02 -0400 Subject: [PATCH 27/31] Keep saved streetscape scenes, the stair height fix and plain fences - Streetscape stays @pascal-app/plugin-streetscape (pascal:streetscape), now pinned to wass08/streetscape-pascal-plugin@c4c012e, which brings in the streetscape-lab changes under its own name. Scenes that installed pascal:streetscape keep loading its nodes; the -lab id disabled them. - A stair on a level is lifted onto its slabs by the stair system again (#216); only under another parent (a deck) does the renderer bind its Y. - The 2D move overlay has no stair branches: the stair's own move target reads the stair as the scene holds it, and already clears its surface. - A fence's surface host lasts one drawing session on its floor: leaving the tool or the floor drops it instead of closing the fence tool, so a later fence is plain and the tool works on every floor. Co-Authored-By: Claude Opus 5.5 --- apps/editor/lib/bootstrap.ts | 4 +--- apps/editor/next.config.ts | 2 +- apps/editor/package.json | 2 +- bun.lock | 4 ++-- .../editor-2d/floorplan-registry-move-overlay.tsx | 12 +++--------- packages/nodes/src/fence/tool.tsx | 15 ++++++++++++--- packages/nodes/src/stair/floorplan-move.ts | 5 ++++- packages/nodes/src/stair/renderer.tsx | 10 +++++++++- 8 files changed, 33 insertions(+), 21 deletions(-) diff --git a/apps/editor/lib/bootstrap.ts b/apps/editor/lib/bootstrap.ts index 7b4013fcad..6548709f35 100644 --- a/apps/editor/lib/bootstrap.ts +++ b/apps/editor/lib/bootstrap.ts @@ -119,9 +119,7 @@ extendPluginDiscovery(async () => { return [bathSpacePlugin] }) extendPluginDiscovery(async () => { - const { streetscapeHostPanel, streetscapePlugin } = await import( - '@pascal-app/plugin-streetscape-lab' - ) + const { streetscapeHostPanel, streetscapePlugin } = await import('@pascal-app/plugin-streetscape') registerEditorHostPanel({ ...streetscapeHostPanel, creator: { name: 'Sudhir Yadav', url: 'https://github.com/sudhir9297' }, diff --git a/apps/editor/next.config.ts b/apps/editor/next.config.ts index 91e204455f..ff5f1dcb2d 100644 --- a/apps/editor/next.config.ts +++ b/apps/editor/next.config.ts @@ -45,7 +45,7 @@ const nextConfig: NextConfig = { '@pascal-app/plugin-pool', '@pascal-app/plugin-bath-space', '@pascal-app/plugin-landscape', - '@pascal-app/plugin-streetscape-lab', + '@pascal-app/plugin-streetscape', '@pascal-app/plugin-trees', '@mint/pascal-plugin', '@pascal-app/plugin-bones', diff --git a/apps/editor/package.json b/apps/editor/package.json index 147f54fd11..470233e1ed 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -24,7 +24,7 @@ "@pascal-app/plugin-bath-space": "github:sudhir9297/pascal-cluster#70cffa9a6878122acf6e3fdbde5725db4908f737", "@pascal-app/plugin-landscape": "github:sudhir9297/pascal-cluster#c7f555a89863fae67400a915d25c74e132f17472", "@pascal-app/plugin-pool": "github:sudhir9297/pool-pascal-plugin#b8e408121feae51d91c89f7efe16f9d8f795cb49", - "@pascal-app/plugin-streetscape-lab": "github:sudhir9297/pascal-cluster#54095f28938a23f57c538c72eb8f94808fb2569c", + "@pascal-app/plugin-streetscape": "github:wass08/streetscape-pascal-plugin#c4c012ead949f64aca7ffc3d2a310896f8a231ad", "@pascal-app/plugin-trees": "github:pascalorg/plugin-trees#56d978cd9b409b716207b3f3d269455d3cd6f067", "@pascal-app/viewer": "*", "@radix-ui/react-tooltip": "^1.4.1", diff --git a/bun.lock b/bun.lock index 1fe52f945d..fe117adbc6 100644 --- a/bun.lock +++ b/bun.lock @@ -41,7 +41,7 @@ "@pascal-app/plugin-environment": "github:AxiomeCG/environment#1aa0bc3cbed273dbf751e9af79e446f6de105c90", "@pascal-app/plugin-landscape": "github:sudhir9297/pascal-cluster#c7f555a89863fae67400a915d25c74e132f17472", "@pascal-app/plugin-pool": "github:sudhir9297/pool-pascal-plugin#b8e408121feae51d91c89f7efe16f9d8f795cb49", - "@pascal-app/plugin-streetscape-lab": "github:sudhir9297/pascal-cluster#54095f28938a23f57c538c72eb8f94808fb2569c", + "@pascal-app/plugin-streetscape": "github:wass08/streetscape-pascal-plugin#c4c012ead949f64aca7ffc3d2a310896f8a231ad", "@pascal-app/plugin-trees": "github:pascalorg/plugin-trees#56d978cd9b409b716207b3f3d269455d3cd6f067", "@pascal-app/viewer": "*", "@radix-ui/react-tooltip": "^1.4.1", @@ -842,7 +842,7 @@ "@pascal-app/plugin-pool": ["@pascal-app/plugin-pool@github:sudhir9297/pool-pascal-plugin#b8e4081", { "dependencies": { "three-bvh-csg": "^0.0.18", "three-mesh-bvh": "^0.9.8" }, "peerDependencies": { "@pascal-app/core": ">=0.9.1 <1 || >=1.0.0-beta.0 <2", "@pascal-app/editor": ">=0.9.1 <1 || >=1.0.0-beta.0 <2", "@pascal-app/viewer": ">=0.9.1 <1 || >=1.0.0-beta.0 <2", "@react-three/fiber": "^9", "react": "^18 || ^19", "react-dom": "^18 || ^19", "three": "^0.186.0", "zod": "^4", "zustand": "^5" } }, "sudhir9297-pool-pascal-plugin-b8e4081", "sha512-tlsIyYUCLFqxBUr2WOBAp6vOLrgkHF+PuBlWTPTgoAMZNN435cwKzPfmzul5xl4FMQC3TZnC7G0oFpK0HXoiwQ=="], - "@pascal-app/plugin-streetscape-lab": ["@pascal-app/plugin-streetscape-lab@github:sudhir9297/pascal-cluster#54095f2", { "dependencies": { "lucide-react": "^1.41.0", "maplibre-gl": "^6.7.0" }, "peerDependencies": { "@pascal-app/core": ">=0.9.1 <1", "@pascal-app/editor": ">=0.9.1 <1", "@pascal-app/viewer": ">=0.9.1 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "react-dom": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "sudhir9297-pascal-cluster-54095f2", "sha512-qil4Uw8f7CACZRlzlkiG0G2NEySThyZ4VdD9GcPGJVaTtvi0nnEoeoGi1ndxtTuGBDQGa3NXzVhKtj+4BIEWiA=="], + "@pascal-app/plugin-streetscape": ["@pascal-app/plugin-streetscape@github:wass08/streetscape-pascal-plugin#c4c012e", { "dependencies": { "lucide-react": "^1.41.0", "maplibre-gl": "^6.7.0" }, "peerDependencies": { "@pascal-app/core": ">=0.9.1 <1", "@pascal-app/editor": ">=0.9.1 <1", "@pascal-app/viewer": ">=0.9.1 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "react-dom": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "wass08-streetscape-pascal-plugin-c4c012e", "sha512-jMkNWIlhlUSQCFcjjqWWpkTnxWyF8HoFQqIQ6FozhdDiRkCgvdtgCWDGMD8w4DMEgSEBwEiMhCuEGm3kjMtWXA=="], "@pascal-app/plugin-trees": ["@pascal-app/plugin-trees@github:pascalorg/plugin-trees#56d978c", { "dependencies": { "@dgreenheck/ez-tree": "^1.1.0" }, "peerDependencies": { "@pascal-app/core": ">=0.9.1 <1", "@pascal-app/editor": ">=0.9.1 <1", "@pascal-app/viewer": ">=0.9.1 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "pascalorg-plugin-trees-56d978c", "sha512-16VzWot1oadvxCPqsRwMJbaP0a3u5FESFy7F7++pY5wAAFq9JTFwzHvKAsllrY5TZ5TJ7Y5vSqvbmQk8sy8HaA=="], diff --git a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx index 2fb8cb70cf..ef1de406d7 100644 --- a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx @@ -82,15 +82,12 @@ const ALIGNMENT_THRESHOLD_M = 0.08 */ export function FloorplanRegistryMoveOverlay() { const source = useMovingNode() - const currentStair = useScene((state) => - source?.type === 'stair' ? state.nodes[source.id] : undefined, - ) const movingNode = useMemo( () => - source && (source.type === 'stair' || isInteractionSubtreeDraft(source.id)) - ? (currentStair ?? useScene.getState().nodes[source.id] ?? source) + source && isInteractionSubtreeDraft(source.id) + ? (useScene.getState().nodes[source.id] ?? source) : source, - [source, currentStair], + [source], ) const setMovingNode = useEditor((s) => s.setMovingNode) const setMovingNodeOrigin = useEditor((s) => s.setMovingNodeOrigin) @@ -952,9 +949,6 @@ export function FloorplanRegistryMoveOverlay() { { position: [sx, oldY, sz], ...rotationPatch, - ...(movingNode.type === 'stair' && movingNode.landscapeSurfaceId - ? { landscapeSurfaceId: undefined } - : {}), } as Partial, ) } diff --git a/packages/nodes/src/fence/tool.tsx b/packages/nodes/src/fence/tool.tsx index 0a9f5fd66b..110ab36958 100644 --- a/packages/nodes/src/fence/tool.tsx +++ b/packages/nodes/src/fence/tool.tsx @@ -556,6 +556,14 @@ function getCurrentLevelElements(): { walls: WallNode[]; fences: FenceNode[] } { } } +function dropFenceSurfaceHost() { + const editor = useEditor.getState() + const defaults = editor.toolDefaults.fence + if (typeof defaults?.supportSurfaceNodeId !== 'string') return + const { supportSurfaceNodeId: _host, ...unhosted } = defaults + editor.setToolDefaults('fence', unhosted) +} + export const FenceTool: React.FC = () => { const levelId = useViewer((state) => state.selection.levelId) const levelMode = useViewer((state) => state.levelMode) @@ -567,11 +575,12 @@ export const FenceTool: React.FC = () => { levelId ? getLevelPresentationY(levelId, state.nodes, levelMode) : 0, ) const draftKey = `${levelId}:${floorY}` + // A surface host (a deck's railing) holds for one drawing session on its floor: leaving the + // floor or the tool drops it, and the fence tool draws plain fences again. useEffect(() => { - if (typeof hostId === 'string' && hostParentId !== levelId) { - useEditor.getState().setTool(null) - } + if (typeof hostId === 'string' && hostParentId !== levelId) dropFenceSurfaceHost() }, [hostId, levelId, hostParentId]) + useEffect(() => dropFenceSurfaceHost, []) const fenceMode = useEditor((s) => s.continuationByContext.fence) const feature = useEditor((s) => s.toolDefaults.fence?.featurePlacement) if (feature === 'gate' || feature === 'opening') return diff --git a/packages/nodes/src/stair/floorplan-move.ts b/packages/nodes/src/stair/floorplan-move.ts index 16ed739fe3..2ab68f0768 100644 --- a/packages/nodes/src/stair/floorplan-move.ts +++ b/packages/nodes/src/stair/floorplan-move.ts @@ -33,10 +33,13 @@ import { createFloorplanCursorResolver } from '../shared/floorplan-cursor' * scene once via `commit()`. */ export const stairFloorplanMoveTarget: FloorplanMoveTarget = ({ - node, + node: picked, nodes, sceneApi, }) => { + // The stair as the scene holds it now: a surface it sits on may have re-hosted it since + // it was picked up. + const node = (nodes[picked.id] as StairNode | undefined) ?? picked const startY = node.position[1] const resolveCursor = createFloorplanCursorResolver({ original: [node.position[0], node.position[2]], diff --git a/packages/nodes/src/stair/renderer.tsx b/packages/nodes/src/stair/renderer.tsx index 017f9e5fef..fa435fd7a1 100644 --- a/packages/nodes/src/stair/renderer.tsx +++ b/packages/nodes/src/stair/renderer.tsx @@ -165,6 +165,10 @@ export const StairRenderer = ({ node: rawNode }: { node: StairNode }) => { [baseRailingMaterial, node, sceneMaterials, shading, textures], ) + const onLevel = useScene((state) => + node.parentId ? state.nodes[node.parentId as AnyNodeId]?.type === 'level' : true, + ) + // Each flight contributes tread and body material groups to the merged mesh. const straightPlaceholderGeometry = useMemo(() => createPlaceholderGeometry(2), []) @@ -176,7 +180,11 @@ export const StairRenderer = ({ node: rawNode }: { node: StairNode }) => { return ( Date: Thu, 15 Oct 2026 15:35:47 -0400 Subject: [PATCH 28/31] Pin streetscape to pascalorg/plugin-streetscape The plugin moves from a personal fork to the org, at the same code as before (the streetscape-lab changes under pascal:streetscape) with its links pointing at the org repo. Co-Authored-By: Claude Opus 5.5 --- apps/editor/package.json | 2 +- bun.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/editor/package.json b/apps/editor/package.json index 470233e1ed..d5212b0002 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -24,7 +24,7 @@ "@pascal-app/plugin-bath-space": "github:sudhir9297/pascal-cluster#70cffa9a6878122acf6e3fdbde5725db4908f737", "@pascal-app/plugin-landscape": "github:sudhir9297/pascal-cluster#c7f555a89863fae67400a915d25c74e132f17472", "@pascal-app/plugin-pool": "github:sudhir9297/pool-pascal-plugin#b8e408121feae51d91c89f7efe16f9d8f795cb49", - "@pascal-app/plugin-streetscape": "github:wass08/streetscape-pascal-plugin#c4c012ead949f64aca7ffc3d2a310896f8a231ad", + "@pascal-app/plugin-streetscape": "github:pascalorg/plugin-streetscape#bb563f3739a0dbaf7a3d0cc40f7ef9486951b0a1", "@pascal-app/plugin-trees": "github:pascalorg/plugin-trees#56d978cd9b409b716207b3f3d269455d3cd6f067", "@pascal-app/viewer": "*", "@radix-ui/react-tooltip": "^1.4.1", diff --git a/bun.lock b/bun.lock index fe117adbc6..f74eeeb2d8 100644 --- a/bun.lock +++ b/bun.lock @@ -41,7 +41,7 @@ "@pascal-app/plugin-environment": "github:AxiomeCG/environment#1aa0bc3cbed273dbf751e9af79e446f6de105c90", "@pascal-app/plugin-landscape": "github:sudhir9297/pascal-cluster#c7f555a89863fae67400a915d25c74e132f17472", "@pascal-app/plugin-pool": "github:sudhir9297/pool-pascal-plugin#b8e408121feae51d91c89f7efe16f9d8f795cb49", - "@pascal-app/plugin-streetscape": "github:wass08/streetscape-pascal-plugin#c4c012ead949f64aca7ffc3d2a310896f8a231ad", + "@pascal-app/plugin-streetscape": "github:pascalorg/plugin-streetscape#bb563f3739a0dbaf7a3d0cc40f7ef9486951b0a1", "@pascal-app/plugin-trees": "github:pascalorg/plugin-trees#56d978cd9b409b716207b3f3d269455d3cd6f067", "@pascal-app/viewer": "*", "@radix-ui/react-tooltip": "^1.4.1", @@ -842,7 +842,7 @@ "@pascal-app/plugin-pool": ["@pascal-app/plugin-pool@github:sudhir9297/pool-pascal-plugin#b8e4081", { "dependencies": { "three-bvh-csg": "^0.0.18", "three-mesh-bvh": "^0.9.8" }, "peerDependencies": { "@pascal-app/core": ">=0.9.1 <1 || >=1.0.0-beta.0 <2", "@pascal-app/editor": ">=0.9.1 <1 || >=1.0.0-beta.0 <2", "@pascal-app/viewer": ">=0.9.1 <1 || >=1.0.0-beta.0 <2", "@react-three/fiber": "^9", "react": "^18 || ^19", "react-dom": "^18 || ^19", "three": "^0.186.0", "zod": "^4", "zustand": "^5" } }, "sudhir9297-pool-pascal-plugin-b8e4081", "sha512-tlsIyYUCLFqxBUr2WOBAp6vOLrgkHF+PuBlWTPTgoAMZNN435cwKzPfmzul5xl4FMQC3TZnC7G0oFpK0HXoiwQ=="], - "@pascal-app/plugin-streetscape": ["@pascal-app/plugin-streetscape@github:wass08/streetscape-pascal-plugin#c4c012e", { "dependencies": { "lucide-react": "^1.41.0", "maplibre-gl": "^6.7.0" }, "peerDependencies": { "@pascal-app/core": ">=0.9.1 <1", "@pascal-app/editor": ">=0.9.1 <1", "@pascal-app/viewer": ">=0.9.1 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "react-dom": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "wass08-streetscape-pascal-plugin-c4c012e", "sha512-jMkNWIlhlUSQCFcjjqWWpkTnxWyF8HoFQqIQ6FozhdDiRkCgvdtgCWDGMD8w4DMEgSEBwEiMhCuEGm3kjMtWXA=="], + "@pascal-app/plugin-streetscape": ["@pascal-app/plugin-streetscape@github:pascalorg/plugin-streetscape#bb563f3", { "dependencies": { "lucide-react": "^1.41.0", "maplibre-gl": "^6.7.0" }, "peerDependencies": { "@pascal-app/core": ">=0.9.1 <1", "@pascal-app/editor": ">=0.9.1 <1", "@pascal-app/viewer": ">=0.9.1 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "react-dom": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "pascalorg-plugin-streetscape-bb563f3", "sha512-QA8Z7PC2/GwyoabHCqVM1hdr7ZvrChsK7cklKG+L1ZqNln0O3rn6ooEqJxbBarbyCM82+Kbb/qz6KerjDtl3cw=="], "@pascal-app/plugin-trees": ["@pascal-app/plugin-trees@github:pascalorg/plugin-trees#56d978c", { "dependencies": { "@dgreenheck/ez-tree": "^1.1.0" }, "peerDependencies": { "@pascal-app/core": ">=0.9.1 <1", "@pascal-app/editor": ">=0.9.1 <1", "@pascal-app/viewer": ">=0.9.1 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "pascalorg-plugin-trees-56d978c", "sha512-16VzWot1oadvxCPqsRwMJbaP0a3u5FESFy7F7++pY5wAAFq9JTFwzHvKAsllrY5TZ5TJ7Y5vSqvbmQk8sy8HaA=="], From 0dd92df86ce9e30afd2b3f02c0ae1953a527dfe0 Mon Sep 17 00:00:00 2001 From: sudhir Date: Fri, 16 Oct 2026 11:06:24 +0530 Subject: [PATCH 29/31] fix(editor): address plugin architecture and surface workflow reviews --- DECISIONS.md | 13 +++ apps/editor/app/globals.css | 2 +- apps/editor/components/scene-loader.tsx | 8 +- apps/editor/lib/graph-schema.test.ts | 8 +- apps/editor/lib/graph-schema.ts | 5 +- apps/editor/lib/scene-signature.test.ts | 16 ++- apps/editor/lib/scene-signature.ts | 22 ++++ apps/editor/package.json | 2 +- bun.lock | 4 +- packages/cli/README.md | 14 ++- packages/cli/scripts/stage-runtime.ts | 18 +++ .../core/src/contracts/reference-inventory.ts | 2 +- .../spatial-grid/fence-support-patch.test.ts | 12 ++ .../hooks/spatial-grid/support-host-patch.ts | 10 +- packages/core/src/index.ts | 1 + .../src/lib/surface-support-references.ts | 2 +- .../src/schema/nodes}/stair-defaults.ts | 0 packages/core/src/schema/nodes/stair.test.ts | 40 +++++-- packages/core/src/schema/nodes/stair.ts | 7 +- .../src/services/stair-surface-snap.test.ts | 37 +++++- .../core/src/services/stair-surface-snap.ts | 78 +++++++++++-- packages/core/src/store/use-scene.ts | 16 +-- .../core/src/utils/clone-scene-graph.test.ts | 32 ++++++ packages/editor/package.json | 3 +- .../connection-compatibility.test.ts | 0 .../distribution}/connection-compatibility.ts | 0 .../tools/distribution/distribution-public.ts | 9 ++ .../distribution-run-contract.test.ts | 7 +- .../distribution-run-contract.ts | 0 .../distribution-run-tool.test.ts | 0 .../distribution-run-tool.test.tsx | 10 +- .../distribution}/distribution-run-tool.tsx | 9 +- .../tools/distribution}/draw-alignment.ts | 0 .../tools/distribution}/ports.test.ts | 17 ++- .../components/tools/distribution}/ports.ts | 0 .../tools/distribution}/run-cursor.test.ts | 0 .../tools/distribution}/run-cursor.ts | 0 .../run-direction-feedback.test.ts | 0 .../distribution}/run-direction-feedback.tsx | 0 .../tools/distribution}/run-port-snap.test.ts | 2 + .../tools/distribution}/run-port-snap.ts | 0 .../run-screen-direction.test.ts | 0 .../distribution}/run-screen-direction.ts | 0 .../components/ui/panels/panel-wrapper.tsx | 11 +- packages/editor/src/index.tsx | 15 +++ packages/editor/src/lib/glb-export.test.ts | 23 ++++ .../src/lib/wall-placement-pointer.test.ts | 42 +++++++ packages/nodes/package.json | 7 +- packages/nodes/src/duct-fitting/tool.tsx | 9 +- .../nodes/src/duct-segment/continuation.ts | 2 +- packages/nodes/src/duct-segment/move-tool.tsx | 2 +- packages/nodes/src/duct-segment/selection.tsx | 6 +- packages/nodes/src/duct-segment/tool.tsx | 31 +++-- packages/nodes/src/duct-terminal/tool.tsx | 9 +- packages/nodes/src/fence/drafting.test.ts | 51 ++++++++- packages/nodes/src/hvac-equipment/tool.tsx | 2 +- packages/nodes/src/lineset/tool.tsx | 9 +- packages/nodes/src/liquid-line/tool.tsx | 9 +- .../src/pipe-fitting/inline-insertion.ts | 2 +- packages/nodes/src/pipe-fitting/tool.tsx | 17 +-- .../nodes/src/pipe-segment/continuation.ts | 2 +- packages/nodes/src/pipe-segment/selection.tsx | 6 +- packages/nodes/src/pipe-segment/tool.tsx | 35 +++--- packages/nodes/src/pipe-trap/tool.tsx | 2 +- .../nodes/src/shared/accessory-placement.ts | 2 +- .../nodes/src/shared/accessory-snapping.ts | 6 +- .../nodes/src/shared/auto-fitting.test.ts | 4 +- packages/nodes/src/shared/auto-fitting.ts | 2 +- .../nodes/src/shared/automatic-run-end-cap.ts | 2 +- .../nodes/src/shared/connection-feedback.tsx | 9 +- .../nodes/src/shared/distribution-public.ts | 19 ---- packages/nodes/src/shared/duct-adapter.ts | 2 +- .../src/shared/elbow-branch-continuation.ts | 2 +- packages/nodes/src/shared/move-roof-tool.tsx | 101 +++++++---------- .../src/shared/pipe-auto-fitting.test.ts | 2 +- .../pipe-run-translation-offset.test.ts | 2 +- .../src/shared/pipe-run-translation-offset.ts | 2 +- .../src/shared/pipe-vertical-offset.test.ts | 2 +- .../nodes/src/shared/pipe-vertical-offset.ts | 2 +- .../src/shared/refrigerant-line-selection.tsx | 6 +- .../nodes/src/shared/run-crossing.test.ts | 4 +- .../src/shared/run-translation-offset.test.ts | 2 +- .../src/shared/run-translation-offset.ts | 2 +- packages/nodes/src/shared/system-checks.ts | 2 +- .../nodes/src/shared/vertical-offset.test.ts | 2 +- packages/nodes/src/shared/vertical-offset.ts | 2 +- packages/nodes/src/shared/wall-run-move.ts | 2 +- packages/nodes/src/stair/definition.ts | 2 +- .../nodes/src/stair/floorplan-move.test.ts | 12 +- packages/nodes/src/stair/floorplan-move.ts | 50 ++++---- packages/nodes/src/stair/floorplan.test.ts | 2 +- packages/nodes/src/stair/floorplan.ts | 2 +- .../src/stair/landscape-transition.test.ts | 70 ------------ packages/nodes/src/stair/panel.tsx | 22 ++-- packages/nodes/src/stair/renderer.tsx | 20 ++-- packages/nodes/src/stair/stair-type.ts | 2 +- ...w.test.ts => surface-move-preview.test.ts} | 6 +- ...ove-preview.ts => surface-move-preview.ts} | 4 +- .../src/stair/surface-transition.test.ts | 103 +++++++++++++++++ ...pe-transition.ts => surface-transition.ts} | 59 ++++------ packages/nodes/src/stair/tool.tsx | 107 +++++++++--------- .../src/lib/merged-outline-node.test.ts | 19 ++++ wiki/architecture/plugin-authoring.md | 54 +++++++++ 103 files changed, 944 insertions(+), 469 deletions(-) rename packages/{nodes/src/stair => core/src/schema/nodes}/stair-defaults.ts (100%) rename packages/{nodes/src/shared => editor/src/components/tools/distribution}/connection-compatibility.test.ts (100%) rename packages/{nodes/src/shared => editor/src/components/tools/distribution}/connection-compatibility.ts (100%) create mode 100644 packages/editor/src/components/tools/distribution/distribution-public.ts rename packages/{nodes/src/shared => editor/src/components/tools/distribution}/distribution-run-contract.test.ts (80%) rename packages/{nodes/src/shared => editor/src/components/tools/distribution}/distribution-run-contract.ts (100%) rename packages/{nodes/src/shared => editor/src/components/tools/distribution}/distribution-run-tool.test.ts (100%) rename packages/{nodes/src/shared => editor/src/components/tools/distribution}/distribution-run-tool.test.tsx (93%) rename packages/{nodes/src/shared => editor/src/components/tools/distribution}/distribution-run-tool.tsx (99%) rename packages/{nodes/src/shared => editor/src/components/tools/distribution}/draw-alignment.ts (100%) rename packages/{nodes/src/shared => editor/src/components/tools/distribution}/ports.test.ts (83%) rename packages/{nodes/src/shared => editor/src/components/tools/distribution}/ports.ts (100%) rename packages/{nodes/src/shared => editor/src/components/tools/distribution}/run-cursor.test.ts (100%) rename packages/{nodes/src/shared => editor/src/components/tools/distribution}/run-cursor.ts (100%) rename packages/{nodes/src/shared => editor/src/components/tools/distribution}/run-direction-feedback.test.ts (100%) rename packages/{nodes/src/shared => editor/src/components/tools/distribution}/run-direction-feedback.tsx (100%) rename packages/{nodes/src/shared => editor/src/components/tools/distribution}/run-port-snap.test.ts (98%) rename packages/{nodes/src/shared => editor/src/components/tools/distribution}/run-port-snap.ts (100%) rename packages/{nodes/src/shared => editor/src/components/tools/distribution}/run-screen-direction.test.ts (100%) rename packages/{nodes/src/shared => editor/src/components/tools/distribution}/run-screen-direction.ts (100%) create mode 100644 packages/editor/src/lib/wall-placement-pointer.test.ts delete mode 100644 packages/nodes/src/shared/distribution-public.ts delete mode 100644 packages/nodes/src/stair/landscape-transition.test.ts rename packages/nodes/src/stair/{landscape-move-preview.test.ts => surface-move-preview.test.ts} (91%) rename packages/nodes/src/stair/{landscape-move-preview.ts => surface-move-preview.ts} (93%) create mode 100644 packages/nodes/src/stair/surface-transition.test.ts rename packages/nodes/src/stair/{landscape-transition.ts => surface-transition.ts} (50%) diff --git a/DECISIONS.md b/DECISIONS.md index 66244949ef..7683902239 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -67,6 +67,19 @@ Each entry: decision · why · enforced by · revisit when. Detail and examples **E-016 Plugins own their agent tools (owner, 2026-10-10).** A plugin's agent tools ship with the plugin, not in core: contracts as a `PluginAgentTools` in its `./agent-tools` export (zod only, like `core/agent-tools`, and versioned by the plugin), pure operations as an `AgentOperations` in `./agent-operations`, browser execution and cards as a `PluginChatExtension`. The `Plugin` manifest does not change (E-014), and one contract still serves both surfaces. Contract annotations are hints: the host decides which tools are offered, to whom, and with what authority. These are reviewed, trusted contributions, not a sandbox. · Why: a plugin with dozens of specialist tools keeps them versioned with its own code, and installing it extends every agent the way a connector does. · Enforced: `core/src/agent-tools/contracts-purity.test.ts` (the rule each plugin entry follows), `mcp/src/tools/shared-tools.test.ts` (a plugin's operation tool through a real client), the hosted parity test. · See `plugin-authoring.md`, `agent-surfaces.md`. · Revisit: when plugins are loaded without review. +**E-017 Additive node editing and surface contracts (2026-10-16).** Surface boundaries, +connected movement, primary inspector predicates, independent child movement and +preserved level/elevation are optional contributions on `NodeDefinition` and its +existing capability types. They do not add fields to the v1 `Plugin` manifest. +Boundary providers own their geometry and coordinates; framework consumers dispatch +through the registry. Connected-move providers return pure patches, previewed with +live overrides and committed once per gesture. Inspector predicates select an ID +without subscribing the card to whole nodes. `metadata.roomBoundary === false` is +the explicit opt-out from room topology. Shared run-authoring tools are exported by +`editor/distribution`, never by the built-in plugin. · Why: external node packs need +the same authoring behavior without depending on built-in kinds or private plugin +shapes. · Contract: `wiki/architecture/plugin-authoring.md`. + ## Testing **T-001 (2026-10-01, owner).** A test protects an observable result, a persisted or wire contract, or an explicit architecture/performance invariant — and tolerates an equivalent implementation. Therefore: test through package exports; no source-grep, prose-pinning or import-spelling tests (use Biome rules); no cross-package `src/` imports in tests; no `mock.module` that forces sibling files to re-spawn `bun test`; no real sleeps (inject a clock); no tests named after review rounds or bots; refactor scaffolding (parity tables, "matches main" goldens, frozen copies of old code) is deleted in the PR that lands the refactor; performance claims live in `__bench__` / bench lanes, not unit gates; a bug fix is one repro in the feature's existing file, not a new `*-audit*` file; a test that needs an opt-in flag must have a lane that sets it, or go. "Internal" is not the deletion criterion: core's import boundary, the writers allowlist and the migration thresholds pin internals with external consequences and stay. · Why: 231k test lines (82k of them fixtures) had become the main cost of every change for people and agents alike. · Enforced: review; Biome rules as they land. diff --git a/apps/editor/app/globals.css b/apps/editor/app/globals.css index 3a865f5d41..441cb75f1d 100644 --- a/apps/editor/app/globals.css +++ b/apps/editor/app/globals.css @@ -5,7 +5,7 @@ @source "../../../packages/nodes/src"; @source "../../../node_modules/@pascal-app/plugin-trees/src"; @source "../../../node_modules/@mint/pascal-plugin/src"; -@source "../../../node_modules/@pascal-app/plugin-streetscape-lab/src"; +@source "../../../node_modules/@pascal-app/plugin-streetscape/src"; @source "../../../node_modules/@pascal-app/plugin-environment/src"; @source "../../../node_modules/@pascal-app/plugin-landscape/src"; diff --git a/apps/editor/components/scene-loader.tsx b/apps/editor/components/scene-loader.tsx index c843d222c7..1622d61c43 100644 --- a/apps/editor/components/scene-loader.tsx +++ b/apps/editor/components/scene-loader.tsx @@ -18,7 +18,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { PoolSectionBanner } from '@/components/pool-section-banner' import { countGraphNodes, isEmptyGraphOverwrite } from '@/lib/empty-graph-guard' import { useLocalArtifactStore } from '@/lib/local-artifact-store' -import { type PersistedSceneGraph, sceneGraphSignature } from '@/lib/scene-signature' +import { + isPendingSceneGraphEcho, + type PersistedSceneGraph, + sceneGraphSignature, +} from '@/lib/scene-signature' import { useThumbnailAutosave } from '@/lib/use-thumbnail-autosave' import { BuildTab } from './build-tab' import { EditorNavbar } from './editor-navbar' @@ -320,7 +324,7 @@ function SceneEditor({ initialScene, meta: initialMeta }: SceneLoaderProps) { const signature = sceneGraphSignature(payload.graph) // SSE can deliver our save before its HTTP response. Reapplying that // graph would reset tools and selection while the user keeps editing. - if (pendingGraphSignaturesRef.current.has(signature)) return + if (isPendingSceneGraphEcho(payload.graph, pendingGraphSignaturesRef.current)) return lastRemoteGraphJsonRef.current = signature suppressRemoteSaveUntilRef.current = Date.now() + 2500 applySceneGraphToEditor(payload.graph) diff --git a/apps/editor/lib/graph-schema.test.ts b/apps/editor/lib/graph-schema.test.ts index f2f96a7166..00fa489e13 100644 --- a/apps/editor/lib/graph-schema.test.ts +++ b/apps/editor/lib/graph-schema.test.ts @@ -38,7 +38,13 @@ test('accepts namespaced attachment slots while validating plugin asset URLs', ( const node = pluginTree({ slotId: `wall-point:${TREE_ID}` }) expect(apiGraphSchema.safeParse(buildGraph({ [TREE_ID]: node })).success).toBe(true) - for (const url of ['javascript:alert(1)', 'file:///etc/passwd']) { + for (const url of [ + 'javascript:alert(1)', + 'file:///etc/passwd', + 'javascript:alert', + 'file:secret', + 'data:text', + ]) { expect( apiGraphSchema.safeParse(buildGraph({ [TREE_ID]: { ...node, thumbnail: url } })).success, ).toBe(false) diff --git a/apps/editor/lib/graph-schema.ts b/apps/editor/lib/graph-schema.ts index dd3ed9a115..7c32292786 100644 --- a/apps/editor/lib/graph-schema.ts +++ b/apps/editor/lib/graph-schema.ts @@ -84,13 +84,12 @@ function findRejectedUrl(root: unknown): ScanResult | null { if (value && typeof value === 'object') { for (const [key, child] of Object.entries(value)) { if (depth === 0 && key === 'type') continue - // Attachment slots can be namespaced, such as wall-point:. - // Only identifier characters qualify; URL paths and controls still scan. + // Wall attachment slots contain a node ID, not a URL scheme. if ( depth === 0 && key === 'slotId' && typeof child === 'string' && - /^[\w.-]+(?::[\w.-]+)*$/.test(child) + /^wall-point:[\w.-]+$/.test(child) ) { continue } diff --git a/apps/editor/lib/scene-signature.test.ts b/apps/editor/lib/scene-signature.test.ts index 6bcce47df8..acecb07ba5 100644 --- a/apps/editor/lib/scene-signature.test.ts +++ b/apps/editor/lib/scene-signature.test.ts @@ -1,5 +1,9 @@ import { expect, test } from 'bun:test' -import { type PersistedSceneGraph, sceneGraphSignature } from './scene-signature' +import { + isPendingSceneGraphEcho, + type PersistedSceneGraph, + sceneGraphSignature, +} from './scene-signature' const NODE_ID = 'level_a1b2c3d4e5f6g7h8' const MATERIAL_ID = 'mat_a1b2c3d4e5f6g7h8' @@ -39,3 +43,13 @@ test('changing any signed field changes the signature', () => { base, ) }) + +test('an in-flight save echo cannot replace tools when event metadata is omitted', () => { + const saved = graph({ installedPlugins: ['pascal:bath-space'], collections: { col_1: {} } }) + const pending = new Set([sceneGraphSignature(saved)]) + expect(isPendingSceneGraphEcho(graph(), pending)).toBe(true) + expect(isPendingSceneGraphEcho(graph({ installedPlugins: [] }), pending)).toBe(false) + expect(isPendingSceneGraphEcho(graph({ rootNodeIds: [] }), pending)).toBe(false) + pending.clear() + expect(isPendingSceneGraphEcho(saved, pending)).toBe(false) +}) diff --git a/apps/editor/lib/scene-signature.ts b/apps/editor/lib/scene-signature.ts index a5a4587dd0..956bd4ef0c 100644 --- a/apps/editor/lib/scene-signature.ts +++ b/apps/editor/lib/scene-signature.ts @@ -25,3 +25,25 @@ export function sceneGraphSignature(graph: PersistedSceneGraph): string { installedPlugins: graph.installedPlugins ?? [], }) } + +/** Match a pending save even when an event omits optional graph metadata. */ +export function isPendingSceneGraphEcho( + graph: PersistedSceneGraph, + pending: ReadonlySet, +): boolean { + if (pending.has(sceneGraphSignature(graph))) return true + for (const signature of pending) { + const saved = JSON.parse(signature) as PersistedSceneGraph + if ( + sceneGraphSignature({ + ...saved, + ...graph, + collections: graph.collections ?? saved.collections, + materials: graph.materials ?? saved.materials, + installedPlugins: graph.installedPlugins ?? saved.installedPlugins, + }) === signature + ) + return true + } + return false +} diff --git a/apps/editor/package.json b/apps/editor/package.json index d5212b0002..7eeb5360e2 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -22,7 +22,7 @@ "@pascal-app/plugin-bones": "github:pascalorg/plugin-bones#5679260261ee1c733656ff6dfb99e30bb24b58a7", "@pascal-app/plugin-environment": "github:AxiomeCG/environment#1aa0bc3cbed273dbf751e9af79e446f6de105c90", "@pascal-app/plugin-bath-space": "github:sudhir9297/pascal-cluster#70cffa9a6878122acf6e3fdbde5725db4908f737", - "@pascal-app/plugin-landscape": "github:sudhir9297/pascal-cluster#c7f555a89863fae67400a915d25c74e132f17472", + "@pascal-app/plugin-landscape": "github:sudhir9297/pascal-cluster#7425539db7e63d4d42f65bef938420b4145479c3", "@pascal-app/plugin-pool": "github:sudhir9297/pool-pascal-plugin#b8e408121feae51d91c89f7efe16f9d8f795cb49", "@pascal-app/plugin-streetscape": "github:pascalorg/plugin-streetscape#bb563f3739a0dbaf7a3d0cc40f7ef9486951b0a1", "@pascal-app/plugin-trees": "github:pascalorg/plugin-trees#56d978cd9b409b716207b3f3d269455d3cd6f067", diff --git a/bun.lock b/bun.lock index f74eeeb2d8..739958f4b3 100644 --- a/bun.lock +++ b/bun.lock @@ -39,7 +39,7 @@ "@pascal-app/plugin-bath-space": "github:sudhir9297/pascal-cluster#70cffa9a6878122acf6e3fdbde5725db4908f737", "@pascal-app/plugin-bones": "github:pascalorg/plugin-bones#5679260261ee1c733656ff6dfb99e30bb24b58a7", "@pascal-app/plugin-environment": "github:AxiomeCG/environment#1aa0bc3cbed273dbf751e9af79e446f6de105c90", - "@pascal-app/plugin-landscape": "github:sudhir9297/pascal-cluster#c7f555a89863fae67400a915d25c74e132f17472", + "@pascal-app/plugin-landscape": "github:sudhir9297/pascal-cluster#7425539db7e63d4d42f65bef938420b4145479c3", "@pascal-app/plugin-pool": "github:sudhir9297/pool-pascal-plugin#b8e408121feae51d91c89f7efe16f9d8f795cb49", "@pascal-app/plugin-streetscape": "github:pascalorg/plugin-streetscape#bb563f3739a0dbaf7a3d0cc40f7ef9486951b0a1", "@pascal-app/plugin-trees": "github:pascalorg/plugin-trees#56d978cd9b409b716207b3f3d269455d3cd6f067", @@ -838,7 +838,7 @@ "@pascal-app/plugin-environment": ["@pascal-app/plugin-environment@github:AxiomeCG/environment#1aa0bc3", { "peerDependencies": { "@dgreenheck/ez-tree": "^1.1.0", "@pascal-app/core": ">=1.0.0-beta.6 <2", "@pascal-app/editor": ">=1.0.0-beta.6 <2", "@pascal-app/viewer": ">=1.0.0-beta.6 <2", "@radix-ui/react-tooltip": "^1.2.8", "@react-three/fiber": "^9", "lucide-react": "^1.7.0", "react": "^18 || ^19", "react-colorful": "^5.8.1", "three": "^0.186", "zod": "^4", "zustand": "^5" } }, "AxiomeCG-environment-1aa0bc3", "sha512-wvCYDEr62ItSwgWf8zOJL0z97zTyZ7TszrFwLOcIhX7LRYrmHhP/6LE7lB6lBBeXNPExg7jRk8eQZm+ri6GfaQ=="], - "@pascal-app/plugin-landscape": ["@pascal-app/plugin-landscape@github:sudhir9297/pascal-cluster#c7f555a", { "dependencies": { "@types/clipper-lib": "^6.4.0", "clipper-lib": "^6.4.2", "polygon-clipping": "^0.15.7" }, "peerDependencies": { "@pascal-app/core": "^1.0.3", "@pascal-app/editor": "^1.0.3", "@pascal-app/nodes": "^1.0.1", "@pascal-app/viewer": "^1.0.3", "@react-three/drei": "^10", "@react-three/fiber": "^9", "react": "^18 || ^19", "three": "^0.186.0", "zod": ">=4.5.4 <4.6" } }, "sudhir9297-pascal-cluster-c7f555a", "sha512-MskoRQw3uTYD3tTHSiFbiSQXaXzzu+N7o35hpdgxz9zv4IRfkFzsahAcdL/magb3bLPGcLyqia6y6P5DHV45Rg=="], + "@pascal-app/plugin-landscape": ["@pascal-app/plugin-landscape@github:sudhir9297/pascal-cluster#7425539", { "dependencies": { "@types/clipper-lib": "^6.4.0", "clipper-lib": "^6.4.2", "polygon-clipping": "^0.15.7" }, "peerDependencies": { "@pascal-app/core": "^1.0.4", "@pascal-app/editor": "^1.0.4", "@pascal-app/viewer": "^1.0.4", "@react-three/drei": "^10", "@react-three/fiber": "^9", "react": "^18 || ^19", "three": "^0.186.0", "zod": "^4.6.7" } }, "sudhir9297-pascal-cluster-7425539", "sha512-La+nGzzyStNVGEdHHrVD/0afwUM5dzWPZxK0AYkRFnjnpDOdlp+IxaGiXkcvdwWvVTPTDOeZc46nCDzC+AnOtw=="], "@pascal-app/plugin-pool": ["@pascal-app/plugin-pool@github:sudhir9297/pool-pascal-plugin#b8e4081", { "dependencies": { "three-bvh-csg": "^0.0.18", "three-mesh-bvh": "^0.9.8" }, "peerDependencies": { "@pascal-app/core": ">=0.9.1 <1 || >=1.0.0-beta.0 <2", "@pascal-app/editor": ">=0.9.1 <1 || >=1.0.0-beta.0 <2", "@pascal-app/viewer": ">=0.9.1 <1 || >=1.0.0-beta.0 <2", "@react-three/fiber": "^9", "react": "^18 || ^19", "react-dom": "^18 || ^19", "three": "^0.186.0", "zod": "^4", "zustand": "^5" } }, "sudhir9297-pool-pascal-plugin-b8e4081", "sha512-tlsIyYUCLFqxBUr2WOBAp6vOLrgkHF+PuBlWTPTgoAMZNN435cwKzPfmzul5xl4FMQC3TZnC7G0oFpK0HXoiwQ=="], diff --git a/packages/cli/README.md b/packages/cli/README.md index 5bd4cfa358..7d0be6ad46 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -86,8 +86,18 @@ npx @pascal-app/cli editor --foreground --no-open The npm package carries the CLI and the MCP service only: about 0.5 MB compressed and 2.5 MB installed. The web editor itself—the Next.js server, its static assets, and the -bundled item library—is published as one archive per CLI version, about 64 MB compressed -and 106 MB on disk. +bundled item and plugin libraries—is published as one archive per CLI version. +With Bath Space, Landscape, Pool, Streetscape and WebXR included, the current archive +is about 184 MB compressed. The previous runtime without those catalogs was about +64 MB. Plugin media includes about 93 MB of WebP images and 57 MB of GLB models before +archive compression; these files are required for offline thumbnails and models. +The runtime is downloaded once per version, outside the npm package. + +`stage-runtime` reports the compressed size and the static-media breakdown. The packed +runtime smoke check enforces the 220 MiB release ceiling. Removing catalog media to +meet the old budget would break offline projects; distributing optional catalog packs +would need a separate install/download contract and scene-loading fallback. A package +revision update should include this size report so media growth is visible in review. Every command that starts the editor (`editor`, `start`, `open`, `resume`, `projects`, `project open`, `update`) resolves that runtime in this order: diff --git a/packages/cli/scripts/stage-runtime.ts b/packages/cli/scripts/stage-runtime.ts index 53a85cc643..525e914512 100644 --- a/packages/cli/scripts/stage-runtime.ts +++ b/packages/cli/scripts/stage-runtime.ts @@ -94,6 +94,7 @@ await flattenBunNodeModules(outputDirectory) await materializeSymlinks(outputDirectory) await rm(path.join(outputDirectory, 'node_modules/.bun'), { recursive: true, force: true }) await pruneBuildOnlyFiles(outputDirectory) +await reportStaticMedia(path.join(outputDirectory, 'apps/editor/.next/static/media')) const nativeFiles = await findNativeModules(outputDirectory) if (nativeFiles.length > 0) { throw new Error(`portable runtime contains native modules:\n${nativeFiles.join('\n')}`) @@ -348,3 +349,20 @@ async function findNativeModules(root: string): Promise { await walk(root) return result.sort() } + +async function reportStaticMedia(directory: string): Promise { + const totals = new Map() + for (const entry of await readdir(directory, { withFileTypes: true })) { + if (!entry.isFile()) continue + const extension = path.extname(entry.name) || '(no extension)' + const total = totals.get(extension) ?? { bytes: 0, count: 0 } + total.bytes += (await stat(path.join(directory, entry.name))).size + total.count += 1 + totals.set(extension, total) + } + for (const [extension, total] of [...totals].sort((a, b) => b[1].bytes - a[1].bytes)) { + console.log( + `Runtime media ${extension}: ${total.count} files, ${formatMegabytes(total.bytes)} MB`, + ) + } +} diff --git a/packages/core/src/contracts/reference-inventory.ts b/packages/core/src/contracts/reference-inventory.ts index 655f0fdd8f..60f8c18879 100644 --- a/packages/core/src/contracts/reference-inventory.ts +++ b/packages/core/src/contracts/reference-inventory.ts @@ -242,7 +242,7 @@ export const EXISTING_REFERENCES: readonly ExistingReference[] = [ }), row({ kind: 'stair', - path: 'landscapeSurfaceId', + path: 'destinationSurfaceId', ...policy('node', 'host', 'drop', 'strip'), remaps: [...CLONES, 'clone-nodes-into', 'delete-nodes'], note: 'A registered top surface supporting a stair connection.', diff --git a/packages/core/src/hooks/spatial-grid/fence-support-patch.test.ts b/packages/core/src/hooks/spatial-grid/fence-support-patch.test.ts index 9ad31a19c0..088a61c3ea 100644 --- a/packages/core/src/hooks/spatial-grid/fence-support-patch.test.ts +++ b/packages/core/src/hooks/spatial-grid/fence-support-patch.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, test } from 'bun:test' +import { FenceNode, resolveFenceConstructionSupport } from '@pascal-app/core' import type { AnyNode, SlabNode } from '../../schema' import useScene from '../../store/use-scene' import { spatialGridManager } from './spatial-grid-manager' @@ -178,3 +179,14 @@ describe('resolveFenceSupportSlabPatch', () => { }) }) }) + +test('explicit railing hosts must be top surfaces on the fence level', () => { + const deck = makeSlab('slab_deck', DECK_POLYGON, DECK_ELEVATION) + const fence = FenceNode.parse({ parentId: LEVEL_ID, start: [0, 0], end: [3, 0] }) + for (const host of [{ ...deck, parentId: 'level_other' }, makeLevel()]) { + const result = resolveFenceConstructionSupport(fence, LEVEL_ID, nodesFor(makeLevel(), host), { + supportSurfaceNodeId: host.id, + }) + expect(result.supportSurfaceNodeId).toBeUndefined() + } +}) diff --git a/packages/core/src/hooks/spatial-grid/support-host-patch.ts b/packages/core/src/hooks/spatial-grid/support-host-patch.ts index d09f1ff894..883ebcc53b 100644 --- a/packages/core/src/hooks/spatial-grid/support-host-patch.ts +++ b/packages/core/src/hooks/spatial-grid/support-host-patch.ts @@ -379,11 +379,17 @@ export function resolveFenceConstructionSupport( nodes: Record, options?: FenceConstructionOptions, ): FenceNode { - if (options?.supportSurfaceNodeId && nodes[options.supportSurfaceNodeId]) { + const requestedSurface = options?.supportSurfaceNodeId + ? nodes[options.supportSurfaceNodeId] + : null + if ( + requestedSurface?.parentId === levelId && + nodeRegistry.get(requestedSurface.type)?.capabilities?.surfaces?.top + ) { return { ...fence, supportSlabId: undefined, - supportSurfaceNodeId: options.supportSurfaceNodeId, + supportSurfaceNodeId: requestedSurface.id, } } const surface = fence.supportSurfaceNodeId ? nodes[fence.supportSurfaceNodeId] : null diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5a06a2906e..c7fc1391e9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -573,6 +573,7 @@ export * from './registry' // and must stay free of React imports. export { useRegistryVersion } from './registry/use-registry-version' export * from './schema' +export * from './schema/nodes/stair-defaults' export * from './services' export { isMovable, movePlanToward, moveToward, resolveMovable } from './services/movement' export * from './services/project-file-sink' diff --git a/packages/core/src/lib/surface-support-references.ts b/packages/core/src/lib/surface-support-references.ts index ff380ce756..3cd3ec53fb 100644 --- a/packages/core/src/lib/surface-support-references.ts +++ b/packages/core/src/lib/surface-support-references.ts @@ -1,6 +1,6 @@ import type { AnyNode } from '../schema/types' -const fields = ['supportSurfaceNodeId', 'landscapeSurfaceId'] as const +const fields = ['supportSurfaceNodeId', 'destinationSurfaceId'] as const export function remapSurfaceSupportReferences( node: AnyNode, diff --git a/packages/nodes/src/stair/stair-defaults.ts b/packages/core/src/schema/nodes/stair-defaults.ts similarity index 100% rename from packages/nodes/src/stair/stair-defaults.ts rename to packages/core/src/schema/nodes/stair-defaults.ts diff --git a/packages/core/src/schema/nodes/stair.test.ts b/packages/core/src/schema/nodes/stair.test.ts index e77d062afc..beec38bf9f 100644 --- a/packages/core/src/schema/nodes/stair.test.ts +++ b/packages/core/src/schema/nodes/stair.test.ts @@ -1,22 +1,40 @@ import { describe, expect, test } from 'bun:test' -import { StairNode } from './stair' +import { type AnyNode, StairNode, useScene } from '@pascal-app/core' -describe('StairNode landscape connection', () => { - test('defaults magnetic landscape snapping on and preserves its surface link', () => { +describe('StairNode surface connection', () => { + test('defaults magnetic surface snapping on and preserves its surface link', () => { const legacy = StairNode.parse({ id: 'stair_legacy', type: 'stair' }) - expect(legacy.autoLandscapeSnap).toBeUndefined() - expect(legacy.landscapeSurfaceId).toBeUndefined() + expect(legacy.autoSurfaceSnap).toBeUndefined() + expect(legacy.destinationSurfaceId).toBeUndefined() const attached = StairNode.parse({ ...legacy, - autoLandscapeSnap: false, - landscapeSurfaceId: 'patio_1', + autoSurfaceSnap: false, + destinationSurfaceId: 'patio_1', }) - expect(attached.autoLandscapeSnap).toBe(false) - expect(attached.landscapeSurfaceId).toBe('patio_1') + expect(attached.autoSurfaceSnap).toBe(false) + expect(attached.destinationSurfaceId).toBe('patio_1') expect(StairNode.parse(JSON.parse(JSON.stringify(attached)))).toMatchObject({ - autoLandscapeSnap: false, - landscapeSurfaceId: 'patio_1', + autoSurfaceSnap: false, + destinationSurfaceId: 'patio_1', }) }) }) + +test('loading a scene preserves legacy stair destination and snap preference under generic names', () => { + const previous = useScene.getState() + const legacy = { + ...StairNode.parse({ id: 'stair_migration' }), + landscapeSurfaceId: 'deck_1', + autoLandscapeSnap: false, + } + try { + useScene.getState().setScene({ [legacy.id]: legacy as AnyNode }, [legacy.id]) + const loaded = useScene.getState().nodes[legacy.id] + expect(loaded).toMatchObject({ destinationSurfaceId: 'deck_1', autoSurfaceSnap: false }) + expect(loaded).not.toHaveProperty('landscapeSurfaceId') + expect(loaded).not.toHaveProperty('autoLandscapeSnap') + } finally { + useScene.setState(previous, true) + } +}) diff --git a/packages/core/src/schema/nodes/stair.ts b/packages/core/src/schema/nodes/stair.ts index 0eb5e3a9b5..c8021c8a85 100644 --- a/packages/core/src/schema/nodes/stair.ts +++ b/packages/core/src/schema/nodes/stair.ts @@ -64,8 +64,9 @@ export const StairNode = BaseNode.extend({ designTargets: StairDesignTargets.optional(), uniformRisers: z.boolean().optional(), stepCount: z.number().default(10), - autoLandscapeSnap: z.boolean().optional(), - landscapeSurfaceId: z.string().optional(), + // Optional magnetic destination connection, independent of the source support. + autoSurfaceSnap: z.boolean().optional(), + destinationSurfaceId: z.string().optional(), thickness: z.number().default(0.25), fillToFloor: z.boolean().default(true), construction: StairConstruction.optional(), @@ -114,7 +115,7 @@ export const StairNode = BaseNode.extend({ - totalRise: total stair height - stepCount: number of visible steps - construction: optional explicit construction and finish details, inherited by child segments; absence preserves legacy bodies - - autoLandscapeSnap: magnetically connect straight flights to nearby landscape surfaces + - autoSurfaceSnap: magnetically connect straight flights to nearby registered top surfaces - thickness: stair slab / tread thickness - fillToFloor: whether the stair mass fills down to the floor or uses tread thickness only - innerRadius: inner curve radius for curved stairs diff --git a/packages/core/src/services/stair-surface-snap.test.ts b/packages/core/src/services/stair-surface-snap.test.ts index 6ac019f9ba..3928d3337d 100644 --- a/packages/core/src/services/stair-surface-snap.test.ts +++ b/packages/core/src/services/stair-surface-snap.test.ts @@ -114,9 +114,9 @@ test('connects to a concrete slab and accepts a cursor on its edge', () => { test('per-stair switch disables magnetic attachment', () => { const deck = surface('landscape:deck') - const disabled = StairNode.parse({ ...stair, autoLandscapeSnap: false }) + const disabled = StairNode.parse({ ...stair, autoSurfaceSnap: false }) expect(resolveStairSurfaceSnap(disabled, { [deck.id]: deck }, [0, 0, -2], 3)).toBeNull() - expect(StairNode.parse({}).autoLandscapeSnap).toBeUndefined() + expect(StairNode.parse({}).autoSurfaceSnap).toBeUndefined() }) test('reconnects a placed stair after its deck height changes', () => { @@ -126,7 +126,7 @@ test('reconnects a placed stair after its deck height changes', () => { ...stair, position: first.position, rotation: first.rotation, - landscapeSurfaceId: deck.id, + destinationSurfaceId: deck.id, totalRise: first.totalRise, stepCount: first.stepCount, }) @@ -145,7 +145,11 @@ test('reconnects a placed stair after its deck height changes', () => { test('reconnects by the rotated high end of an existing stair', () => { const deck = surface('landscape:deck', { thickness: 0.8 }) - const rotated = StairNode.parse({ ...stair, rotation: Math.PI / 2, landscapeSurfaceId: deck.id }) + const rotated = StairNode.parse({ + ...stair, + rotation: Math.PI / 2, + destinationSurfaceId: deck.id, + }) const result = resolveStairSurfaceSnap(rotated, { [deck.id]: deck }, [-2.3, 0, 0], 0.8) expect(result?.totalRise).toBeCloseTo(0.8) expect(result?.rotation).toBeCloseTo(Math.PI / 2) @@ -165,3 +169,28 @@ test('aligns to the actual curved edge of a circular patio', () => { expect(result!.position[2] + result!.length).toBeCloseTo(-2, 2) expect(result!.rotation).toBeCloseTo(0, 1) }) + +test('a surface-parented stair snaps in the same level frame without changing its stored base offset', () => { + const deck = surface('landscape:deck', { + position: [4, 0.3, 3], + rotation: [0, Math.PI / 2, 0], + thickness: 1.2, + }) + const level = { id: 'level_1', type: 'level', parentId: null } as AnyNode + const child = StairNode.parse({ + ...stair, + parentId: deck.id, + position: [0, -0.1, -2], + destinationSurfaceId: deck.id, + }) + const snap = resolveStairSurfaceSnap( + child, + { [deck.id]: deck, [level.id]: level }, + child.position, + 1, + ) + expect(snap).not.toBeNull() + expect(snap!.position[2] + snap!.length).toBeCloseTo(-1.5) + expect(snap!.position[1]).toBe(-0.1) + expect(snap!.totalRise).toBeCloseTo(1.3) +}) diff --git a/packages/core/src/services/stair-surface-snap.ts b/packages/core/src/services/stair-surface-snap.ts index c778997917..fb8d6234d0 100644 --- a/packages/core/src/services/stair-surface-snap.ts +++ b/packages/core/src/services/stair-surface-snap.ts @@ -24,16 +24,18 @@ export function resolveStairSurfaceSnap( baseElevation = candidatePosition[1], installedPlugins?: readonly string[], ): StairSurfaceSnap | null { - if (stair.stairType !== 'straight' || stair.autoLandscapeSnap === false) return null - const candidate: Point = [candidatePosition[0], candidatePosition[2]] + if (stair.stairType !== 'straight' || stair.autoSurfaceSnap === false) return null + const parentFrame = surfaceFrame(stair.parentId, nodes) + const candidate = toFrame([candidatePosition[0], candidatePosition[2]], parentFrame) + const rotation = stair.rotation + parentFrame.rotation const highEnd: Point = [ - candidate[0] + Math.sin(stair.rotation) * currentLength, - candidate[1] + Math.cos(stair.rotation) * currentLength, + candidate[0] + Math.sin(rotation) * currentLength, + candidate[1] + Math.cos(rotation) * currentLength, ] let closest: { distance: number; snap: StairSurfaceSnap } | null = null for (const raw of Object.values(nodes)) { if ( - raw.parentId !== stair.parentId || + surfaceFrame(raw.parentId, nodes).levelId !== parentFrame.levelId || raw.visible === false || !isNodeKindEnabled(raw.type, installedPlugins) ) @@ -41,7 +43,8 @@ export function resolveStairSurfaceSnap( const top = nodeRegistry.get(raw.type)?.capabilities?.surfaces?.top if (!top?.boundary) continue const surface = raw as AnyNode & { position?: [number, number, number] } - const outline = top.boundary(raw, 0) + const targetFrame = surfaceFrame(raw.parentId, nodes) + const outline = top.boundary(raw, 0).map((point) => toFrame(point, targetFrame)) if (outline.length < 3) continue const area = outline.reduce((sum, p, index) => { const next = outline[(index + 1) % outline.length]! @@ -70,21 +73,25 @@ export function resolveStairSurfaceSnap( const outside = -((candidate[0] - edge[0]) * nx + (candidate[1] - edge[1]) * nz) if (outside < -SNAP_DISTANCE) continue const distance = Math.min(lowDistance, highDistance) - const catchDistance = stair.landscapeSurfaceId === surface.id ? 1.25 : SNAP_DISTANCE + const catchDistance = stair.destinationSurfaceId === surface.id ? 1.25 : SNAP_DISTANCE if (distance > catchDistance || (closest && distance >= closest.distance)) continue const height = - typeof top.height === 'function' ? top.height(raw, { nodes, point: edge }) : top.height - const topElevation = (surface.position?.[1] ?? 0) + height + floorConstructionLift(nodes, raw) - const rise = topElevation - baseElevation + typeof top.height === 'function' + ? top.height(raw, { nodes, point: fromFrame(edge, targetFrame) }) + : top.height + const topElevation = + targetFrame.y + (surface.position?.[1] ?? 0) + height + floorConstructionLift(nodes, raw) + const rise = topElevation - (baseElevation + parentFrame.y) if (!Number.isFinite(rise) || rise <= 0.01) continue const stepCount = Math.max(1, Math.round(rise / 0.16)) const minRun = stepCount * 0.28 const length = Math.max(minRun, outside, 0.4) + const localPoint = fromFrame([edge[0] - nx * length, edge[1] - nz * length], parentFrame) closest = { distance, snap: { - position: [edge[0] - nx * length, candidatePosition[1], edge[1] - nz * length], - rotation: Math.atan2(nx, nz), + position: [localPoint[0], candidatePosition[1], localPoint[1]], + rotation: Math.atan2(nx, nz) - parentFrame.rotation, totalRise: rise, stepCount, length, @@ -109,3 +116,50 @@ export function getBoundarySurfaces( Boolean(nodeRegistry.get(node.type)?.capabilities?.surfaces?.top?.boundary), ) } + +type SurfaceFrame = { + x: number + y: number + z: number + rotation: number + levelId: string | null | undefined +} + +function surfaceFrame( + parentId: string | null | undefined, + nodes: Readonly>, +): SurfaceFrame { + const frame: SurfaceFrame = { x: 0, y: 0, z: 0, rotation: 0, levelId: parentId } + const visited = new Set() + while (frame.levelId && !visited.has(frame.levelId)) { + visited.add(frame.levelId) + const parent = nodes[frame.levelId] + if (!parent || parent.type === 'level') break + const pose = parent as AnyNode & { + position?: [number, number, number] + rotation?: number | [number, number, number] + } + const angle = typeof pose.rotation === 'number' ? pose.rotation : (pose.rotation?.[1] ?? 0) + const c = Math.cos(angle), + s = Math.sin(angle) + const [x, y, z] = pose.position ?? [0, 0, 0] + const nextX = x + frame.x * c + frame.z * s + frame.z = z - frame.x * s + frame.z * c + frame.x = nextX + frame.y += y + frame.rotation += angle + frame.levelId = parent.parentId + } + return frame +} + +function toFrame([x, z]: readonly [number, number], frame: SurfaceFrame): Point { + const c = Math.cos(frame.rotation), + s = Math.sin(frame.rotation) + return [frame.x + x * c + z * s, frame.z - x * s + z * c] +} +function fromFrame([x, z]: Point, frame: SurfaceFrame): Point { + const c = Math.cos(frame.rotation), + s = Math.sin(frame.rotation) + return [(x - frame.x) * c - (z - frame.z) * s, (x - frame.x) * s + (z - frame.z) * c] +} diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index 259e776ab6..fd8b71577a 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -643,13 +643,6 @@ function migrateNodes(nodes: Record): { // "missing" — corrupting the level. Running elevators in a second pass after // all levels are stable avoids the race entirely. for (const [id, node] of Object.entries(patchedNodes)) { - if (node.type === 'fence' && 'supportSurfaceId' in node) { - const { supportSurfaceId, ...canonicalFence } = node - patchedNodes[id] = { - ...canonicalFence, - supportSurfaceNodeId: node.supportSurfaceNodeId ?? supportSurfaceId, - } - } // 1. Item scale migration if (node.type === 'item' && !('scale' in node)) { patchedNodes[id] = { ...node, scale: [1, 1, 1] } @@ -782,7 +775,14 @@ function migrateNodes(nodes: Record): { } if (node.type === 'stair') { - const normalized = normalizeStairNode(migrateStairSurfaceMaterials(node)) + const { landscapeSurfaceId, autoLandscapeSnap, ...canonicalStair } = node + const normalized = normalizeStairNode( + migrateStairSurfaceMaterials({ + ...canonicalStair, + destinationSurfaceId: node.destinationSurfaceId ?? landscapeSurfaceId, + autoSurfaceSnap: node.autoSurfaceSnap ?? autoLandscapeSnap, + }), + ) if (normalized) { patchedNodes[id] = normalized } diff --git a/packages/core/src/utils/clone-scene-graph.test.ts b/packages/core/src/utils/clone-scene-graph.test.ts index cd16b2a12c..e955480239 100644 --- a/packages/core/src/utils/clone-scene-graph.test.ts +++ b/packages/core/src/utils/clone-scene-graph.test.ts @@ -437,3 +437,35 @@ describe('corner window partner remap', () => { expect(copyOf('window_b')).toMatchObject({ corner: { partnerId: idMap.get('window_a') } }) }) }) + +test('whole-scene and level clones remap railing supports and stair destinations', () => { + const level = makeNode('level_surface', 'level', { + children: ['slab_surface', 'fence_surface', 'stair_surface'], + }) + const slab = makeNode('slab_surface', 'slab', { parentId: level.id }) + const fence = makeNode('fence_surface', 'fence', { + parentId: level.id, + supportSurfaceNodeId: slab.id, + }) + const stair = makeNode('stair_surface', 'stair', { + parentId: level.id, + destinationSurfaceId: slab.id, + }) + const nodes = Object.fromEntries([level, slab, fence, stair].map((node) => [node.id, node])) + for (const copy of [ + cloneSceneGraph({ nodes, rootNodeIds: [level.id] }).nodes, + cloneLevelSubtree(nodes, level.id).clonedNodes, + ]) { + const entries = Object.values(copy) + const copiedSlab = entries.find((node) => node.type === 'slab')! + const copiedFence = entries.find((node) => node.type === 'fence')! + const copiedStair = entries.find((node) => node.type === 'stair')! + expect((copiedFence as unknown as { supportSurfaceNodeId: string }).supportSurfaceNodeId).toBe( + copiedSlab.id, + ) + expect((copiedStair as unknown as { destinationSurfaceId: string }).destinationSurfaceId).toBe( + copiedSlab.id, + ) + expect(copiedSlab.id).not.toBe(slab.id) + } +}) diff --git a/packages/editor/package.json b/packages/editor/package.json index 2ac98672f8..4b116e47e0 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -7,7 +7,8 @@ "exports": { ".": "./src/index.tsx", "./catalog": "./src/components/ui/item-catalog/catalog-items.tsx", - "./procedural-items": "./src/components/procedural-items/inspector.tsx" + "./procedural-items": "./src/components/procedural-items/inspector.tsx", + "./distribution": "./src/components/tools/distribution/distribution-public.ts" }, "scripts": { "bench:room-labels": "bun run src/components/editor/__bench__/room-label-placement.bench.ts", diff --git a/packages/nodes/src/shared/connection-compatibility.test.ts b/packages/editor/src/components/tools/distribution/connection-compatibility.test.ts similarity index 100% rename from packages/nodes/src/shared/connection-compatibility.test.ts rename to packages/editor/src/components/tools/distribution/connection-compatibility.test.ts diff --git a/packages/nodes/src/shared/connection-compatibility.ts b/packages/editor/src/components/tools/distribution/connection-compatibility.ts similarity index 100% rename from packages/nodes/src/shared/connection-compatibility.ts rename to packages/editor/src/components/tools/distribution/connection-compatibility.ts diff --git a/packages/editor/src/components/tools/distribution/distribution-public.ts b/packages/editor/src/components/tools/distribution/distribution-public.ts new file mode 100644 index 0000000000..8603a9f5db --- /dev/null +++ b/packages/editor/src/components/tools/distribution/distribution-public.ts @@ -0,0 +1,9 @@ +export * from './connection-compatibility' +export * from './distribution-run-contract' +export * from './distribution-run-tool' +export * from './draw-alignment' +export * from './ports' +export * from './run-cursor' +export * from './run-direction-feedback' +export * from './run-port-snap' +export * from './run-screen-direction' diff --git a/packages/nodes/src/shared/distribution-run-contract.test.ts b/packages/editor/src/components/tools/distribution/distribution-run-contract.test.ts similarity index 80% rename from packages/nodes/src/shared/distribution-run-contract.test.ts rename to packages/editor/src/components/tools/distribution/distribution-run-contract.test.ts index cf4c63cc5e..8e3d42fc38 100644 --- a/packages/nodes/src/shared/distribution-run-contract.test.ts +++ b/packages/editor/src/components/tools/distribution/distribution-run-contract.test.ts @@ -1,9 +1,12 @@ import { describe, expect, test } from 'bun:test' import { offsetRunPointFromSurface, type RunSurfaceTarget } from './distribution-run-contract' -const wall = (hostId = 'wall-1', side: 'front' | 'back' = 'front'): RunSurfaceTarget => ({ +const wall = ( + hostId: `wall_${string}` = 'wall_1', + side: 'front' | 'back' = 'front', +): RunSurfaceTarget => ({ kind: 'wall', - levelId: 'level-1', + levelId: 'level_1', hostId, side, frame: { diff --git a/packages/nodes/src/shared/distribution-run-contract.ts b/packages/editor/src/components/tools/distribution/distribution-run-contract.ts similarity index 100% rename from packages/nodes/src/shared/distribution-run-contract.ts rename to packages/editor/src/components/tools/distribution/distribution-run-contract.ts diff --git a/packages/nodes/src/shared/distribution-run-tool.test.ts b/packages/editor/src/components/tools/distribution/distribution-run-tool.test.ts similarity index 100% rename from packages/nodes/src/shared/distribution-run-tool.test.ts rename to packages/editor/src/components/tools/distribution/distribution-run-tool.test.ts diff --git a/packages/nodes/src/shared/distribution-run-tool.test.tsx b/packages/editor/src/components/tools/distribution/distribution-run-tool.test.tsx similarity index 93% rename from packages/nodes/src/shared/distribution-run-tool.test.tsx rename to packages/editor/src/components/tools/distribution/distribution-run-tool.test.tsx index a50efb0d1a..e14c753226 100644 --- a/packages/nodes/src/shared/distribution-run-tool.test.tsx +++ b/packages/editor/src/components/tools/distribution/distribution-run-tool.test.tsx @@ -21,8 +21,11 @@ test.each(['duct-segment', 'pipe-segment'] as const)( configurable: true, writable: true, }) - if (!previousWindow) - Object.defineProperty(globalThis, 'window', { value: new EventTarget(), configurable: true }) + Object.defineProperty(globalThis, 'window', { + value: new EventTarget(), + configurable: true, + writable: true, + }) const wall = WallNode.parse({ parentId: 'level_test', start: [0, 0], end: [10, 0] }) let tool: ReturnType | undefined const commits: Array<[number, number, number]> = [] @@ -112,7 +115,8 @@ test.each(['duct-segment', 'pipe-segment'] as const)( useScene.setState(previousScene, true) useEditor.setState(previousEditor, true) useInteractionScope.setState(previousScope, true) - if (!previousWindow) Reflect.deleteProperty(globalThis, 'window') + if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow) + else Reflect.deleteProperty(globalThis, 'window') if (previousActEnvironment) Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', previousActEnvironment) else Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT') diff --git a/packages/nodes/src/shared/distribution-run-tool.tsx b/packages/editor/src/components/tools/distribution/distribution-run-tool.tsx similarity index 99% rename from packages/nodes/src/shared/distribution-run-tool.tsx rename to packages/editor/src/components/tools/distribution/distribution-run-tool.tsx index 0d161b167f..a7348efd25 100644 --- a/packages/nodes/src/shared/distribution-run-tool.tsx +++ b/packages/editor/src/components/tools/distribution/distribution-run-tool.tsx @@ -35,14 +35,9 @@ import { import { findScreenPort, type PortScreenPoint } from './run-port-snap' import { chooseScreenDirection, screenDirectionScore } from './run-screen-direction' -export type RunPoint = [number, number, number] +export type { RunPoint, RunSurfaceFrame } from './distribution-run-contract' -export type RunSurfaceFrame = { - origin: RunPoint - normal: RunPoint - tangent: RunPoint - bitangent: RunPoint -} +import type { RunPoint, RunSurfaceFrame } from './distribution-run-contract' type RunPointerEvent = GridEvent diff --git a/packages/nodes/src/shared/draw-alignment.ts b/packages/editor/src/components/tools/distribution/draw-alignment.ts similarity index 100% rename from packages/nodes/src/shared/draw-alignment.ts rename to packages/editor/src/components/tools/distribution/draw-alignment.ts diff --git a/packages/nodes/src/shared/ports.test.ts b/packages/editor/src/components/tools/distribution/ports.test.ts similarity index 83% rename from packages/nodes/src/shared/ports.test.ts rename to packages/editor/src/components/tools/distribution/ports.test.ts index 5c74f45b6e..08d5176eb2 100644 --- a/packages/nodes/src/shared/ports.test.ts +++ b/packages/editor/src/components/tools/distribution/ports.test.ts @@ -3,8 +3,20 @@ import type { RunSurfaceTarget } from './distribution-run-contract' import { findNearestPort3D, findNearestPortXZ, type ScenePort } from './ports' const ports: ScenePort[] = [ - { nodeId: 'duct-segment_a', id: 'floor', position: [0, 0, 0], direction: [1, 0, 0] }, - { nodeId: 'duct-segment_b', id: 'wall', position: [0.4, 2, 0], direction: [1, 0, 0] }, + { + nodeId: 'duct-segment_a', + id: 'floor', + position: [0, 0, 0], + diameter: 0.1, + direction: [1, 0, 0], + }, + { + nodeId: 'duct-segment_b', + id: 'wall', + position: [0.4, 2, 0], + diameter: 0.1, + direction: [1, 0, 0], + }, ] describe('distribution port distance metrics', () => { @@ -35,6 +47,7 @@ describe('distribution port distance metrics', () => { nodeId: 'duct-segment_c', id: 'off-plane', position: [0, 1.8, 0.25], + diameter: 0.1, direction: [1, 0, 0], } expect(findNearestPort3D([0, 1.8, 0], [offPlane], 0.1, wall)).toBeNull() diff --git a/packages/nodes/src/shared/ports.ts b/packages/editor/src/components/tools/distribution/ports.ts similarity index 100% rename from packages/nodes/src/shared/ports.ts rename to packages/editor/src/components/tools/distribution/ports.ts diff --git a/packages/nodes/src/shared/run-cursor.test.ts b/packages/editor/src/components/tools/distribution/run-cursor.test.ts similarity index 100% rename from packages/nodes/src/shared/run-cursor.test.ts rename to packages/editor/src/components/tools/distribution/run-cursor.test.ts diff --git a/packages/nodes/src/shared/run-cursor.ts b/packages/editor/src/components/tools/distribution/run-cursor.ts similarity index 100% rename from packages/nodes/src/shared/run-cursor.ts rename to packages/editor/src/components/tools/distribution/run-cursor.ts diff --git a/packages/nodes/src/shared/run-direction-feedback.test.ts b/packages/editor/src/components/tools/distribution/run-direction-feedback.test.ts similarity index 100% rename from packages/nodes/src/shared/run-direction-feedback.test.ts rename to packages/editor/src/components/tools/distribution/run-direction-feedback.test.ts diff --git a/packages/nodes/src/shared/run-direction-feedback.tsx b/packages/editor/src/components/tools/distribution/run-direction-feedback.tsx similarity index 100% rename from packages/nodes/src/shared/run-direction-feedback.tsx rename to packages/editor/src/components/tools/distribution/run-direction-feedback.tsx diff --git a/packages/nodes/src/shared/run-port-snap.test.ts b/packages/editor/src/components/tools/distribution/run-port-snap.test.ts similarity index 98% rename from packages/nodes/src/shared/run-port-snap.test.ts rename to packages/editor/src/components/tools/distribution/run-port-snap.test.ts index f2f7cac083..885e6054f9 100644 --- a/packages/nodes/src/shared/run-port-snap.test.ts +++ b/packages/editor/src/components/tools/distribution/run-port-snap.test.ts @@ -7,12 +7,14 @@ const low: ScenePort = { nodeId: 'pipe-segment_low', id: 'end', position: [0, 0, 0], + diameter: 0.1, direction: [1, 0, 0], } const high: ScenePort = { nodeId: 'duct-segment_high', id: 'start', position: [0, 3, 0], + diameter: 0.1, direction: [1, 0, 0], } const camera = new PerspectiveCamera(50, 1, 0.1, 100) diff --git a/packages/nodes/src/shared/run-port-snap.ts b/packages/editor/src/components/tools/distribution/run-port-snap.ts similarity index 100% rename from packages/nodes/src/shared/run-port-snap.ts rename to packages/editor/src/components/tools/distribution/run-port-snap.ts diff --git a/packages/nodes/src/shared/run-screen-direction.test.ts b/packages/editor/src/components/tools/distribution/run-screen-direction.test.ts similarity index 100% rename from packages/nodes/src/shared/run-screen-direction.test.ts rename to packages/editor/src/components/tools/distribution/run-screen-direction.test.ts diff --git a/packages/nodes/src/shared/run-screen-direction.ts b/packages/editor/src/components/tools/distribution/run-screen-direction.ts similarity index 100% rename from packages/nodes/src/shared/run-screen-direction.ts rename to packages/editor/src/components/tools/distribution/run-screen-direction.ts diff --git a/packages/editor/src/components/ui/panels/panel-wrapper.tsx b/packages/editor/src/components/ui/panels/panel-wrapper.tsx index d313b005ea..316ebb2c91 100644 --- a/packages/editor/src/components/ui/panels/panel-wrapper.tsx +++ b/packages/editor/src/components/ui/panels/panel-wrapper.tsx @@ -138,8 +138,7 @@ export function PanelWrapper({ ) as AnyNodeId | undefined // Subscribe to the selected node's *type* only — a string primitive that // doesn't change as fields are edited (same trick as ParametricInspector). - const selectedNode = useScene((s) => (selectedId ? s.nodes[selectedId] : undefined)) - const selectedType = selectedNode?.type ?? null + const selectedType = useScene((s) => (selectedId ? s.nodes[selectedId]?.type ?? null : null)) const installedPlugins = useScene((s) => s.installedPlugins) const extensions = useMemo(() => { // re-derive when plugin extensions register after mount (async plugin load) @@ -156,9 +155,11 @@ export function PanelWrapper({ // See `lib/inspector-card-mode.ts` for the transition table. const [activeExtensionId, setActiveExtensionId] = useState(null) // Stale ids (kind changed, plugin gated off) fall back to regular mode. - const primaryExtension = selectedNode - ? extensions.find((extension) => extension.primaryWhen?.(selectedNode)) - : undefined + const primaryExtensionId = useScene((state) => { + const node = selectedId ? state.nodes[selectedId] : undefined + return node ? extensions.find((extension) => extension.primaryWhen?.(node))?.id : undefined + }) + const primaryExtension = extensions.find((extension) => extension.id === primaryExtensionId) const activeExtension = primaryExtension ?? resolveActiveExtension(activeExtensionId, extensions) // The panel is collapsed to just its header until the user expands it. The diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index d70d927dee..f3c74be7e7 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -7,6 +7,21 @@ export { acquireSceneReadOnlyLease, applyScenePatch, applySceneSnapshot, + DEFAULT_CURVED_STAIR_INNER_RADIUS, + DEFAULT_CURVED_STAIR_SWEEP_ANGLE, + DEFAULT_SPIRAL_SHOW_CENTER_COLUMN, + DEFAULT_SPIRAL_SHOW_STEP_SUPPORTS, + DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE, + DEFAULT_SPIRAL_TOP_LANDING_DEPTH, + DEFAULT_SPIRAL_TOP_LANDING_MODE, + DEFAULT_STAIR_ATTACHMENT_SIDE, + DEFAULT_STAIR_FILL_TO_FLOOR, + DEFAULT_STAIR_OPENING_OFFSET, + DEFAULT_STAIR_RAILING_HEIGHT, + DEFAULT_STAIR_RAILING_MODE, + DEFAULT_STAIR_THICKNESS, + DEFAULT_STAIR_TYPE, + DEFAULT_STAIR_WIDTH, type SceneCommit, type SceneCommitListener, type SceneCommitOrigin, diff --git a/packages/editor/src/lib/glb-export.test.ts b/packages/editor/src/lib/glb-export.test.ts index d31b6f4b26..c8387003f8 100644 --- a/packages/editor/src/lib/glb-export.test.ts +++ b/packages/editor/src/lib/glb-export.test.ts @@ -2438,3 +2438,26 @@ test('prepared exports preserve surface identities and IFC subpart roles on regi expect(exported.userData.pascalIfcRole).toBe('railing') expect(exported.userData.pascalIfcParts).toEqual([{ start: 0, count: 3, role: 'handrail' }]) }) + +test('exports a plugin LOD once without invoking its argument-requiring constructor', () => { + class PluginLOD extends THREE.LOD { + constructor(model: THREE.Mesh) { + super() + if (!model) throw new Error('Model is required') + this.addLevel(model, 0) + this.addLevel(model.clone(), 10) + } + } + const mesh = new THREE.Mesh(new THREE.BoxGeometry(), new THREE.MeshBasicMaterial()) + const lod = new PluginLOD(mesh) + const root = new THREE.Group() + root.add(lod) + const { scene } = prepareSceneForExport(root, {}) + const meshes: THREE.Mesh[] = [] + scene.traverse((object) => { + if (object instanceof THREE.Mesh) meshes.push(object) + }) + expect(meshes).toHaveLength(2) + expect(meshes[0]!.geometry).not.toBe(mesh.geometry) + expect(lod.levels).toHaveLength(2) +}) diff --git a/packages/editor/src/lib/wall-placement-pointer.test.ts b/packages/editor/src/lib/wall-placement-pointer.test.ts new file mode 100644 index 0000000000..e8c1bc1083 --- /dev/null +++ b/packages/editor/src/lib/wall-placement-pointer.test.ts @@ -0,0 +1,42 @@ +import { expect, test } from 'bun:test' +import { Group, Vector3 } from 'three' +import { + createWallPointerTracker, + wallPointerInParent, + wallSideAtPointer, +} from './wall-placement-pointer' + +test('keeps the grab offset across a pointer gap on the original face', () => { + const pointer = createWallPointerTracker({ wallId: 'wall_a', side: 'front', position: [4, 2] }) + expect(pointer.resolve('wall_a', 'front', [3, 1])).toEqual([4, 2]) + pointer.leave() + expect(pointer.resolve('wall_a', 'front', [5, 3])).toEqual([6, 4]) +}) +test('a different wall or face permanently releases the old grab offset', () => { + for (const [wall, side] of [ + ['wall_b', 'front'], + ['wall_a', 'back'], + ] as const) { + const pointer = createWallPointerTracker({ wallId: 'wall_a', side: 'front', position: [4, 2] }) + pointer.resolve('wall_a', 'front', [3, 1]) + expect(pointer.resolve(wall, side, [5, 3])).toEqual([5, 3]) + expect(pointer.resolve('wall_a', 'front', [6, 4])).toEqual([6, 4]) + } +}) +test('a committed floor placement releases the original wall grab', () => { + const pointer = createWallPointerTracker({ wallId: 'wall_a', position: [4, 2] }) + pointer.resolve('wall_a', 'front', [3, 1]) + pointer.forget() + expect(pointer.resolve('wall_a', 'front', [5, 3])).toEqual([5, 3]) +}) +test('reads the pointer in the rotated wall parent and identifies its face', () => { + const parent = new Group() + parent.position.set(3, 1, 5) + parent.rotation.y = Math.PI / 2 + parent.updateMatrixWorld(true) + const world = parent.localToWorld(new Vector3(2, 3, -0.1)) + const local = wallPointerInParent(world.toArray(), parent) + expect(local[0]).toBeCloseTo(2) + expect(local[1]).toBeCloseTo(3) + expect(wallSideAtPointer(local)).toBe('back') +}) diff --git a/packages/nodes/package.json b/packages/nodes/package.json index 95bded61f3..c0d42dab9d 100644 --- a/packages/nodes/package.json +++ b/packages/nodes/package.json @@ -1,7 +1,7 @@ { "name": "@pascal-app/nodes", "version": "1.0.4", - "description": "Built-in node bundles for the Pascal 3D editor — one folder per kind, exported as `builtinPlugin`", + "description": "Built-in node bundles for the Pascal 3D editor \u2014 one folder per kind, exported as `builtinPlugin`", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -16,11 +16,6 @@ "import": "./dist/procedural-item/index.js", "default": "./dist/procedural-item/index.js" }, - "./distribution": { - "types": "./dist/shared/distribution-public.d.ts", - "import": "./dist/shared/distribution-public.js", - "default": "./dist/shared/distribution-public.js" - }, "./agent-tools": { "types": "./dist/agent-tools/index.d.ts", "import": "./dist/agent-tools/index.js", diff --git a/packages/nodes/src/duct-fitting/tool.tsx b/packages/nodes/src/duct-fitting/tool.tsx index 29cba4bcbd..70605dbf4b 100644 --- a/packages/nodes/src/duct-fitting/tool.tsx +++ b/packages/nodes/src/duct-fitting/tool.tsx @@ -11,6 +11,13 @@ import { useEditor, useInteractionScope, } from '@pascal-app/editor' +import { + alignDrawPoint, + clearDrawAlignment, + collectScenePorts, + DUCT_PORT_SYSTEMS, + type ScenePort, +} from '@pascal-app/editor/distribution' import { useViewer } from '@pascal-app/viewer' import { Html } from '@react-three/drei' import { useEffect, useMemo, useRef, useState } from 'react' @@ -27,7 +34,6 @@ import { subscribeAccessorySnapping, } from '../shared/accessory-snapping' import { ConnectionFeedback } from '../shared/connection-feedback' -import { alignDrawPoint, clearDrawAlignment } from '../shared/draw-alignment' import { AXIS_VECTORS, cycleRotationAxis, @@ -36,7 +42,6 @@ import { } from '../shared/fitting-rotation' import { createFittingSurfaceSupport } from '../shared/fitting-surface-support' import { LevelOffsetGroup } from '../shared/level-offset-group' -import { collectScenePorts, DUCT_PORT_SYSTEMS, type ScenePort } from '../shared/ports' import { ductFittingDefinition } from './definition' import { buildDuctFittingGeometry } from './geometry' import { localFittingPorts } from './ports' diff --git a/packages/nodes/src/duct-segment/continuation.ts b/packages/nodes/src/duct-segment/continuation.ts index 79c26a31a7..8c79df38be 100644 --- a/packages/nodes/src/duct-segment/continuation.ts +++ b/packages/nodes/src/duct-segment/continuation.ts @@ -6,6 +6,7 @@ import { useScene, } from '@pascal-app/core' import { useEditor } from '@pascal-app/editor' +import type { RunBodyHit, ScenePort } from '@pascal-app/editor/distribution' import { useViewer } from '@pascal-app/viewer' import { findMatedScenePorts, @@ -14,7 +15,6 @@ import { type RunContinuationHandlePlan, resolveDuctContinuationHandle, } from '../shared/elbow-branch-continuation' -import type { RunBodyHit, ScenePort } from '../shared/ports' import { ductPortDiameterIn } from './geometry' import type { DuctSegmentNode } from './schema' diff --git a/packages/nodes/src/duct-segment/move-tool.tsx b/packages/nodes/src/duct-segment/move-tool.tsx index 4ae8529be6..26821c2812 100644 --- a/packages/nodes/src/duct-segment/move-tool.tsx +++ b/packages/nodes/src/duct-segment/move-tool.tsx @@ -25,6 +25,7 @@ import { useAlignmentGuides, useEditor, } from '@pascal-app/editor' +import { collectScenePorts, DUCT_PORT_SYSTEMS } from '@pascal-app/editor/distribution' import { useViewer } from '@pascal-app/viewer' import { useEffect, useRef, useState } from 'react' import { Matrix4, Vector3 } from 'three' @@ -34,7 +35,6 @@ import { resolveGhostAlignment, } from '../shared/ghost-alignment' import { DuctSegmentGhost, FittingGhost } from '../shared/mep-ghost' -import { collectScenePorts, DUCT_PORT_SYSTEMS } from '../shared/ports' import { type RunMoveConnectivity, startRunMoveConnectivity } from '../shared/run-move-connectivity' import { planRunTranslationOffsets, diff --git a/packages/nodes/src/duct-segment/selection.tsx b/packages/nodes/src/duct-segment/selection.tsx index 8ea6ea0ac8..406b071c4c 100644 --- a/packages/nodes/src/duct-segment/selection.tsx +++ b/packages/nodes/src/duct-segment/selection.tsx @@ -25,6 +25,11 @@ import { triggerSFX, useEditor, } from '@pascal-app/editor' +import { + collectScenePorts, + DUCT_PORT_SYSTEMS, + findNearestPortXZ, +} from '@pascal-app/editor/distribution' import { useViewer } from '@pascal-app/viewer' import { Html } from '@react-three/drei' import { createPortal, type ThreeEvent, useFrame, useThree } from '@react-three/fiber' @@ -55,7 +60,6 @@ import { planFittingEndpointReaim, } from '../shared/fitting-endpoint-reaim' import { DuctSegmentGhost, FittingGhost } from '../shared/mep-ghost' -import { collectScenePorts, DUCT_PORT_SYSTEMS, findNearestPortXZ } from '../shared/ports' import { planRunTranslationOffsets } from '../shared/run-translation-offset' import { ContinuePlusHandle, HandleCube, MoveChevron, RotateArc } from '../shared/selection-handles' import { planVerticalOffsets, type VerticalOffsetResult } from '../shared/vertical-offset' diff --git a/packages/nodes/src/duct-segment/tool.tsx b/packages/nodes/src/duct-segment/tool.tsx index 5ccf1bc180..fc58741024 100644 --- a/packages/nodes/src/duct-segment/tool.tsx +++ b/packages/nodes/src/duct-segment/tool.tsx @@ -8,6 +8,21 @@ import { usePathDraftPreview, useRegistryToolContext, } from '@pascal-app/editor' +import { + collectScenePorts, + createRunWallAttachment, + DistributionRunCursor, + DUCT_PORT_SYSTEMS, + runDistanceSquared as dist2, + findNearestRunBody3D, + findRunBodyCrossingSurface, + type RunBodyHit, + type RunSurfaceTarget, + runSectionHalfSizeM, + type ScenePort, + stepNominalRunSize, + useDistributionRunTool, +} from '@pascal-app/editor/distribution' import { useEffect, useMemo, useRef, useState } from 'react' import { Euler, type Group, Vector3 } from 'three' import { getDuctFittingPorts } from '../duct-fitting/ports' @@ -23,26 +38,10 @@ import { isRunEndCapPort, } from '../shared/automatic-run-end-cap' import { ConnectionFeedback } from '../shared/connection-feedback' -import { createRunWallAttachment, type RunSurfaceTarget } from '../shared/distribution-run-contract' -import { - DistributionRunCursor, - runDistanceSquared as dist2, - runSectionHalfSizeM, - stepNominalRunSize, - useDistributionRunTool, -} from '../shared/distribution-run-tool' import { ductProfilesMatch, planDuctAdapter } from '../shared/duct-adapter' import { FITTING_CLEARANCE_MESSAGE, hasFittingClearance } from '../shared/fitting-clearance' import { LevelOffsetGroup } from '../shared/level-offset-group' import { DuctSegmentGhost, FittingGhost } from '../shared/mep-ghost' -import { - collectScenePorts, - DUCT_PORT_SYSTEMS, - findNearestRunBody3D, - findRunBodyCrossingSurface, - type RunBodyHit, - type ScenePort, -} from '../shared/ports' import { RunHangerPreview } from '../shared/run-hanger-controls' import { useRunHangerMode } from '../shared/run-hanger-mode' import { currentDuctContinuationSeed, ductEndpointPort } from './continuation' diff --git a/packages/nodes/src/duct-terminal/tool.tsx b/packages/nodes/src/duct-terminal/tool.tsx index 0ae6cf1ad5..66f26240db 100644 --- a/packages/nodes/src/duct-terminal/tool.tsx +++ b/packages/nodes/src/duct-terminal/tool.tsx @@ -25,6 +25,13 @@ import { triggerSFX, useEditor, } from '@pascal-app/editor' +import { + alignDrawPoint, + clearDrawAlignment, + collectScenePorts, + DUCT_PORT_SYSTEMS, + findNearestPort3D, +} from '@pascal-app/editor/distribution' import { useViewer } from '@pascal-app/viewer' import { Html } from '@react-three/drei' import { useThree } from '@react-three/fiber' @@ -32,9 +39,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { Euler, Matrix3, Matrix4, Plane, Quaternion, Raycaster, Vector2, Vector3 } from 'three' import { subscribeAccessorySnapping } from '../shared/accessory-snapping' import { ConnectionFeedback } from '../shared/connection-feedback' -import { alignDrawPoint, clearDrawAlignment } from '../shared/draw-alignment' import { LevelOffsetGroup } from '../shared/level-offset-group' -import { collectScenePorts, DUCT_PORT_SYSTEMS, findNearestPort3D } from '../shared/ports' import { ductTerminalDefinition } from './definition' import { buildDuctTerminalGeometry } from './geometry' import { COLLAR_LENGTH, getDuctTerminalPorts, mountQuaternion } from './ports' diff --git a/packages/nodes/src/fence/drafting.test.ts b/packages/nodes/src/fence/drafting.test.ts index 1d5629a3d5..ba95543fe5 100644 --- a/packages/nodes/src/fence/drafting.test.ts +++ b/packages/nodes/src/fence/drafting.test.ts @@ -1,18 +1,22 @@ import { beforeEach, describe, expect, jest, test } from 'bun:test' import { type AnyNode, + type AnyNodeDefinition, type AnyNodeId, createSceneApi, type FenceNode, GROUND_SUPPORT_ID, type GridEvent, + nodeRegistry, + registerNode, type SlabNode, spatialGridManager, useScene, } from '@pascal-app/core' import { useEditor } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' -import { createFenceOnCurrentLevel } from './drafting' +import { z } from 'zod' +import { createFenceOnCurrentLevel, getFenceDrawingSurface, snapFenceDraftPoint } from './drafting' const LEVEL_ID = 'level_test' as AnyNodeId @@ -354,3 +358,48 @@ test('focus completion: ready spline fence ignores fields and commits outside En }) } }, 15_000) + +test('railing snaps use inset edges and corners on the active floor at the deck top', () => { + const kind = 'test:railing-platform' + if (!nodeRegistry.has(kind)) + registerNode({ + kind, + schemaVersion: 1, + schema: z.object({ type: z.literal(kind) }), + category: 'site', + defaults: () => ({}), + capabilities: { + surfaces: { + top: { + height: 0.4, + boundary: (_node, inset = 0) => [ + [inset, inset], + [4 - inset, inset], + [4 - inset, 3 - inset], + [inset, 3 - inset], + ], + }, + }, + }, + } as AnyNodeDefinition) + const deck = { + id: 'platform_rail', + type: kind, + parentId: LEVEL_ID, + position: [0, 0.8, 0], + } as unknown as AnyNode + seedLevel([deck]) + useViewer.setState({ selection: { ...useViewer.getState().selection, levelId: LEVEL_ID } }) + useEditor.getState().setToolDefaults('fence', { supportSurfaceNodeId: deck.id }) + const support = getFenceDrawingSurface() + expect(support?.elevation).toBeCloseTo(1.2) + expect(support?.boundary[0]).toEqual([0.08, 0.08]) + expect(snapFenceDraftPoint({ point: [0.1, 0.1], walls: [], fences: [], magnetic: true })).toEqual( + [0.08, 0.08], + ) + expect(snapFenceDraftPoint({ point: [2, 0.1], walls: [], fences: [], magnetic: true })).toEqual([ + 2, 0.08, + ]) + expect(getFenceDrawingSurface(useScene.getState().nodes, deck.id, 'level_other')).toBeNull() + useEditor.getState().setToolDefaults('fence', null) +}) diff --git a/packages/nodes/src/hvac-equipment/tool.tsx b/packages/nodes/src/hvac-equipment/tool.tsx index 22210b7224..6a87740320 100644 --- a/packages/nodes/src/hvac-equipment/tool.tsx +++ b/packages/nodes/src/hvac-equipment/tool.tsx @@ -14,11 +14,11 @@ import { triggerSFX, useEditor, } from '@pascal-app/editor' +import { alignDrawPoint, clearDrawAlignment } from '@pascal-app/editor/distribution' import { useViewer } from '@pascal-app/viewer' import { Html } from '@react-three/drei' import { useEffect, useMemo, useRef, useState } from 'react' import { subscribeAccessorySnapping } from '../shared/accessory-snapping' -import { alignDrawPoint, clearDrawAlignment } from '../shared/draw-alignment' import { LevelOffsetGroup } from '../shared/level-offset-group' import { hvacEquipmentDefinition } from './definition' import { buildHvacEquipmentGeometry } from './geometry' diff --git a/packages/nodes/src/lineset/tool.tsx b/packages/nodes/src/lineset/tool.tsx index e140a8c834..9bc0e84de0 100644 --- a/packages/nodes/src/lineset/tool.tsx +++ b/packages/nodes/src/lineset/tool.tsx @@ -14,13 +14,18 @@ import { useEditor, usePathDraftPreview, } from '@pascal-app/editor' +import { + alignDrawPoint, + clearDrawAlignment, + collectScenePorts, + findNearestPortXZ, + REFRIGERANT_PORT_SYSTEMS, +} from '@pascal-app/editor/distribution' import { useViewer } from '@pascal-app/viewer' import { Html } from '@react-three/drei' import { useEffect, useRef, useState } from 'react' import { type Group, Vector3 } from 'three' -import { alignDrawPoint, clearDrawAlignment } from '../shared/draw-alignment' import { LevelOffsetGroup } from '../shared/level-offset-group' -import { collectScenePorts, findNearestPortXZ, REFRIGERANT_PORT_SYSTEMS } from '../shared/ports' import { linesetDefinition } from './definition' /** diff --git a/packages/nodes/src/liquid-line/tool.tsx b/packages/nodes/src/liquid-line/tool.tsx index 0fdacdecff..4552c4e7b0 100644 --- a/packages/nodes/src/liquid-line/tool.tsx +++ b/packages/nodes/src/liquid-line/tool.tsx @@ -21,14 +21,19 @@ import { useEditor, usePathDraftPreview, } from '@pascal-app/editor' +import { + alignDrawPoint, + clearDrawAlignment, + collectScenePorts, + findNearestPortXZ, + REFRIGERANT_PORT_SYSTEMS, +} from '@pascal-app/editor/distribution' import { useViewer } from '@pascal-app/viewer' import { Html } from '@react-three/drei' import { useEffect, useRef, useState } from 'react' import { type Group, Vector3 } from 'three' -import { alignDrawPoint, clearDrawAlignment } from '../shared/draw-alignment' import { LevelOffsetGroup } from '../shared/level-offset-group' import { offsetPathHorizontal } from '../shared/path-offset' -import { collectScenePorts, findNearestPortXZ, REFRIGERANT_PORT_SYSTEMS } from '../shared/ports' import { liquidLineDefinition } from './definition' import { useLiquidLineToolOptions } from './options' diff --git a/packages/nodes/src/pipe-fitting/inline-insertion.ts b/packages/nodes/src/pipe-fitting/inline-insertion.ts index 57c3ceec97..32488a3d91 100644 --- a/packages/nodes/src/pipe-fitting/inline-insertion.ts +++ b/packages/nodes/src/pipe-fitting/inline-insertion.ts @@ -1,6 +1,6 @@ import { PipeFittingNode, PipeSegmentNode } from '@pascal-app/core' +import type { RunBodyHit } from '@pascal-app/editor/distribution' import { Euler, Quaternion, Vector3 } from 'three' -import type { RunBodyHit } from '../shared/ports' import { pipeFittingLegLength } from './ports' type Point = [number, number, number] diff --git a/packages/nodes/src/pipe-fitting/tool.tsx b/packages/nodes/src/pipe-fitting/tool.tsx index fd0d31920d..afc0ff8776 100644 --- a/packages/nodes/src/pipe-fitting/tool.tsx +++ b/packages/nodes/src/pipe-fitting/tool.tsx @@ -19,6 +19,15 @@ import { useInteractionScope, useRegistryToolContext, } from '@pascal-app/editor' +import { + alignDrawPoint, + clearDrawAlignment, + collectScenePorts, + DWV_PORT_SYSTEMS, + findNearestRunBody3D, + findNearestRunBodyXZ, + type ScenePort, +} from '@pascal-app/editor/distribution' import { Html } from '@react-three/drei' import { useEffect, useMemo, useRef, useState } from 'react' import { Euler, type Material, Mesh, MeshStandardMaterial, Quaternion, Vector3 } from 'three' @@ -30,7 +39,6 @@ import { subscribeAccessorySnapping, } from '../shared/accessory-snapping' import { ConnectionFeedback } from '../shared/connection-feedback' -import { alignDrawPoint, clearDrawAlignment } from '../shared/draw-alignment' import { AXIS_VECTORS, cycleRotationAxis, @@ -39,13 +47,6 @@ import { } from '../shared/fitting-rotation' import { createFittingSurfaceSupport } from '../shared/fitting-surface-support' import { LevelOffsetGroup } from '../shared/level-offset-group' -import { - collectScenePorts, - DWV_PORT_SYSTEMS, - findNearestRunBody3D, - findNearestRunBodyXZ, - type ScenePort, -} from '../shared/ports' import { pipeFittingDefinition } from './definition' import { buildPipeFittingGeometry } from './geometry' import { diff --git a/packages/nodes/src/pipe-segment/continuation.ts b/packages/nodes/src/pipe-segment/continuation.ts index 689e487578..f7590e203f 100644 --- a/packages/nodes/src/pipe-segment/continuation.ts +++ b/packages/nodes/src/pipe-segment/continuation.ts @@ -1,5 +1,6 @@ import { type AnyNode, type AnyNodeId, type FloorplanAffordance, useScene } from '@pascal-app/core' import { useEditor } from '@pascal-app/editor' +import type { RunBodyHit, ScenePort } from '@pascal-app/editor/distribution' import { useViewer } from '@pascal-app/viewer' import type { PipeFittingNode } from '../pipe-fitting/schema' import { @@ -9,7 +10,6 @@ import { type RunContinuationHandlePlan, resolvePipeContinuationHandle, } from '../shared/elbow-branch-continuation' -import type { RunBodyHit, ScenePort } from '../shared/ports' import type { PipeSegmentNode } from './schema' export type PipeEndpoint = 'start' | 'end' diff --git a/packages/nodes/src/pipe-segment/selection.tsx b/packages/nodes/src/pipe-segment/selection.tsx index e9bbad6bfa..6e5fb0e60b 100644 --- a/packages/nodes/src/pipe-segment/selection.tsx +++ b/packages/nodes/src/pipe-segment/selection.tsx @@ -23,6 +23,11 @@ import { triggerSFX, useEditor, } from '@pascal-app/editor' +import { + collectScenePorts, + DWV_PORT_SYSTEMS, + findNearestPortXZ, +} from '@pascal-app/editor/distribution' import { useViewer } from '@pascal-app/viewer' import { Html } from '@react-three/drei' import { createPortal, type ThreeEvent, useFrame, useThree } from '@react-three/fiber' @@ -37,7 +42,6 @@ import { import { PipeFittingGhost, PipeSegmentGhost } from '../shared/mep-ghost' import { planPipeRunTranslationOffsets } from '../shared/pipe-run-translation-offset' import { planVerticalOffsets, type VerticalOffsetResult } from '../shared/pipe-vertical-offset' -import { collectScenePorts, DWV_PORT_SYSTEMS, findNearestPortXZ } from '../shared/ports' import { ContinuePlusHandle, HandleCube, MoveChevron } from '../shared/selection-handles' import { refreshWallRunAttachment } from '../shared/wall-run-move' import { diff --git a/packages/nodes/src/pipe-segment/tool.tsx b/packages/nodes/src/pipe-segment/tool.tsx index 9dfe5710fc..59dd094ba1 100644 --- a/packages/nodes/src/pipe-segment/tool.tsx +++ b/packages/nodes/src/pipe-segment/tool.tsx @@ -8,6 +8,23 @@ import { usePathDraftPreview, useRegistryToolContext, } from '@pascal-app/editor' +import { + collectScenePorts, + createRunWallAttachment, + DistributionRunCursor, + DWV_PORT_SYSTEMS, + findNearestRunBody3D, + findRunBodyCrossingSurface, + RUN_PREVIEW_OPACITY, + type RunConnection, + type RunPoint, + type RunSurfaceTarget, + runDistanceSquared, + runSectionHalfSizeM, + type ScenePort, + stepNominalRunSize, + useDistributionRunTool, +} from '@pascal-app/editor/distribution' import { useEffect, useRef, useState } from 'react' import { Vector3 } from 'three' import { @@ -21,27 +38,9 @@ import { isRunEndCapPort, } from '../shared/automatic-run-end-cap' import { ConnectionFeedback } from '../shared/connection-feedback' -import { createRunWallAttachment, type RunSurfaceTarget } from '../shared/distribution-run-contract' -import { - DistributionRunCursor, - RUN_PREVIEW_OPACITY, - type RunConnection, - type RunPoint, - runDistanceSquared, - runSectionHalfSizeM, - stepNominalRunSize, - useDistributionRunTool, -} from '../shared/distribution-run-tool' import { FITTING_CLEARANCE_MESSAGE, hasFittingClearance } from '../shared/fitting-clearance' import { LevelOffsetGroup } from '../shared/level-offset-group' import { PipeFittingGhost } from '../shared/mep-ghost' -import { - collectScenePorts, - DWV_PORT_SYSTEMS, - findNearestRunBody3D, - findRunBodyCrossingSurface, - type ScenePort, -} from '../shared/ports' import { RunHangerPreview } from '../shared/run-hanger-controls' import { useRunHangerMode } from '../shared/run-hanger-mode' import { currentPipeContinuationSeed, pipeEndpointPort } from './continuation' diff --git a/packages/nodes/src/pipe-trap/tool.tsx b/packages/nodes/src/pipe-trap/tool.tsx index a003e6b188..8e2a3e2c84 100644 --- a/packages/nodes/src/pipe-trap/tool.tsx +++ b/packages/nodes/src/pipe-trap/tool.tsx @@ -8,11 +8,11 @@ import { triggerSFX, useEditor, } from '@pascal-app/editor' +import { alignDrawPoint, clearDrawAlignment } from '@pascal-app/editor/distribution' import { useViewer } from '@pascal-app/viewer' import { Html } from '@react-three/drei' import { useEffect, useMemo, useRef, useState } from 'react' import { subscribeAccessorySnapping } from '../shared/accessory-snapping' -import { alignDrawPoint, clearDrawAlignment } from '../shared/draw-alignment' import { LevelOffsetGroup } from '../shared/level-offset-group' import { pipeTrapDefinition } from './definition' import { buildPipeTrapGeometry } from './geometry' diff --git a/packages/nodes/src/shared/accessory-placement.ts b/packages/nodes/src/shared/accessory-placement.ts index 78a2296241..464f7da39f 100644 --- a/packages/nodes/src/shared/accessory-placement.ts +++ b/packages/nodes/src/shared/accessory-placement.ts @@ -1,8 +1,8 @@ import type { AnyNode, AnyNodeId, DuctFittingNode, PipeFittingNode } from '@pascal-app/core' +import type { ScenePort } from '@pascal-app/editor/distribution' import { Euler, Matrix4, Quaternion, Vector3 } from 'three' import { adapterShape } from '../duct-fitting/ports' import { rectSectionAxes } from '../duct-segment/geometry' -import type { ScenePort } from './ports' import { reducerOutletDiameter } from './reducer-size' export function inheritFittingProfile( diff --git a/packages/nodes/src/shared/accessory-snapping.ts b/packages/nodes/src/shared/accessory-snapping.ts index 08d8920987..2926411adf 100644 --- a/packages/nodes/src/shared/accessory-snapping.ts +++ b/packages/nodes/src/shared/accessory-snapping.ts @@ -1,5 +1,9 @@ import { useEditor } from '@pascal-app/editor' -import { findNearestPort3D, findNearestPortXZ, type ScenePort } from './ports' +import { + findNearestPort3D, + findNearestPortXZ, + type ScenePort, +} from '@pascal-app/editor/distribution' export function subscribeAccessorySnapping(refresh: () => void): () => void { return useEditor.subscribe((state, previous) => { diff --git a/packages/nodes/src/shared/auto-fitting.test.ts b/packages/nodes/src/shared/auto-fitting.test.ts index e3eecef6ab..e6e92c3f70 100644 --- a/packages/nodes/src/shared/auto-fitting.test.ts +++ b/packages/nodes/src/shared/auto-fitting.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from 'bun:test' +import type { ScenePort } from '@pascal-app/editor/distribution' import { getDuctFittingPorts } from '../duct-fitting/ports' import { type DuctProfile, planElbowAtPort, planElbowRealign } from './auto-fitting' -import type { ScenePort } from './ports' type Point = [number, number, number] @@ -126,8 +126,8 @@ describe('planElbowAtPort', () => { }) import { DuctFittingNode, DuctSegmentNode } from '@pascal-app/core' +import type { RunBodyHit } from '@pascal-app/editor/distribution' import { planCrossAtRunBody, planTeeAtRunBody } from './auto-fitting' -import type { RunBodyHit } from './ports' function trunk(path: Point[]): DuctSegmentNode { return DuctSegmentNode.parse({ diff --git a/packages/nodes/src/shared/auto-fitting.ts b/packages/nodes/src/shared/auto-fitting.ts index 4ee84b87ae..4952968a5f 100644 --- a/packages/nodes/src/shared/auto-fitting.ts +++ b/packages/nodes/src/shared/auto-fitting.ts @@ -4,6 +4,7 @@ import { PipeFittingNode, PipeSegmentNode, } from '@pascal-app/core' +import type { RunBodyHit, ScenePort } from '@pascal-app/editor/distribution' import { Euler, Matrix4, Quaternion, Vector3 } from 'three' import { fittingLegLength } from '../duct-fitting/ports' import { @@ -12,7 +13,6 @@ import { ovalEquivalentDiameterIn, } from '../duct-segment/geometry' import { pipeFittingLegLength } from '../pipe-fitting/ports' -import type { RunBodyHit, ScenePort } from './ports' /** Turns shallower than this read as a straight continuation — butt-join * the runs instead of minting a fitting. Matches the elbow schema's diff --git a/packages/nodes/src/shared/automatic-run-end-cap.ts b/packages/nodes/src/shared/automatic-run-end-cap.ts index 2186ea3c05..7a34211234 100644 --- a/packages/nodes/src/shared/automatic-run-end-cap.ts +++ b/packages/nodes/src/shared/automatic-run-end-cap.ts @@ -6,12 +6,12 @@ import { PipeFittingNode, type PipeSegmentNode, } from '@pascal-app/core' +import type { ScenePort } from '@pascal-app/editor/distribution' import { Euler, Quaternion, Vector3 } from 'three' import { localFittingPorts } from '../duct-fitting/ports' import { ductPortDiameterIn } from '../duct-segment/geometry' import { localPipeFittingPorts } from '../pipe-fitting/ports' import { accessoryMateQuaternion } from './accessory-placement' -import type { ScenePort } from './ports' const END_CAP_OWNER_ID_KEY = 'automaticRunEndCapOwnerId' const END_CAP_ENDPOINT_KEY = 'automaticRunEndCapEndpoint' diff --git a/packages/nodes/src/shared/connection-feedback.tsx b/packages/nodes/src/shared/connection-feedback.tsx index 0555f4dde2..b8d3c46dd4 100644 --- a/packages/nodes/src/shared/connection-feedback.tsx +++ b/packages/nodes/src/shared/connection-feedback.tsx @@ -8,8 +8,13 @@ import { isMagneticSnapActive, useEditor, } from '@pascal-app/editor' -import { type ConnectionProfile, connectionCompatibility } from './connection-compatibility' -import { collectScenePorts, findNearestPort3D, type ScenePort } from './ports' +import { + type ConnectionProfile, + collectScenePorts, + connectionCompatibility, + findNearestPort3D, + type ScenePort, +} from '@pascal-app/editor/distribution' const COLORS = { match: '#16a34a', adapter: '#d97706', incompatible: '#dc2626', unknown: '#d97706' } diff --git a/packages/nodes/src/shared/distribution-public.ts b/packages/nodes/src/shared/distribution-public.ts deleted file mode 100644 index e7ea48f1d7..0000000000 --- a/packages/nodes/src/shared/distribution-public.ts +++ /dev/null @@ -1,19 +0,0 @@ -export { type ConnectionProfile, connectionCompatibility } from './connection-compatibility' -export { - DistributionRunCursor, - type DistributionRunToolConfig, - projectRunToAngleLock, - RUN_PREVIEW_OPACITY, - type RunCommitResult, - type RunConnection, - type RunPoint, - snapRunLength, - useDistributionRunTool, -} from './distribution-run-tool' -export { - collectScenePorts, - findNearestRunBody3D, - type RunBodyHit, - type ScenePort, -} from './ports' -export { findScreenPort, type PortScreenPoint } from './run-port-snap' diff --git a/packages/nodes/src/shared/duct-adapter.ts b/packages/nodes/src/shared/duct-adapter.ts index dff0a06321..39de6403e2 100644 --- a/packages/nodes/src/shared/duct-adapter.ts +++ b/packages/nodes/src/shared/duct-adapter.ts @@ -1,9 +1,9 @@ import { DuctFittingNode } from '@pascal-app/core' +import type { ScenePort } from '@pascal-app/editor/distribution' import { Euler, Matrix4, Vector3 } from 'three' import { fittingLegLength } from '../duct-fitting/ports' import { rectSectionAxes } from '../duct-segment/geometry' import { type DuctProfile, profileDiameterIn } from './auto-fitting' -import type { ScenePort } from './ports' export function ductProfilesMatch(a: DuctProfile, b: DuctProfile): boolean { return ( diff --git a/packages/nodes/src/shared/elbow-branch-continuation.ts b/packages/nodes/src/shared/elbow-branch-continuation.ts index c285e0c12c..ba46029872 100644 --- a/packages/nodes/src/shared/elbow-branch-continuation.ts +++ b/packages/nodes/src/shared/elbow-branch-continuation.ts @@ -5,10 +5,10 @@ import { nodeRegistry, PipeFittingNode, } from '@pascal-app/core' +import type { ScenePort } from '@pascal-app/editor/distribution' import { Euler, Matrix4, Quaternion, Vector3 } from 'three' import { getDuctFittingPorts } from '../duct-fitting/ports' import { getPipeFittingPorts, WYE_BRANCH_RAD } from '../pipe-fitting/ports' -import type { ScenePort } from './ports' type Point = [number, number, number] diff --git a/packages/nodes/src/shared/move-roof-tool.tsx b/packages/nodes/src/shared/move-roof-tool.tsx index 327a96d695..08c1212b3f 100644 --- a/packages/nodes/src/shared/move-roof-tool.tsx +++ b/packages/nodes/src/shared/move-roof-tool.tsx @@ -28,7 +28,6 @@ import { consumePlacementDragRelease, DragBoundingBox, getFloorStackPreviewPosition, - getMovingNode, isMagneticSnapActive, isTypingTarget, resolvePlanarCursorPosition, @@ -44,7 +43,7 @@ import { useViewer } from '@pascal-app/viewer' import { useCallback, useEffect, useRef, useState } from 'react' import * as THREE from 'three' import { snapFenceDraftPoint } from '../fence/drafting' -import { clearStairMovePreview, publishStairMovePreview } from '../stair/landscape-move-preview' +import { clearStairMovePreview, publishStairMovePreview } from '../stair/surface-move-preview' /** Figma-style alignment-snap threshold (meters), matching the other tools. */ const ALIGNMENT_THRESHOLD_M = 0.08 @@ -162,9 +161,8 @@ export const MoveRoofTool: React.FC<{ // resetting the mesh position (it resets on dirty) and from triggering // expensive merged-mesh CSG rebuilds on every frame. let wasCommitted = false - let wasCancelled = false let hasMoved = false - let landscapeSnap: StairSurfaceSnap | null = null + let surfaceSnap: StairSurfaceSnap | null = null const flightId = movingNode.type === 'stair' ? movingNode.children @@ -174,7 +172,7 @@ export const MoveRoofTool: React.FC<{ child?.type === 'stair-segment' && child.segmentType === 'stair', )?.id : undefined - const clearLandscapePreview = () => { + const clearSurfacePreview = () => { if (movingNode.type !== 'stair') return clearStairMovePreview(movingNode.id, flightId) } @@ -391,7 +389,7 @@ export const MoveRoofTool: React.FC<{ previousGridPosRef.current = [localX, localZ] lastLocalPosition = [localX, movingNode.position[1], localZ] - const wasLandscapeSnapped = landscapeSnap !== null + const wasSurfaceSnapped = surfaceSnap !== null if ( movingNode.type === 'stair' && event.nativeEvent?.altKey !== true && @@ -405,7 +403,7 @@ export const MoveRoofTool: React.FC<{ child?.type === 'stair-segment' && child.segmentType === 'stair', ) const baseElevation = getPreviewPosition(lastLocalPosition)[1] - landscapeSnap = resolveStairSurfaceSnap( + surfaceSnap = resolveStairSurfaceSnap( { ...movingNode, rotation: freeRotation }, sceneNodes, lastLocalPosition, @@ -413,14 +411,14 @@ export const MoveRoofTool: React.FC<{ baseElevation, useScene.getState().installedPlugins, ) - if (landscapeSnap) { - if (!wasLandscapeSnapped) freeRotation = pendingRotation - lastLocalPosition = landscapeSnap.position - pendingRotation = landscapeSnap.rotation - setPreviewRotation(landscapeSnap.rotation) + if (surfaceSnap) { + if (!wasSurfaceSnapped) freeRotation = pendingRotation + lastLocalPosition = surfaceSnap.position + pendingRotation = surfaceSnap.rotation + setPreviewRotation(surfaceSnap.rotation) } - } else landscapeSnap = null - if (wasLandscapeSnapped && !landscapeSnap) { + } else surfaceSnap = null + if (wasSurfaceSnapped && !surfaceSnap) { pendingRotation = freeRotation setPreviewRotation(resolvePreviewRotationY(movingNode, freeRotation)) } @@ -430,7 +428,7 @@ export const MoveRoofTool: React.FC<{ flightId, lastLocalPosition, pendingRotation, - landscapeSnap, + surfaceSnap, ) } const previewPosition = getPreviewPosition(lastLocalPosition) @@ -478,7 +476,7 @@ export const MoveRoofTool: React.FC<{ position, rotation: pendingRotation, } as typeof movingNode - const supportPatch = landscapeSnap + const supportPatch = surfaceSnap ? {} : isFloorPlaced ? resolveSupportSlabPatch(effectiveNode, { @@ -489,11 +487,11 @@ export const MoveRoofTool: React.FC<{ let committedId = movingNode.id as AnyNodeId if (isNew) { - if (landscapeSnap && flightId) + if (surfaceSnap && flightId) useScene.getState().updateNode(flightId, { - height: landscapeSnap.totalRise, - length: landscapeSnap.length, - stepCount: landscapeSnap.stepCount, + height: surfaceSnap.totalRise, + length: surfaceSnap.length, + stepCount: surfaceSnap.stepCount, }) committedId = commitFreshPlacementSubtree(movingNode.id as AnyNodeId, { @@ -502,16 +500,16 @@ export const MoveRoofTool: React.FC<{ metadata: committedMeta, visible: true, ...supportPatch, - ...(landscapeSnap + ...(surfaceSnap ? { - totalRise: landscapeSnap.totalRise, - stepCount: landscapeSnap.stepCount, + totalRise: surfaceSnap.totalRise, + stepCount: surfaceSnap.stepCount, slabOpeningMode: 'none' as const, - landscapeSurfaceId: landscapeSnap.surfaceId, + destinationSurfaceId: surfaceSnap.surfaceId, railingMode: 'none' as const, } - : movingNode.type === 'stair' && movingNode.landscapeSurfaceId - ? { landscapeSurfaceId: undefined } + : movingNode.type === 'stair' && movingNode.destinationSurfaceId + ? { destinationSurfaceId: undefined } : {}), }) ?? committedId } else { @@ -535,16 +533,16 @@ export const MoveRoofTool: React.FC<{ { id: movingNode.id, data: { - ...(landscapeSnap + ...(surfaceSnap ? { - totalRise: landscapeSnap.totalRise, - stepCount: landscapeSnap.stepCount, + totalRise: surfaceSnap.totalRise, + stepCount: surfaceSnap.stepCount, slabOpeningMode: 'none' as const, - landscapeSurfaceId: landscapeSnap.surfaceId, + destinationSurfaceId: surfaceSnap.surfaceId, railingMode: 'none' as const, } - : movingNode.type === 'stair' && movingNode.landscapeSurfaceId - ? { landscapeSurfaceId: undefined } + : movingNode.type === 'stair' && movingNode.destinationSurfaceId + ? { destinationSurfaceId: undefined } : {}), position, rotation: pendingRotation, @@ -552,14 +550,14 @@ export const MoveRoofTool: React.FC<{ ...supportPatch, }, }, - ...(landscapeSnap && flightId + ...(surfaceSnap && flightId ? [ { id: flightId, data: { - height: landscapeSnap.totalRise, - length: landscapeSnap.length, - stepCount: landscapeSnap.stepCount, + height: surfaceSnap.totalRise, + length: surfaceSnap.length, + stepCount: surfaceSnap.stepCount, }, }, ] @@ -572,7 +570,7 @@ export const MoveRoofTool: React.FC<{ triggerSFX('sfx:item-place') selectPlacedNode(committedId) clearHostedPreview() - clearLandscapePreview() + clearSurfacePreview() useLiveTransforms.getState().clear(movingNode.id) useEditor.getState().setMovingNodeOrigin('3d') exitMoveMode() @@ -585,9 +583,8 @@ export const MoveRoofTool: React.FC<{ } const onCancel = () => { - wasCancelled = true clearHostedPreview() - clearLandscapePreview() + clearSurfacePreview() useLiveTransforms.getState().clear(movingNode.id) useAlignmentGuides.getState().clear() if (isNew) { @@ -624,7 +621,7 @@ export const MoveRoofTool: React.FC<{ flightId, lastLocalPosition, pendingRotation, - landscapeSnap, + surfaceSnap, ) // Directly update the Three.js mesh — no store update during drag @@ -664,31 +661,11 @@ export const MoveRoofTool: React.FC<{ // Clear ephemeral live transform + any alignment guides clearHostedPreview() - clearLandscapePreview() + clearSurfacePreview() useLiveTransforms.getState().clear(movingNode.id) useAlignmentGuides.getState().clear() - // Skip restore when the 2D floor-plan overlay claimed teardown - // ownership — same contract `FloorplanRegistryMoveOverlay` uses to - // decide whether to revert its own apply() writes. Without this, - // a stair / roof move committed in the floor plan unmounts this - // tool with `wasCommitted === false` (this tool's own grid-click - // never fired), and the restore below stomps the just-committed - // position back to the snapshot. - const finalisedBy2D = useEditor.getState().movingNodeOrigin === '2d' - - // Reparenting an attached stair at move start replaces its scene node. - // That restarts this effect while the same move remains active; restoring - // the old surface-local pose here would start a scene-update loop. - const sameStairMoveStillActive = - movingNode.type === 'stair' && getMovingNode()?.id === movingNode.id - if (!(wasCommitted || wasCancelled || isNew || finalisedBy2D || sameStairMoveStillActive)) { - useScene.getState().updateNode(movingNode.id, { - position: original.position, - rotation: original.rotation, - metadata: original.metadata, - }) - } + // Drag motion only changes previews. Cleanup never rewrites a committed pose. useScene.temporal.getState().resume() emitter.off('grid:move', onGridMove) emitter.off('grid:click', onGridClick) diff --git a/packages/nodes/src/shared/pipe-auto-fitting.test.ts b/packages/nodes/src/shared/pipe-auto-fitting.test.ts index 17cab0f174..b6682deaa1 100644 --- a/packages/nodes/src/shared/pipe-auto-fitting.test.ts +++ b/packages/nodes/src/shared/pipe-auto-fitting.test.ts @@ -1,8 +1,8 @@ import { describe, expect, test } from 'bun:test' import { PipeSegmentNode } from '@pascal-app/core' +import type { RunBodyHit, ScenePort } from '@pascal-app/editor/distribution' import { getPipeFittingPorts } from '../pipe-fitting/ports' import { planPipeBranchTap, planPipeElbowAtPort } from './auto-fitting' -import type { RunBodyHit, ScenePort } from './ports' type Point = [number, number, number] diff --git a/packages/nodes/src/shared/pipe-run-translation-offset.test.ts b/packages/nodes/src/shared/pipe-run-translation-offset.test.ts index 02d9d7ea38..3f99f4a410 100644 --- a/packages/nodes/src/shared/pipe-run-translation-offset.test.ts +++ b/packages/nodes/src/shared/pipe-run-translation-offset.test.ts @@ -5,10 +5,10 @@ import { PipeSegmentNode, type PortConnection, } from '@pascal-app/core' +import type { ScenePort } from '@pascal-app/editor/distribution' import { getPipeFittingPorts } from '../pipe-fitting/ports' import { planPipeElbowAtPort } from './auto-fitting' import { planPipeRunTranslationOffsets } from './pipe-run-translation-offset' -import type { ScenePort } from './ports' type Point = [number, number, number] diff --git a/packages/nodes/src/shared/pipe-run-translation-offset.ts b/packages/nodes/src/shared/pipe-run-translation-offset.ts index d93c454a48..61bd009e87 100644 --- a/packages/nodes/src/shared/pipe-run-translation-offset.ts +++ b/packages/nodes/src/shared/pipe-run-translation-offset.ts @@ -4,10 +4,10 @@ import { PipeSegmentNode, type PortConnection, } from '@pascal-app/core' +import type { ScenePort } from '@pascal-app/editor/distribution' import { pipeFittingLegLength } from '../pipe-fitting/ports' import type { PipeFittingNode } from '../pipe-fitting/schema' import { planPipeElbowAtPort, planPipeElbowRealign } from './auto-fitting' -import type { ScenePort } from './ports' type Point = [number, number, number] type PipeProfile = { diff --git a/packages/nodes/src/shared/pipe-vertical-offset.test.ts b/packages/nodes/src/shared/pipe-vertical-offset.test.ts index b210d07615..4abdc6b6db 100644 --- a/packages/nodes/src/shared/pipe-vertical-offset.test.ts +++ b/packages/nodes/src/shared/pipe-vertical-offset.test.ts @@ -5,10 +5,10 @@ import { PipeSegmentNode, type PortConnection, } from '@pascal-app/core' +import type { ScenePort } from '@pascal-app/editor/distribution' import { getPipeFittingPorts } from '../pipe-fitting/ports' import { planPipeElbowAtPort } from './auto-fitting' import { planVerticalOffsets } from './pipe-vertical-offset' -import type { ScenePort } from './ports' type Point = [number, number, number] diff --git a/packages/nodes/src/shared/pipe-vertical-offset.ts b/packages/nodes/src/shared/pipe-vertical-offset.ts index b198359c8c..43b05063cf 100644 --- a/packages/nodes/src/shared/pipe-vertical-offset.ts +++ b/packages/nodes/src/shared/pipe-vertical-offset.ts @@ -4,10 +4,10 @@ import { PipeSegmentNode, type PortConnection, } from '@pascal-app/core' +import type { ScenePort } from '@pascal-app/editor/distribution' import { getPipeFittingPorts, pipeFittingLegLength } from '../pipe-fitting/ports' import type { PipeFittingNode } from '../pipe-fitting/schema' import { type PipeElbowPlan, planPipeElbowAtPort, planPipeElbowRealign } from './auto-fitting' -import type { ScenePort } from './ports' /** * Center-cube vertical-move auto-routing for pipe runs. diff --git a/packages/nodes/src/shared/refrigerant-line-selection.tsx b/packages/nodes/src/shared/refrigerant-line-selection.tsx index b4fc971b36..d15207e555 100644 --- a/packages/nodes/src/shared/refrigerant-line-selection.tsx +++ b/packages/nodes/src/shared/refrigerant-line-selection.tsx @@ -15,12 +15,16 @@ import { useScene, } from '@pascal-app/core' import { DimensionPill, swallowNextClick, triggerSFX, useEditor } from '@pascal-app/editor' +import { + collectScenePorts, + findNearestPortXZ, + REFRIGERANT_PORT_SYSTEMS, +} from '@pascal-app/editor/distribution' import { useViewer } from '@pascal-app/viewer' import { Html } from '@react-three/drei' import { createPortal, type ThreeEvent, useFrame, useThree } from '@react-three/fiber' import { useEffect, useMemo, useRef, useState } from 'react' import { type Group, type Object3D, Plane, Raycaster, Vector2, Vector3 } from 'three' -import { collectScenePorts, findNearestPortXZ, REFRIGERANT_PORT_SYSTEMS } from './ports' import { HandleCube, MoveChevron } from './selection-handles' type RefrigerantLineKind = 'lineset' | 'liquid-line' diff --git a/packages/nodes/src/shared/run-crossing.test.ts b/packages/nodes/src/shared/run-crossing.test.ts index 20637df3a6..0c4826cc32 100644 --- a/packages/nodes/src/shared/run-crossing.test.ts +++ b/packages/nodes/src/shared/run-crossing.test.ts @@ -1,7 +1,7 @@ import { expect, test } from 'bun:test' import { DuctSegmentNode, useScene } from '@pascal-app/core' -import type { RunSurfaceTarget } from './distribution-run-contract' -import { findRunBodyCrossingSurface } from './ports' +import type { RunSurfaceTarget } from '@pascal-app/editor/distribution' +import { findRunBodyCrossingSurface } from '@pascal-app/editor/distribution' test('ceiling crossings ignore floor runs and connect only at matching 3D height', () => { const previous = useScene.getState().nodes diff --git a/packages/nodes/src/shared/run-translation-offset.test.ts b/packages/nodes/src/shared/run-translation-offset.test.ts index 64a03b67c7..3a1992b071 100644 --- a/packages/nodes/src/shared/run-translation-offset.test.ts +++ b/packages/nodes/src/shared/run-translation-offset.test.ts @@ -5,9 +5,9 @@ import { DuctSegmentNode, type PortConnection, } from '@pascal-app/core' +import type { ScenePort } from '@pascal-app/editor/distribution' import { getDuctFittingPorts } from '../duct-fitting/ports' import { type DuctProfile, planElbowAtPort, profileDiameterIn } from './auto-fitting' -import type { ScenePort } from './ports' import { planRunTranslationOffsets } from './run-translation-offset' type Point = [number, number, number] diff --git a/packages/nodes/src/shared/run-translation-offset.ts b/packages/nodes/src/shared/run-translation-offset.ts index d327da0bf8..0b73f30a58 100644 --- a/packages/nodes/src/shared/run-translation-offset.ts +++ b/packages/nodes/src/shared/run-translation-offset.ts @@ -4,6 +4,7 @@ import { DuctSegmentNode, type PortConnection, } from '@pascal-app/core' +import type { ScenePort } from '@pascal-app/editor/distribution' import { fittingLegLength } from '../duct-fitting/ports' import type { DuctFittingNode } from '../duct-fitting/schema' import { @@ -12,7 +13,6 @@ import { planElbowRealign, profileDiameterIn, } from './auto-fitting' -import type { ScenePort } from './ports' type Point = [number, number, number] diff --git a/packages/nodes/src/shared/system-checks.ts b/packages/nodes/src/shared/system-checks.ts index 6fbd2a5a5f..335fcc98db 100644 --- a/packages/nodes/src/shared/system-checks.ts +++ b/packages/nodes/src/shared/system-checks.ts @@ -11,8 +11,8 @@ import { nodeRegistry, validateDwv, } from '@pascal-app/core' +import { connectionCompatibility } from '@pascal-app/editor/distribution' import { Box3, Ray, Vector3 } from 'three' -import { connectionCompatibility } from './connection-compatibility' import { planRunHangerSlots } from './run-hangers' export type SystemFinding = { diff --git a/packages/nodes/src/shared/vertical-offset.test.ts b/packages/nodes/src/shared/vertical-offset.test.ts index 2096e6ec2d..ae0a6b8e5f 100644 --- a/packages/nodes/src/shared/vertical-offset.test.ts +++ b/packages/nodes/src/shared/vertical-offset.test.ts @@ -5,9 +5,9 @@ import { DuctSegmentNode, type PortConnection, } from '@pascal-app/core' +import type { ScenePort } from '@pascal-app/editor/distribution' import { getDuctFittingPorts } from '../duct-fitting/ports' import { type DuctProfile, planElbowAtPort, profileDiameterIn } from './auto-fitting' -import type { ScenePort } from './ports' import { planVerticalOffsets } from './vertical-offset' type Point = [number, number, number] diff --git a/packages/nodes/src/shared/vertical-offset.ts b/packages/nodes/src/shared/vertical-offset.ts index 2ea4aa3d6d..1084f53948 100644 --- a/packages/nodes/src/shared/vertical-offset.ts +++ b/packages/nodes/src/shared/vertical-offset.ts @@ -4,6 +4,7 @@ import { DuctSegmentNode, type PortConnection, } from '@pascal-app/core' +import type { ScenePort } from '@pascal-app/editor/distribution' import { Vector3 } from 'three' import { fittingLegLength, getDuctFittingPorts } from '../duct-fitting/ports' import type { DuctFittingNode } from '../duct-fitting/schema' @@ -15,7 +16,6 @@ import { planElbowRealign, profileDiameterIn, } from './auto-fitting' -import type { ScenePort } from './ports' /** * Center-cube vertical-move auto-routing for duct runs. diff --git a/packages/nodes/src/shared/wall-run-move.ts b/packages/nodes/src/shared/wall-run-move.ts index 32554aa24a..7f0ddb4233 100644 --- a/packages/nodes/src/shared/wall-run-move.ts +++ b/packages/nodes/src/shared/wall-run-move.ts @@ -1,5 +1,5 @@ import type { AnyNode, GridEvent } from '@pascal-app/core' -import type { RunWallAttachment } from './distribution-run-contract' +import type { RunWallAttachment } from '@pascal-app/editor/distribution' type Point = [number, number, number] diff --git a/packages/nodes/src/stair/definition.ts b/packages/nodes/src/stair/definition.ts index 7e820fc8bd..a0248381d3 100644 --- a/packages/nodes/src/stair/definition.ts +++ b/packages/nodes/src/stair/definition.ts @@ -439,7 +439,7 @@ export const stairDefinition: NodeDefinition = { capabilities: { reveal: { phase: 'circulation', style: 'drop', height: 2.5 }, - hostRefFields: ['landscapeSurfaceId'], + hostRefFields: ['destinationSurfaceId'], surfacePlacement: 'floor-only', selectable: { hitVolume: 'bbox' }, // A stair has no centred box footprint: straight = a cumulative diff --git a/packages/nodes/src/stair/floorplan-move.test.ts b/packages/nodes/src/stair/floorplan-move.test.ts index 5d4931b083..86a8c6a89f 100644 --- a/packages/nodes/src/stair/floorplan-move.test.ts +++ b/packages/nodes/src/stair/floorplan-move.test.ts @@ -118,12 +118,12 @@ test('2D landscape attachment previews without scene writes and commits the flig const previewStair = getEffectiveNode(stair) const previewFlight = getEffectiveNode(flight) expect(useScene.getState().nodes).toBe(before) - expect(previewStair.landscapeSurfaceId).toBe(deck.id) + expect(previewStair.destinationSurfaceId).toBe(deck.id) expect(previewFlight.height).toBeCloseTo(previewStair.totalRise!) expect(previewFlight.stepCount).toBe(previewStair.stepCount) expect(session.canCommit()).toBe(true) session.commit!() - expect(useScene.getState().nodes[stair.id]).toMatchObject({ landscapeSurfaceId: deck.id }) + expect(useScene.getState().nodes[stair.id]).toMatchObject({ destinationSurfaceId: deck.id }) expect(useScene.getState().nodes[flight.id]).toMatchObject({ height: previewFlight.height, length: previewFlight.length, @@ -136,12 +136,12 @@ test('2D landscape attachment previews without scene writes and commits the flig test('snap mode releases the landscape preview and commits the free position', () => { const { stair, flight, session } = setup() session.apply({ planPoint: [0, -2], modifiers }) - expect(getEffectiveNode(stair).landscapeSurfaceId).toBeDefined() + expect(getEffectiveNode(stair).destinationSurfaceId).toBeDefined() session.apply({ planPoint: [0.13, -2.17], modifiers: { ...modifiers, altKey: true } }) - expect(getEffectiveNode(stair).landscapeSurfaceId).toBeDefined() + expect(getEffectiveNode(stair).destinationSurfaceId).toBeDefined() useEditor.getState().setSnappingMode('polygon', 'off') session.apply({ planPoint: [0.13, -2.17], modifiers }) - expect(getEffectiveNode(stair).landscapeSurfaceId).toBeUndefined() + expect(getEffectiveNode(stair).destinationSurfaceId).toBeUndefined() expect(getEffectiveNode(flight)).toEqual(flight) session.commit!() expect(useScene.getState().nodes[stair.id]).toMatchObject({ position: [0.13, 0, -2.17] }) @@ -155,7 +155,7 @@ test('fresh 2D stair replacement keeps the snapped flight preview', () => { const id = commitFreshPlacementSubtree(stair.id, getEffectiveNode(stair))! const committed = useScene.getState().nodes[id] as StairNode const committedFlight = useScene.getState().nodes[committed.children[0]!] as StairSegmentNode - expect(committed.landscapeSurfaceId).toBe(deck.id) + expect(committed.destinationSurfaceId).toBe(deck.id) expect(committedFlight.height).toBe(expected.height) expect(committedFlight.length).toBe(expected.length) expect(committedFlight.stepCount).toBe(expected.stepCount) diff --git a/packages/nodes/src/stair/floorplan-move.ts b/packages/nodes/src/stair/floorplan-move.ts index 2ab68f0768..2548712321 100644 --- a/packages/nodes/src/stair/floorplan-move.ts +++ b/packages/nodes/src/stair/floorplan-move.ts @@ -54,7 +54,7 @@ export const stairFloorplanMoveTarget: FloorplanMoveTarget = ({ (child): child is StairSegmentNode => child?.type === 'stair-segment' && child.segmentType === 'stair', ) - let landscapeSnap: StairSurfaceSnap | null = null + let surfaceSnap: StairSurfaceSnap | null = null const session: FloorplanMoveTargetSession = { affectedIds: flight ? [node.id as AnyNodeId, flight.id as AnyNodeId] : [node.id as AnyNodeId], @@ -84,7 +84,7 @@ export const stairFloorplanMoveTarget: FloorplanMoveTarget = ({ rotation: node.rotation, levelId: node.parentId, })[1] - landscapeSnap = !isMagneticSnapActive() + surfaceSnap = !isMagneticSnapActive() ? null : resolveStairSurfaceSnap( node, @@ -94,33 +94,33 @@ export const stairFloorplanMoveTarget: FloorplanMoveTarget = ({ baseElevation, sceneApi?.installedPlugins?.(), ) - const position = landscapeSnap?.position ?? ([sx, startY, sz] as [number, number, number]) + const position = surfaceSnap?.position ?? ([sx, startY, sz] as [number, number, number]) if ( lastValid && lastValid.position[0] === position[0] && lastValid.position[2] === position[2] && - lastValid.rotation === landscapeSnap?.rotation + lastValid.rotation === surfaceSnap?.rotation ) return - lastValid = { position, ...(landscapeSnap ? { rotation: landscapeSnap.rotation } : {}) } - if (!landscapeSnap) useLiveNodeOverrides.getState().clear(node.id as AnyNodeId) + lastValid = { position, ...(surfaceSnap ? { rotation: surfaceSnap.rotation } : {}) } + if (!surfaceSnap) useLiveNodeOverrides.getState().clear(node.id as AnyNodeId) useLiveNodeOverrides.getState().set(node.id as AnyNodeId, { ...lastValid, - ...(landscapeSnap + ...(surfaceSnap ? { - totalRise: landscapeSnap.totalRise, - stepCount: landscapeSnap.stepCount, - landscapeSurfaceId: landscapeSnap.surfaceId, + totalRise: surfaceSnap.totalRise, + stepCount: surfaceSnap.stepCount, + destinationSurfaceId: surfaceSnap.surfaceId, railingMode: 'none' as const, } - : { landscapeSurfaceId: undefined }), + : { destinationSurfaceId: undefined }), }) if (flight) { - if (landscapeSnap) + if (surfaceSnap) useLiveNodeOverrides.getState().set(flight.id as AnyNodeId, { - height: landscapeSnap.totalRise, - length: landscapeSnap.length, - stepCount: landscapeSnap.stepCount, + height: surfaceSnap.totalRise, + length: surfaceSnap.length, + stepCount: surfaceSnap.stepCount, }) else useLiveNodeOverrides.getState().clear(flight.id as AnyNodeId) } @@ -146,27 +146,27 @@ export const stairFloorplanMoveTarget: FloorplanMoveTarget = ({ id: node.id as AnyNodeId, data: { ...lastValid, - ...(landscapeSnap + ...(surfaceSnap ? { - totalRise: landscapeSnap.totalRise, - stepCount: landscapeSnap.stepCount, + totalRise: surfaceSnap.totalRise, + stepCount: surfaceSnap.stepCount, slabOpeningMode: 'none' as const, - landscapeSurfaceId: landscapeSnap.surfaceId, + destinationSurfaceId: surfaceSnap.surfaceId, railingMode: 'none' as const, } - : node.landscapeSurfaceId - ? { landscapeSurfaceId: undefined } + : node.destinationSurfaceId + ? { destinationSurfaceId: undefined } : {}), }, }, - ...(landscapeSnap && flight + ...(surfaceSnap && flight ? [ { id: flight.id as AnyNodeId, data: { - height: landscapeSnap.totalRise, - length: landscapeSnap.length, - stepCount: landscapeSnap.stepCount, + height: surfaceSnap.totalRise, + length: surfaceSnap.length, + stepCount: surfaceSnap.stepCount, }, }, ] diff --git a/packages/nodes/src/stair/floorplan.test.ts b/packages/nodes/src/stair/floorplan.test.ts index 96112c79e3..f29cfc8262 100644 --- a/packages/nodes/src/stair/floorplan.test.ts +++ b/packages/nodes/src/stair/floorplan.test.ts @@ -136,7 +136,7 @@ describe('buildStairFloorplan documentation', () => { const local = StairNode.parse({ ...world, parentId: surface.id, - landscapeSurfaceId: surface.id, + destinationSurfaceId: surface.id, position: [1, 0, 1], rotation: 0, }) diff --git a/packages/nodes/src/stair/floorplan.ts b/packages/nodes/src/stair/floorplan.ts index ed3eb2365a..01b8ca49c9 100644 --- a/packages/nodes/src/stair/floorplan.ts +++ b/packages/nodes/src/stair/floorplan.ts @@ -50,7 +50,7 @@ export function buildStairFloorplan( rotation?: [number, number, number] } | null let stair = rawStair - if (parent && parent.id === rawStair.landscapeSurfaceId && parent.position && parent.rotation) { + if (parent && parent.id === rawStair.destinationSurfaceId && parent.position && parent.rotation) { const angle = parent.rotation[1] const c = Math.cos(angle), s = Math.sin(angle) diff --git a/packages/nodes/src/stair/landscape-transition.test.ts b/packages/nodes/src/stair/landscape-transition.test.ts deleted file mode 100644 index f5bbcb2dfc..0000000000 --- a/packages/nodes/src/stair/landscape-transition.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { expect, test } from 'bun:test' -import { StairNode } from '@pascal-app/core' -import { landscapeTransitionProfile } from './landscape-transition' - -const stair = StairNode.parse({ - position: [0, 0, -3.5], - rotation: 0, - width: 1, - totalRise: 0.35, - landscapeSurfaceId: 'patio_1', -}) - -test('bridges the gap from a straight stair to a circular patio', () => { - const profile = landscapeTransitionProfile( - stair, - { - position: [0, 0, 0], - width: 4, - depth: 4, - shape: 'circle', - }, - 1.5, - ) - expect(profile).not.toBeNull() - expect(profile!.frontZ).toBe(0) - expect(profile!.samples[0]![1]).toBeGreaterThan(0) - expect(Math.abs(profile!.samples.find(([x]) => Math.abs(x) < 1e-6)![1])).toBeLessThan(0.01) - expect(profile!.samples.at(-1)![1]).toBeCloseTo(profile!.samples[0]![1]) - expect(profile!.samples.length).toBeGreaterThan(3) -}) - -test('follows a custom polygon and skips a disconnected edge', () => { - const custom = { - position: [0, 0, 0] as [number, number, number], - width: 4, - depth: 4, - shape: 'custom', - outline: [ - [-0.5, -0.5], - [0, -0.45], - [0.5, -0.5], - [0.5, 0.5], - [-0.5, 0.5], - ] as [number, number][], - } - expect( - landscapeTransitionProfile(stair, custom, 1.5)?.samples.find(([x]) => x === 0)?.[1], - ).toBeCloseTo(0.2) - expect(landscapeTransitionProfile(stair, custom, 0.5)).toBeNull() -}) - -test('keeps the same boundary after the stair becomes a rotated surface child', () => { - const surface = { - position: [4, 0, 3] as [number, number, number], - rotation: [0, Math.PI / 2, 0] as [number, number, number], - width: 4, - depth: 4, - shape: 'circle', - } - const levelStair = StairNode.parse({ ...stair, position: [5, 0, 2], rotation: Math.PI / 2 }) - const childStair = StairNode.parse({ - ...levelStair, - parentId: 'patio_1', - position: [1, 0, 1], - rotation: 0, - }) - expect(landscapeTransitionProfile(childStair, surface, 1.5)?.samples).toEqual( - landscapeTransitionProfile(levelStair, surface, 1.5)?.samples, - ) -}) diff --git a/packages/nodes/src/stair/panel.tsx b/packages/nodes/src/stair/panel.tsx index 835aa69aff..32e42de676 100644 --- a/packages/nodes/src/stair/panel.tsx +++ b/packages/nodes/src/stair/panel.tsx @@ -377,13 +377,13 @@ export default function StairPanel() { .reduce((sum, segment) => sum + segment.height, 0) : 0) + 0.001, ) - const landscapeLevelId = - node.landscapeSurfaceId && node.parentId === node.landscapeSurfaceId + const surfaceLevelId = + node.destinationSurfaceId && node.parentId === node.destinationSurfaceId ? nodes[node.parentId as AnyNodeId]?.parentId : node.parentId - const landscapeSurfaces = getBoundarySurfaces(nodes, landscapeLevelId, installedPlugins) - const connectedSurface = node.landscapeSurfaceId - ? nodes[node.landscapeSurfaceId as AnyNodeId] + const boundarySurfaces = getBoundarySurfaces(nodes, surfaceLevelId, installedPlugins) + const connectedSurface = node.destinationSurfaceId + ? nodes[node.destinationSurfaceId as AnyNodeId] : undefined return ( @@ -589,18 +589,18 @@ export default function StairPanel() { /> {node.stairType === 'straight' && - (landscapeSurfaces.length > 0 || node.landscapeSurfaceId) && ( - + (boundarySurfaces.length > 0 || node.destinationSurfaceId) && ( +
handleUpdate({ autoLandscapeSnap: checked })} + onChange={(checked) => handleUpdate({ autoSurfaceSnap: checked })} />

- {node.autoLandscapeSnap !== false + {node.autoSurfaceSnap !== false ? connectedSurface - ? `Connected to ${connectedSurface.name || 'landscape surface'}. Move the stair to connect to another edge.` + ? `Connected to ${connectedSurface.name || 'surface'}. Move the stair to connect to another edge.` : 'Drag near a deck, patio, concrete slab, or landing edge to fit the stair.' : 'Moving this stair keeps its current rise, steps, and run.'}

diff --git a/packages/nodes/src/stair/renderer.tsx b/packages/nodes/src/stair/renderer.tsx index fa435fd7a1..5ac82ec561 100644 --- a/packages/nodes/src/stair/renderer.tsx +++ b/packages/nodes/src/stair/renderer.tsx @@ -42,13 +42,13 @@ import { type GuardBox, type GuardRail, } from './guard-path' -import { landscapeTransitionProfile } from './landscape-transition' import { resolveStairBodySlotMaterials, resolveStairSegmentMaterials, resolveStairSlotMaterial, } from './materials' import { buildPostAndRailGuard, postAndRailGuardRails } from './post-and-rail-guard' +import { surfaceTransitionProfile } from './surface-transition' import { type StairRenderData, useStairRenderData } from './use-stair-render-data' type StairRailPathSide = 'left' | 'right' | 'front' @@ -222,8 +222,8 @@ export const StairRenderer = ({ node: rawNode }: { node: StairNode }) => { ) : null} )} - {isSegmentBasedStair && node.landscapeSurfaceId && !detail.error ? ( - + {isSegmentBasedStair && node.destinationSurfaceId && !detail.error ? ( + ) : null} {isSegmentBasedStair && !detail.error ? ( @@ -237,16 +237,16 @@ export const StairRenderer = ({ node: rawNode }: { node: StairNode }) => { ) } -function StairLandscapeTransition({ +function StairSurfaceTransition({ stair, materials, }: { stair: StairNode materials: StairBodyMaterials }) { - const rawSurface = useScene((state) => state.nodes[stair.landscapeSurfaceId as AnyNodeId]) + const rawSurface = useScene((state) => state.nodes[stair.destinationSurfaceId as AnyNodeId]) const surfaceOverride = useLiveNodeOverrides((state) => - state.overrides.get(stair.landscapeSurfaceId as AnyNodeId), + state.overrides.get(stair.destinationSurfaceId as AnyNodeId), ) const surface = useMemo( () => (rawSurface && surfaceOverride ? { ...rawSurface, ...surfaceOverride } : rawSurface), @@ -276,9 +276,9 @@ function StairLandscapeTransition({ const profile = useMemo( () => surface - ? landscapeTransitionProfile( + ? surfaceTransitionProfile( stair, - surface as Parameters[1], + surface as Parameters[1], length, ) : null, @@ -393,7 +393,7 @@ function StairLandscapeTransition({ castShadow geometry={geometry} material={materials} - name="landscape-stair-transition" + name="stair-surface-transition" receiveShadow userData={STAIR_BODY_SLOT_USER_DATA} /> @@ -474,7 +474,7 @@ function StairRailings({ // reads as one coherent guard across straight, chained, winder, curved and // spiral layouts. - if (stair.landscapeSurfaceId || (stair.railingMode ?? 'none') === 'none') { + if (stair.destinationSurfaceId || (stair.railingMode ?? 'none') === 'none') { return null } diff --git a/packages/nodes/src/stair/stair-type.ts b/packages/nodes/src/stair/stair-type.ts index fe69eb1b46..2a451c9568 100644 --- a/packages/nodes/src/stair/stair-type.ts +++ b/packages/nodes/src/stair/stair-type.ts @@ -2,11 +2,11 @@ import { type AnyNode, type AnyNodeId, createStairFlightFromStair, + DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE, type StairNode, type StairSegmentNode, type StairType, } from '@pascal-app/core' -import { DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE } from './stair-defaults' export type StairTypeChange = { updates: Partial diff --git a/packages/nodes/src/stair/landscape-move-preview.test.ts b/packages/nodes/src/stair/surface-move-preview.test.ts similarity index 91% rename from packages/nodes/src/stair/landscape-move-preview.test.ts rename to packages/nodes/src/stair/surface-move-preview.test.ts index 0b3e18b089..4d80cf9429 100644 --- a/packages/nodes/src/stair/landscape-move-preview.test.ts +++ b/packages/nodes/src/stair/surface-move-preview.test.ts @@ -5,7 +5,7 @@ import { StairSegmentNode, useLiveNodeOverrides, } from '@pascal-app/core' -import { clearStairMovePreview, publishStairMovePreview } from './landscape-move-preview' +import { clearStairMovePreview, publishStairMovePreview } from './surface-move-preview' const flight = StairSegmentNode.parse({ id: 'sseg_preview', @@ -41,7 +41,7 @@ test('the visible stair model receives live pose and flight geometry during a sn expect(visibleStair.rotation).toBeCloseTo(Math.PI / 2) expect(visibleStair.totalRise).toBe(1.2) expect(visibleStair.railingMode).toBe('none') - expect(visibleStair.landscapeSurfaceId).toBe('deck_1') + expect(visibleStair.destinationSurfaceId).toBe('deck_1') expect(visibleFlight.height).toBe(1.2) expect(visibleFlight.length).toBe(2.24) expect(visibleFlight.stepCount).toBe(8) @@ -49,7 +49,7 @@ test('the visible stair model receives live pose and flight geometry during a sn publishStairMovePreview(stair.id, flight.id, [4, 0, -3], 0, null) expect(getEffectiveNode(stair).position).toEqual([4, 0, -3]) expect(getEffectiveNode(stair).railingMode).toBe('both') - expect(getEffectiveNode(stair).landscapeSurfaceId).toBeUndefined() + expect(getEffectiveNode(stair).destinationSurfaceId).toBeUndefined() expect(getEffectiveNode(flight).height).toBe(0.35) clearStairMovePreview(stair.id, flight.id) expect(getEffectiveNode(stair).position).toEqual(stair.position) diff --git a/packages/nodes/src/stair/landscape-move-preview.ts b/packages/nodes/src/stair/surface-move-preview.ts similarity index 93% rename from packages/nodes/src/stair/landscape-move-preview.ts rename to packages/nodes/src/stair/surface-move-preview.ts index f531f8a646..3dca1e8221 100644 --- a/packages/nodes/src/stair/landscape-move-preview.ts +++ b/packages/nodes/src/stair/surface-move-preview.ts @@ -22,7 +22,7 @@ export function publishStairMovePreview( rotation, totalRise: snap.totalRise, stepCount: snap.stepCount, - landscapeSurfaceId: snap.surfaceId, + destinationSurfaceId: snap.surfaceId, railingMode: 'none', }, ], @@ -45,7 +45,7 @@ export function publishStairMovePreview( (flightId !== undefined && overrides.get(flightId) !== undefined) overrides.clearFields(stairId, ['totalRise', 'stepCount', 'railingMode']) if (flightId && overrides.get(flightId)) overrides.clear(flightId) - overrides.set(stairId, { position, rotation, landscapeSurfaceId: undefined }) + overrides.set(stairId, { position, rotation, destinationSurfaceId: undefined }) if (hadSnap) { useScene.getState().markDirty(stairId) if (flightId) useScene.getState().markDirty(flightId) diff --git a/packages/nodes/src/stair/surface-transition.test.ts b/packages/nodes/src/stair/surface-transition.test.ts new file mode 100644 index 0000000000..e57a3aa3b4 --- /dev/null +++ b/packages/nodes/src/stair/surface-transition.test.ts @@ -0,0 +1,103 @@ +import { expect, test } from 'bun:test' +import { + type AnyNode, + type AnyNodeDefinition, + nodeRegistry, + registerNode, + StairNode, +} from '@pascal-app/core' +import { z } from 'zod' +import { surfaceTransitionProfile } from './surface-transition' + +function surfaceFixture( + pose: { position: [number, number, number]; rotation?: [number, number, number] }, + points: [number, number][], +): AnyNode { + const type = 'test:curved-platform' + if (!nodeRegistry.has(type)) + registerNode({ + kind: type, + schemaVersion: 1, + schema: z.object({ type: z.literal(type) }), + defaults: () => ({}), + category: 'site', + capabilities: { + surfaces: { + top: { + height: 0.35, + boundary: (node: AnyNode) => { + const surface = node as AnyNode & { + edge: [number, number][] + position: [number, number, number] + rotation?: [number, number, number] + } + const c = Math.cos(surface.rotation?.[1] ?? 0), + s = Math.sin(surface.rotation?.[1] ?? 0) + return surface.edge.map( + ([x, z]) => + [surface.position[0] + x * c + z * s, surface.position[2] - x * s + z * c] as [ + number, + number, + ], + ) + }, + }, + }, + }, + } as AnyNodeDefinition) + return { id: 'patio_1', type, ...pose, edge: points } as unknown as AnyNode +} +const circle = Array.from({ length: 128 }, (_, i): [number, number] => [ + 2 * Math.cos((i * Math.PI) / 64), + 2 * Math.sin((i * Math.PI) / 64), +]) + +const stair = StairNode.parse({ + position: [0, 0, -3.5], + rotation: 0, + width: 1, + totalRise: 0.35, + destinationSurfaceId: 'patio_1', +}) + +test('bridges the gap from a straight stair to a circular patio', () => { + const profile = surfaceTransitionProfile( + stair, + surfaceFixture({ position: [0, 0, 0] }, circle), + 1.5, + ) + expect(profile).not.toBeNull() + expect(profile!.frontZ).toBe(0) + expect(profile!.samples[0]![1]).toBeGreaterThan(0) + expect(Math.abs(profile!.samples.find(([x]) => Math.abs(x) < 1e-6)![1])).toBeLessThan(0.01) + expect(profile!.samples.at(-1)![1]).toBeCloseTo(profile!.samples[0]![1]) + expect(profile!.samples.length).toBeGreaterThan(3) +}) + +test('follows a custom polygon and skips a disconnected edge', () => { + const custom = surfaceFixture({ position: [0, 0, 0] }, [ + [-2, -2], + [0, -1.8], + [2, -2], + [2, 2], + [-2, 2], + ]) + expect( + surfaceTransitionProfile(stair, custom, 1.5)?.samples.find(([x]) => x === 0)?.[1], + ).toBeCloseTo(0.2) + expect(surfaceTransitionProfile(stair, custom, 0.5)).toBeNull() +}) + +test('keeps the same boundary after the stair becomes a rotated surface child', () => { + const surface = surfaceFixture({ position: [4, 0, 3], rotation: [0, Math.PI / 2, 0] }, circle) + const levelStair = StairNode.parse({ ...stair, position: [5, 0, 2], rotation: Math.PI / 2 }) + const childStair = StairNode.parse({ + ...levelStair, + parentId: 'patio_1', + position: [1, 0, 1], + rotation: 0, + }) + expect(surfaceTransitionProfile(childStair, surface, 1.5)?.samples).toEqual( + surfaceTransitionProfile(levelStair, surface, 1.5)?.samples, + ) +}) diff --git a/packages/nodes/src/stair/landscape-transition.ts b/packages/nodes/src/stair/surface-transition.ts similarity index 50% rename from packages/nodes/src/stair/landscape-transition.ts rename to packages/nodes/src/stair/surface-transition.ts index 5620487e2b..1785dd1c52 100644 --- a/packages/nodes/src/stair/landscape-transition.ts +++ b/packages/nodes/src/stair/surface-transition.ts @@ -1,59 +1,42 @@ -import type { StairNode } from '@pascal-app/core' +import { type AnyNode, nodeRegistry, type StairNode } from '@pascal-app/core' type Point = [number, number] -type LandscapeSurface = { - position?: [number, number, number] - rotation?: [number, number, number] - width?: number - depth?: number - shape?: string - outline?: Point[] -} - export type TransitionProfile = { frontZ: number; samples: Point[] } -export function landscapeTransitionProfile( +export function surfaceTransitionProfile( stair: StairNode, - surface: LandscapeSurface, + surface: AnyNode, length: number, ): TransitionProfile | null { - if (!surface.position || !surface.width || !surface.depth || !Number.isFinite(length)) return null - const { width, depth, shape } = surface - const localOutline: Point[] = - shape === 'circle' || shape === 'oval' - ? Array.from({ length: 128 }, (_, index) => { - const angle = (index * Math.PI * 2) / 128 - return [ - (Math.cos(angle) * width) / 2, - (Math.sin(angle) * (shape === 'circle' ? width : depth)) / 2, - ] - }) - : shape !== 'rectangle' && surface.outline && surface.outline.length >= 3 - ? surface.outline.map(([x, z]): Point => [x * width, z * depth]) - : [] - if (localOutline.length < 3) return null - - const surfaceAngle = surface.rotation?.[1] ?? 0 - const attached = stair.parentId === stair.landscapeSurfaceId + if (!Number.isFinite(length)) return null + const boundary = nodeRegistry.get(surface.type)?.capabilities?.surfaces?.top?.boundary + if (!boundary) return null + const surfaceOutline = boundary(surface, 0) + if (surfaceOutline.length < 3) return null + const pose = surface as AnyNode & { + position?: [number, number, number] + rotation?: number | [number, number, number] + } + const surfacePosition = pose.position ?? [0, 0, 0] + const surfaceAngle = typeof pose.rotation === 'number' ? pose.rotation : (pose.rotation?.[1] ?? 0) + const attached = stair.parentId === stair.destinationSurfaceId const parentCos = Math.cos(surfaceAngle), parentSin = Math.sin(surfaceAngle) const stairPosition: [number, number, number] = attached ? [ - surface.position[0] + parentCos * stair.position[0] + parentSin * stair.position[2], - surface.position[1] + stair.position[1], - surface.position[2] - parentSin * stair.position[0] + parentCos * stair.position[2], + surfacePosition[0] + parentCos * stair.position[0] + parentSin * stair.position[2], + surfacePosition[1] + stair.position[1], + surfacePosition[2] - parentSin * stair.position[0] + parentCos * stair.position[2], ] : stair.position const stairRotation = stair.rotation + (attached ? surfaceAngle : 0) - const sc = Math.cos(surfaceAngle), - ss = Math.sin(surfaceAngle) const cc = Math.cos(stairRotation), cs = Math.sin(stairRotation) const highX = stairPosition[0] + cs * length const highZ = stairPosition[2] + cc * length - const outline = localOutline.map(([x, z]): Point => { - const dx = surface.position![0] + x * sc + z * ss - highX - const dz = surface.position![2] - x * ss + z * sc - highZ + const outline = surfaceOutline.map(([x, z]): Point => { + const dx = x - highX + const dz = z - highZ return [dx * cc - dz * cs, dx * cs + dz * cc] }) diff --git a/packages/nodes/src/stair/tool.tsx b/packages/nodes/src/stair/tool.tsx index f8804cd17b..3225ac9462 100644 --- a/packages/nodes/src/stair/tool.tsx +++ b/packages/nodes/src/stair/tool.tsx @@ -5,7 +5,21 @@ import { collectAlignmentAnchors, createSizedStairFlight, createSurfaceOpeningPreviewController, + DEFAULT_CURVED_STAIR_INNER_RADIUS, + DEFAULT_CURVED_STAIR_SWEEP_ANGLE, DEFAULT_LEVEL_HEIGHT, + DEFAULT_SPIRAL_SHOW_CENTER_COLUMN, + DEFAULT_SPIRAL_SHOW_STEP_SUPPORTS, + DEFAULT_SPIRAL_TOP_LANDING_DEPTH, + DEFAULT_SPIRAL_TOP_LANDING_MODE, + DEFAULT_STAIR_ATTACHMENT_SIDE, + DEFAULT_STAIR_FILL_TO_FLOOR, + DEFAULT_STAIR_OPENING_OFFSET, + DEFAULT_STAIR_RAILING_HEIGHT, + DEFAULT_STAIR_RAILING_MODE, + DEFAULT_STAIR_THICKNESS, + DEFAULT_STAIR_TYPE, + DEFAULT_STAIR_WIDTH, emitter, type GridEvent, getFloorStackedPosition, @@ -48,22 +62,6 @@ import { useThree } from '@react-three/fiber' import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' import { createStairCommitGate, swallowFollowUpBrowserClick } from './stair-click-guard' -import { - DEFAULT_CURVED_STAIR_INNER_RADIUS, - DEFAULT_CURVED_STAIR_SWEEP_ANGLE, - DEFAULT_SPIRAL_SHOW_CENTER_COLUMN, - DEFAULT_SPIRAL_SHOW_STEP_SUPPORTS, - DEFAULT_SPIRAL_TOP_LANDING_DEPTH, - DEFAULT_SPIRAL_TOP_LANDING_MODE, - DEFAULT_STAIR_ATTACHMENT_SIDE, - DEFAULT_STAIR_FILL_TO_FLOOR, - DEFAULT_STAIR_OPENING_OFFSET, - DEFAULT_STAIR_RAILING_HEIGHT, - DEFAULT_STAIR_RAILING_MODE, - DEFAULT_STAIR_THICKNESS, - DEFAULT_STAIR_TYPE, - DEFAULT_STAIR_WIDTH, -} from './stair-defaults' const GRID_OFFSET = 0.02 /** Figma-style alignment-snap threshold (meters), matching the move tools. */ @@ -202,7 +200,7 @@ function commitStairPlacement( position: [number, number, number], rotation: number, supportSurface: PointerSupportSurface | null, - landscapeSnap: StairSurfaceSnap | null, + surfaceSnap: StairSurfaceSnap | null, ): void { const { applyNodeChanges, nodes } = useScene.getState() const placementLevelId = resolveStairPlacementLevelId( @@ -218,7 +216,7 @@ function commitStairPlacement( if (!(storeyRise > 0)) return const seed = createSeedStairSegment(storeyRise) - const destinationPlan = landscapeSnap + const destinationPlan = surfaceSnap ? null : resolveStairDestinationLevel({ createMissing: true, @@ -237,12 +235,12 @@ function commitStairPlacement( segmentId: seed.id, }), parentId: placementLevelId, - ...(landscapeSnap + ...(surfaceSnap ? { - totalRise: landscapeSnap.totalRise, - stepCount: landscapeSnap.stepCount, + totalRise: surfaceSnap.totalRise, + stepCount: surfaceSnap.stepCount, slabOpeningMode: 'none' as const, - landscapeSurfaceId: landscapeSnap.surfaceId, + destinationSurfaceId: surfaceSnap.surfaceId, railingMode: 'none' as const, } : {}), @@ -268,12 +266,12 @@ function commitStairPlacement( if (error instanceof RangeError) return throw error } - if (landscapeSnap) + if (surfaceSnap) segment = { ...segment, - height: landscapeSnap.totalRise, - length: landscapeSnap.length, - stepCount: landscapeSnap.stepCount, + height: surfaceSnap.totalRise, + length: surfaceSnap.length, + stepCount: surfaceSnap.stepCount, } stair.stepCount = segment.stepCount const prospectiveNodes = { @@ -281,7 +279,7 @@ function commitStairPlacement( [stair.id]: stair, [segment.id]: { ...segment, parentId: stair.id }, } as Record - const placementPatch = landscapeSnap + const placementPatch = surfaceSnap ? { position } : supportSurface?.sourceNodeId ? resolveFrozenFloorPlacementPatch(stair, prospectiveNodes, { @@ -328,7 +326,7 @@ export default function StairTool() { const previewMeshRef = useRef(null) const rotationRef = useRef(0) const supportSurfaceRef = useRef(null) - const landscapeSnapRef = useRef(null) + const surfaceSnapRef = useRef(null) const previousGridPosRef = useRef<[number, number] | null>(null) const lastCanonicalPositionRef = useRef<[number, number, number] | null>(null) const currentLevelId = useViewer((state) => state.selection.levelId) @@ -361,13 +359,13 @@ export default function StairTool() { } lastCanonicalPositionRef.current = null supportSurfaceRef.current = null - landscapeSnapRef.current = null + surfaceSnapRef.current = null const buildPreviewScene = ( position: [number, number, number], rotation: number, supportSurface: PointerSupportSurface | null, - landscapeSnap: StairSurfaceSnap | null, + surfaceSnap: StairSurfaceSnap | null, ) => { const nodes = useScene.getState().nodes const placementLevelId = resolveStairPlacementLevelId( @@ -377,7 +375,7 @@ export default function StairTool() { ) if (!placementLevelId) return null - const destinationPlan = landscapeSnap + const destinationPlan = surfaceSnap ? null : resolveStairDestinationLevel({ createMissing: true, @@ -417,16 +415,16 @@ export default function StairTool() { if (error instanceof RangeError) return null throw error } - if (landscapeSnap) { - stair.totalRise = landscapeSnap.totalRise - stair.landscapeSurfaceId = landscapeSnap.surfaceId + if (surfaceSnap) { + stair.totalRise = surfaceSnap.totalRise + stair.destinationSurfaceId = surfaceSnap.surfaceId stair.slabOpeningMode = 'none' stair.railingMode = 'none' segment = { ...segment, - height: landscapeSnap.totalRise, - length: landscapeSnap.length, - stepCount: landscapeSnap.stepCount, + height: surfaceSnap.totalRise, + length: surfaceSnap.length, + stepCount: surfaceSnap.stepCount, } } stair.stepCount = segment.stepCount @@ -456,23 +454,23 @@ export default function StairTool() { rotation: number, supportSurface: PointerSupportSurface | null, ) => { - const landscapeSnap = landscapeSnapRef.current - rotation = landscapeSnap?.rotation ?? rotation - const key = `${position[0].toFixed(3)},${position[2].toFixed(3)},${rotation.toFixed(4)},${supportSurface?.elevation.toFixed(3) ?? 'none'},${supportSurface?.sourceNodeId ?? 'floor'},${landscapeSnapRef.current?.length.toFixed(3) ?? 'free'},${landscapeSnapRef.current?.stepCount ?? 0},${landscapeSnapRef.current?.totalRise.toFixed(3) ?? 'free'}` + const surfaceSnap = surfaceSnapRef.current + rotation = surfaceSnap?.rotation ?? rotation + const key = `${position[0].toFixed(3)},${position[2].toFixed(3)},${rotation.toFixed(4)},${supportSurface?.elevation.toFixed(3) ?? 'none'},${supportSurface?.sourceNodeId ?? 'floor'},${surfaceSnapRef.current?.length.toFixed(3) ?? 'free'},${surfaceSnapRef.current?.stepCount ?? 0},${surfaceSnapRef.current?.totalRise.toFixed(3) ?? 'free'}` if (key === lastPreviewKey) return lastPreviewKey = key - const preview = buildPreviewScene(position, rotation, supportSurface, landscapeSnap) + const preview = buildPreviewScene(position, rotation, supportSurface, surfaceSnap) useStairBuildPreview .getState() .setPreview( preview ? [position[0], position[2]] : null, rotation, preview?.rise ?? null, - landscapeSnap?.length, - landscapeSnap?.stepCount, + surfaceSnap?.length, + surfaceSnap?.stepCount, ) const frozenPatch = - preview && supportSurface?.sourceNodeId && !landscapeSnap + preview && supportSurface?.sourceNodeId && !surfaceSnap ? resolveFrozenFloorPlacementPatch(preview.stair, preview.previewNodes, { position, rotation, @@ -484,9 +482,8 @@ export default function StairTool() { const previewStair = frozenPatch ? ({ ...preview?.stair, ...frozenPatch } as AnyNode) : preview?.stair - const visualPosition = landscapeSnap - ? previewPosition - : preview && previewStair + const visualPosition = + preview && previewStair ? getFloorStackPreviewPosition({ node: previewStair, position: previewPosition, @@ -511,18 +508,18 @@ export default function StairTool() { if ( preview && previewMeshRef.current && - `${preview.rise}:${landscapeSnap?.stepCount}:${landscapeSnap?.length}` !== + `${preview.rise}:${surfaceSnap?.stepCount}:${surfaceSnap?.length}` !== String(geometryRiseRef.current) ) { const previous = previewMeshRef.current.geometry previewMeshRef.current.geometry = createStairPreviewGeometry( preview.rise, - landscapeSnap?.stepCount, - landscapeSnap?.length, + surfaceSnap?.stepCount, + surfaceSnap?.length, ) previous.dispose() ownedGeometryRef.current = previewMeshRef.current.geometry - geometryRiseRef.current = `${preview.rise}:${landscapeSnap?.stepCount}:${landscapeSnap?.length}` + geometryRiseRef.current = `${preview.rise}:${surfaceSnap?.stepCount}:${surfaceSnap?.length}` } previewRef.current.scale.y = 1 } @@ -563,7 +560,7 @@ export default function StairTool() { [x, 0, z], rotation, supportSurfaceRef.current, - landscapeSnapRef.current, + surfaceSnapRef.current, ) const moving = preview ? movingAlignmentAnchors(preview.stair, preview.previewNodes, x, z, rotation) @@ -667,7 +664,7 @@ export default function StairTool() { baseElevation, useScene.getState().installedPlugins, ) - landscapeSnapRef.current = snap + surfaceSnapRef.current = snap if (snap) return snap.position return candidate } @@ -714,9 +711,9 @@ export default function StairTool() { commitStairPlacement( currentLevelId, position, - landscapeSnapRef.current?.rotation ?? rotationRef.current, + surfaceSnapRef.current?.rotation ?? rotationRef.current, supportSurfaceRef.current, - landscapeSnapRef.current, + surfaceSnapRef.current, ) openingPreview.clear() // Commit cleared the opening preview, so force the next hover (even on the diff --git a/packages/viewer/src/lib/merged-outline-node.test.ts b/packages/viewer/src/lib/merged-outline-node.test.ts index d5adb0335c..8a17dca74c 100644 --- a/packages/viewer/src/lib/merged-outline-node.test.ts +++ b/packages/viewer/src/lib/merged-outline-node.test.ts @@ -16,6 +16,7 @@ import { SkinnedMesh, Sprite, SpriteMaterial, + Texture, type Vector2, } from 'three' import RenderObject from 'three/src/renderers/common/RenderObject.js' @@ -435,3 +436,21 @@ describe('merged outline passes', () => { f.dispose() }) }) + +test('selection masks retain cutout coverage and release it when alpha testing is removed', () => { + const f = makeOutlineFixture() + const map = new Texture() + f.material.map = map + f.material.alphaTest = 0.5 + f.tick() + const mask = f.renderer.draws[0].material + expect(mask.alphaTest).toBe(0.5) + expect(mask.opacityNode).toBeDefined() + expect(f.mesh.material).toBe(f.material) + f.renderer.draws.length = 0 + f.material.alphaTest = 0 + f.tick() + expect(f.renderer.draws[0].material.alphaTest).toBe(0) + map.dispose() + f.dispose() +}) diff --git a/wiki/architecture/plugin-authoring.md b/wiki/architecture/plugin-authoring.md index ab1f85bc13..6746c3bdea 100644 --- a/wiki/architecture/plugin-authoring.md +++ b/wiki/architecture/plugin-authoring.md @@ -402,3 +402,57 @@ The boundary stays narrow on purpose so the contract is shippable. Each "not yet 3. The dev-mode `[pascal:registry]` console log shows the loaded plugin id + node count — that's the verification anchor. The host's own parity test (`packages/nodes/src/index.test.ts`) asserts every `AnyNode` discriminator has a registered kind. Plugin-contributed kinds don't participate in that test (they're not in `AnyNode`); add an equivalent test on your own side if you maintain a hand-typed union elsewhere. + +## Surface connections and editing contributions + +These optional fields extend node definitions, not the `Plugin` v1 manifest. See +DECISIONS.md E-017. Framework packages never import a node pack to implement them. + +- `capabilities.surfaces.top.boundary(node, inset)` returns an ordered polygon in + the node's parent XZ frame, in metres. The polygon can be clockwise or + counterclockwise. A positive inset moves the boundary inward. Return at least + three finite points; an unavailable boundary returns an empty array. `top.height` + is relative to `node.position[1]`, before floor construction lift, and its point + context is in the same parent frame. Snapping and railings consult the registry. +- `connectedMove(node, preview, nodes)` returns data patches for connected nodes. + It is pure: it must not mutate `nodes` or write the store. The editor applies its + patches to live overrides during a drag and includes them in the one commit on + release. It may return an empty list when there is no connection to move. +- `InspectorExtension.primaryWhen(node)` selects an extension as the card's main + content. Registered order breaks ties. It must be pure and cheap. The wrapper + subscribes to the resulting extension ID, so changes unrelated to that choice + do not rerender the whole card. The extension's own controls subscribe to the + fields they display. +- `capabilities.movable.preserveLevelAndElevation` keeps an existing floor-placed + object on its original level and visual elevation during a move. Fresh placement + still follows the chosen support. Stored Y remains an offset from the support, + not an absolute world height. +- `capabilities.movable.parentFrame.independent` keeps selection/manipulation on + the child instead of promoting its parent. The frame's conversion hooks still + own cursor and rotation conversion in 2D and 3D. +- `node.metadata.roomBoundary === false` excludes a wall-like node from room + topology. Omitted or true retains ordinary room-boundary behavior. This flag + does not change the node's visible geometry or paint surfaces. + +### Shared run tools + +Use `@pascal-app/editor/distribution` for `useDistributionRunTool`, +`DistributionRunCursor`, port queries, connection compatibility and screen/angle +snapping. It depends on the framework packages and never on `@pascal-app/nodes`. +External plugins must not depend on the built-in node pack. + +### Stair destination surfaces + +Stairs use `destinationSurfaceId` and `autoSurfaceSnap`. Their source support is +still `supportSlabId` and the stored Y is the offset above that support. Both edge +snapping and transition geometry read `surfaces.top.boundary`; they do not inspect +another plugin's width, shape, or private polygon fields. The scene loader migrates +old `landscapeSurfaceId` / `autoLandscapeSnap` values to these generic fields. + +### Stable package revisions + +Pin immutable commits reachable from a maintained publishing branch or release tag. +Bath Space uses `pascal-cluster`'s `packages/bath-space` branch; Landscape uses +`packages/landscape`. Do not use a temporary PR branch as the only reference to a +published revision. Streetscape is maintained in `pascalorg/plugin-streetscape` +with the existing package name and `pascal:streetscape` plugin ID. From 16091ac076f4c1a74bbf95884eb8a6bc44a404c0 Mon Sep 17 00:00:00 2001 From: sudhir Date: Fri, 16 Oct 2026 11:20:58 +0530 Subject: [PATCH 30/31] fix(cli): preserve runtime activation and shrink offline catalogs --- apps/editor/package.json | 2 +- bun.lock | 4 +- packages/cli/README.md | 18 ++++----- packages/cli/scripts/smoke-packed-runtime.ts | 39 ++++++++++++++++++-- packages/cli/src/editor-process.ts | 19 +++++++--- packages/cli/src/runtime.test.ts | 22 ++++++++++- 6 files changed, 80 insertions(+), 24 deletions(-) diff --git a/apps/editor/package.json b/apps/editor/package.json index 7eeb5360e2..35344aee22 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -21,7 +21,7 @@ "@pascal-app/nodes": "*", "@pascal-app/plugin-bones": "github:pascalorg/plugin-bones#5679260261ee1c733656ff6dfb99e30bb24b58a7", "@pascal-app/plugin-environment": "github:AxiomeCG/environment#1aa0bc3cbed273dbf751e9af79e446f6de105c90", - "@pascal-app/plugin-bath-space": "github:sudhir9297/pascal-cluster#70cffa9a6878122acf6e3fdbde5725db4908f737", + "@pascal-app/plugin-bath-space": "github:sudhir9297/pascal-cluster#357338ddb7e042498fca7b6e6ee2054edbeb70b7", "@pascal-app/plugin-landscape": "github:sudhir9297/pascal-cluster#7425539db7e63d4d42f65bef938420b4145479c3", "@pascal-app/plugin-pool": "github:sudhir9297/pool-pascal-plugin#b8e408121feae51d91c89f7efe16f9d8f795cb49", "@pascal-app/plugin-streetscape": "github:pascalorg/plugin-streetscape#bb563f3739a0dbaf7a3d0cc40f7ef9486951b0a1", diff --git a/bun.lock b/bun.lock index 739958f4b3..280d784fbc 100644 --- a/bun.lock +++ b/bun.lock @@ -36,7 +36,7 @@ "@pascal-app/editor": "*", "@pascal-app/mcp": "*", "@pascal-app/nodes": "*", - "@pascal-app/plugin-bath-space": "github:sudhir9297/pascal-cluster#70cffa9a6878122acf6e3fdbde5725db4908f737", + "@pascal-app/plugin-bath-space": "github:sudhir9297/pascal-cluster#357338ddb7e042498fca7b6e6ee2054edbeb70b7", "@pascal-app/plugin-bones": "github:pascalorg/plugin-bones#5679260261ee1c733656ff6dfb99e30bb24b58a7", "@pascal-app/plugin-environment": "github:AxiomeCG/environment#1aa0bc3cbed273dbf751e9af79e446f6de105c90", "@pascal-app/plugin-landscape": "github:sudhir9297/pascal-cluster#7425539db7e63d4d42f65bef938420b4145479c3", @@ -832,7 +832,7 @@ "@pascal-app/nodes": ["@pascal-app/nodes@workspace:packages/nodes"], - "@pascal-app/plugin-bath-space": ["@pascal-app/plugin-bath-space@github:sudhir9297/pascal-cluster#70cffa9", { "dependencies": { "@tanstack/react-virtual": "^3.14.13" }, "peerDependencies": { "@pascal-app/core": "^1.0.1", "@pascal-app/editor": "^1.0.1", "@pascal-app/viewer": "^1.0.1", "@react-three/fiber": "^9", "react": "^18 || ^19", "react-dom": "^18 || ^19", "three": "^0.186.0", "zod": ">=4.5.4 <4.6" } }, "sudhir9297-pascal-cluster-70cffa9", "sha512-TtSJe5zxn8hfHgguCMx+3eFMR+NoROkdQXfWduh9pWl7KE4JVC4Qs4IPiDjpH7aUzxtr505O9hwY4ChLXzubCA=="], + "@pascal-app/plugin-bath-space": ["@pascal-app/plugin-bath-space@github:sudhir9297/pascal-cluster#357338d", { "dependencies": { "@tanstack/react-virtual": "^3.14.13" }, "peerDependencies": { "@pascal-app/core": "^1.0.1", "@pascal-app/editor": "^1.0.1", "@pascal-app/viewer": "^1.0.1", "@react-three/fiber": "^9", "react": "^18 || ^19", "react-dom": "^18 || ^19", "three": "^0.186.0", "zod": ">=4.5.4 <4.6" } }, "sudhir9297-pascal-cluster-357338d", "sha512-PR6goK+qa4pFOBFFFUDrJxXs63rUSvNFnyJEQPq/fzOalLTzCIyGdaV7H/2bSA2xPMf8Ln3gFBdH4JtIKDocng=="], "@pascal-app/plugin-bones": ["@pascal-app/plugin-bones@github:pascalorg/plugin-bones#5679260", { "peerDependencies": { "@pascal-app/core": ">=0.9.1 <1", "@pascal-app/editor": ">=0.9.1 <1", "@pascal-app/viewer": ">=0.9.1 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "pascalorg-plugin-bones-5679260", "sha512-uQkyHHOl/VuYx2+d/MmcJui/KEAQZ2UUnO4ywp1XaopmD2YfN+Yx/fR/lS/VL2joEoyyZeBLC3KaXK1Sx5qEvg=="], diff --git a/packages/cli/README.md b/packages/cli/README.md index 7d0be6ad46..973f553806 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -84,20 +84,18 @@ npx @pascal-app/cli editor --foreground --no-open ## The web editor runtime -The npm package carries the CLI and the MCP service only: about 0.5 MB compressed and -2.5 MB installed. The web editor itself—the Next.js server, its static assets, and the +The npm package carries the CLI and the MCP service only: about 1.7 MiB compressed and +8.5 MiB installed. The web editor itself—the Next.js server, its static assets, and the bundled item and plugin libraries—is published as one archive per CLI version. With Bath Space, Landscape, Pool, Streetscape and WebXR included, the current archive -is about 184 MB compressed. The previous runtime without those catalogs was about -64 MB. Plugin media includes about 93 MB of WebP images and 57 MB of GLB models before -archive compression; these files are required for offline thumbnails and models. -The runtime is downloaded once per version, outside the npm package. +is about 126 MiB compressed, down from 184 MiB. Bath Space catalog thumbnails fit +within 512 × 512 pixels; all catalog entries, offline models and material textures +remain included. The runtime is downloaded once per version, outside the npm package. `stage-runtime` reports the compressed size and the static-media breakdown. The packed -runtime smoke check enforces the 220 MiB release ceiling. Removing catalog media to -meet the old budget would break offline projects; distributing optional catalog packs -would need a separate install/download contract and scene-loading fallback. A package -revision update should include this size report so media growth is visible in review. +runtime smoke check enforces a 140 MiB release ceiling and verifies every bundled +WebP image and GLB model through the installed editor's HTTP server. A package revision +update should include this size report so media growth is visible in review. Every command that starts the editor (`editor`, `start`, `open`, `resume`, `projects`, `project open`, `update`) resolves that runtime in this order: diff --git a/packages/cli/scripts/smoke-packed-runtime.ts b/packages/cli/scripts/smoke-packed-runtime.ts index 68d7b05b5e..1bdd8af523 100644 --- a/packages/cli/scripts/smoke-packed-runtime.ts +++ b/packages/cli/scripts/smoke-packed-runtime.ts @@ -1,7 +1,7 @@ import { spawn } from 'node:child_process' import { createHash } from 'node:crypto' import { createReadStream } from 'node:fs' -import { copyFile, mkdtemp, open, readFile, rm, stat } from 'node:fs/promises' +import { copyFile, mkdtemp, open, readdir, readFile, rm, stat } from 'node:fs/promises' import http from 'node:http' import os from 'node:os' import path from 'node:path' @@ -179,6 +179,7 @@ async function checkEditorFromLocalArchive(executable: string, archiveFile: stri if (!rootResponse.ok) throw new Error(`editor root returned ${rootResponse.status}`) const scenesResponse = await fetch(`${started.url}/scenes`) if (!scenesResponse.ok) throw new Error(`editor scenes returned ${scenesResponse.status}`) + await checkOfflineCatalogMedia(`http://127.0.0.1:${started.port}`) const repeatedStart = JSON.parse( ( await run( @@ -255,6 +256,37 @@ async function checkEditorFromLocalArchive(executable: string, archiveFile: stri await run(process.execPath, [executable, 'stop', '--json'], undefined, smokeEnvironment) } +async function checkOfflineCatalogMedia(origin: string): Promise { + const directory = path.join(packageDirectory, 'build/runtime/apps/editor/.next/static/media') + const files = (await readdir(directory)).filter((file) => /\.(webp|glb)$/.test(file)) + if ( + !files.some((file) => file.endsWith('.webp')) || + !files.some((file) => file.endsWith('.glb')) + ) { + throw new Error('the staged runtime is missing offline catalog images or models') + } + for (let start = 0; start < files.length; start += 8) { + await Promise.all( + files.slice(start, start + 8).map(async (file) => { + const response = await fetch(`${origin}/_next/static/media/${encodeURIComponent(file)}`, { + signal: AbortSignal.timeout(10_000), + }) + if (!response.ok) throw new Error(`offline catalog asset ${file}: HTTP ${response.status}`) + const [served, staged] = await Promise.all([ + response.arrayBuffer(), + readFile(path.join(directory, file)), + ]) + if (!Buffer.from(served).equals(staged)) { + throw new Error(`the installed archive changed offline catalog asset ${file}`) + } + }), + ) + } + console.log( + `Verified ${files.length} offline catalog images and models from the installed archive.`, + ) +} + async function verifyStagedWebRuntime(): Promise<{ file: string; size: number; url: string }> { const source = JSON.parse( await readFile(path.join(packageDirectory, 'dist/runtime-source.json'), 'utf8'), @@ -277,9 +309,8 @@ async function verifyStagedWebRuntime(): Promise<{ file: string; size: number; u if (size !== source.size) { throw new Error(`${archiveName} is ${size} bytes; runtime-source.json records ${source.size}`) } - // The integrated plugin catalogs include 57 MB of models and 93 MB of WebP assets. - // The portable archive is ~183 MB; leave bounded room for catalog updates. - const maximumArchiveSize = 220 * 1024 * 1024 + // Catalog thumbnails are sized for the sidebar; retain bounded room for new assets. + const maximumArchiveSize = 140 * 1024 * 1024 if (size > maximumArchiveSize) { throw new Error( `the web runtime archive exceeds its release budget: ${formatMb(size)} MB > ${formatMb(maximumArchiveSize)} MB`, diff --git a/packages/cli/src/editor-process.ts b/packages/cli/src/editor-process.ts index 6e77743b82..e6094b6fb7 100644 --- a/packages/cli/src/editor-process.ts +++ b/packages/cli/src/editor-process.ts @@ -141,7 +141,10 @@ export async function startEditor(options: StartEditorOptions): Promise startEditorUnlocked(options)) } -async function startEditorUnlocked(options: StartEditorOptions): Promise { +async function startEditorUnlocked( + options: StartEditorOptions, + selectedRuntime?: ActiveRuntime, +): Promise { await ensurePascalDirectories(options.paths) options.onProgress?.({ step: 'storage-ready', dataDirectory: options.paths.data }) let currentStatus: EditorStatus @@ -153,7 +156,8 @@ async function startEditorUnlocked(options: StartEditorOptions): Promise { const started = await startEditor({ paths, runtimeSource: firstSource }) const candidate = await installBundledRuntime(paths, secondSource, { activate: false }) - const result = await activateEditorRuntime(paths, candidate) + // Exercise the published layout, where a CLI pin exists even for an explicit activation. + const cliDist = path.join(root, 'cli/dist') + const built = await Bun.build({ + entrypoints: [fileURLToPath(new URL('./editor-process.ts', import.meta.url))], + outdir: cliDist, + target: 'node', + }) + expect(built.success).toBe(true) + await writeFile(path.join(cliDist, '../package.json'), JSON.stringify({ version: '3.0.0' })) + await writeFile( + path.join(cliDist, 'runtime-source.json'), + JSON.stringify({ + version: '3.0.0', + url: 'https://127.0.0.1:1/runtime.tar.gz', + sha256: 'a'.repeat(64), + size: 10, + }), + ) + const packaged = await import(pathToFileURL(path.join(cliDist, 'editor-process.js')).href) + const result = await packaged.activateEditorRuntime(paths, candidate) expect(result.restarted).toBe(true) const status = await getEditorStatus(paths) From 046f96249d0ec5f2477ced97bc6a5e73d63ee066 Mon Sep 17 00:00:00 2001 From: sudhir Date: Fri, 16 Oct 2026 11:44:08 +0530 Subject: [PATCH 31/31] fix(editor): pin shader texture type fixes CI merges main with @types/three 0.186, where ReturnType erases the RGBA type through a generic overload. Publish and pin explicit TextureNode declarations in Pool and Landscape. --- apps/editor/package.json | 4 ++-- bun.lock | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/editor/package.json b/apps/editor/package.json index 35344aee22..18df3eee25 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -22,8 +22,8 @@ "@pascal-app/plugin-bones": "github:pascalorg/plugin-bones#5679260261ee1c733656ff6dfb99e30bb24b58a7", "@pascal-app/plugin-environment": "github:AxiomeCG/environment#1aa0bc3cbed273dbf751e9af79e446f6de105c90", "@pascal-app/plugin-bath-space": "github:sudhir9297/pascal-cluster#357338ddb7e042498fca7b6e6ee2054edbeb70b7", - "@pascal-app/plugin-landscape": "github:sudhir9297/pascal-cluster#7425539db7e63d4d42f65bef938420b4145479c3", - "@pascal-app/plugin-pool": "github:sudhir9297/pool-pascal-plugin#b8e408121feae51d91c89f7efe16f9d8f795cb49", + "@pascal-app/plugin-landscape": "github:sudhir9297/pascal-cluster#d60ee247334dad037e02a432a50bed9264193402", + "@pascal-app/plugin-pool": "github:sudhir9297/pool-pascal-plugin#1da7fb0d29691f3e2e488e24b436261266c692ce", "@pascal-app/plugin-streetscape": "github:pascalorg/plugin-streetscape#bb563f3739a0dbaf7a3d0cc40f7ef9486951b0a1", "@pascal-app/plugin-trees": "github:pascalorg/plugin-trees#56d978cd9b409b716207b3f3d269455d3cd6f067", "@pascal-app/viewer": "*", diff --git a/bun.lock b/bun.lock index 280d784fbc..5992ce5dc9 100644 --- a/bun.lock +++ b/bun.lock @@ -39,8 +39,8 @@ "@pascal-app/plugin-bath-space": "github:sudhir9297/pascal-cluster#357338ddb7e042498fca7b6e6ee2054edbeb70b7", "@pascal-app/plugin-bones": "github:pascalorg/plugin-bones#5679260261ee1c733656ff6dfb99e30bb24b58a7", "@pascal-app/plugin-environment": "github:AxiomeCG/environment#1aa0bc3cbed273dbf751e9af79e446f6de105c90", - "@pascal-app/plugin-landscape": "github:sudhir9297/pascal-cluster#7425539db7e63d4d42f65bef938420b4145479c3", - "@pascal-app/plugin-pool": "github:sudhir9297/pool-pascal-plugin#b8e408121feae51d91c89f7efe16f9d8f795cb49", + "@pascal-app/plugin-landscape": "github:sudhir9297/pascal-cluster#d60ee247334dad037e02a432a50bed9264193402", + "@pascal-app/plugin-pool": "github:sudhir9297/pool-pascal-plugin#1da7fb0d29691f3e2e488e24b436261266c692ce", "@pascal-app/plugin-streetscape": "github:pascalorg/plugin-streetscape#bb563f3739a0dbaf7a3d0cc40f7ef9486951b0a1", "@pascal-app/plugin-trees": "github:pascalorg/plugin-trees#56d978cd9b409b716207b3f3d269455d3cd6f067", "@pascal-app/viewer": "*", @@ -838,9 +838,9 @@ "@pascal-app/plugin-environment": ["@pascal-app/plugin-environment@github:AxiomeCG/environment#1aa0bc3", { "peerDependencies": { "@dgreenheck/ez-tree": "^1.1.0", "@pascal-app/core": ">=1.0.0-beta.6 <2", "@pascal-app/editor": ">=1.0.0-beta.6 <2", "@pascal-app/viewer": ">=1.0.0-beta.6 <2", "@radix-ui/react-tooltip": "^1.2.8", "@react-three/fiber": "^9", "lucide-react": "^1.7.0", "react": "^18 || ^19", "react-colorful": "^5.8.1", "three": "^0.186", "zod": "^4", "zustand": "^5" } }, "AxiomeCG-environment-1aa0bc3", "sha512-wvCYDEr62ItSwgWf8zOJL0z97zTyZ7TszrFwLOcIhX7LRYrmHhP/6LE7lB6lBBeXNPExg7jRk8eQZm+ri6GfaQ=="], - "@pascal-app/plugin-landscape": ["@pascal-app/plugin-landscape@github:sudhir9297/pascal-cluster#7425539", { "dependencies": { "@types/clipper-lib": "^6.4.0", "clipper-lib": "^6.4.2", "polygon-clipping": "^0.15.7" }, "peerDependencies": { "@pascal-app/core": "^1.0.4", "@pascal-app/editor": "^1.0.4", "@pascal-app/viewer": "^1.0.4", "@react-three/drei": "^10", "@react-three/fiber": "^9", "react": "^18 || ^19", "three": "^0.186.0", "zod": "^4.6.7" } }, "sudhir9297-pascal-cluster-7425539", "sha512-La+nGzzyStNVGEdHHrVD/0afwUM5dzWPZxK0AYkRFnjnpDOdlp+IxaGiXkcvdwWvVTPTDOeZc46nCDzC+AnOtw=="], + "@pascal-app/plugin-landscape": ["@pascal-app/plugin-landscape@github:sudhir9297/pascal-cluster#d60ee24", { "dependencies": { "@types/clipper-lib": "^6.4.0", "clipper-lib": "^6.4.2", "polygon-clipping": "^0.15.7" }, "peerDependencies": { "@pascal-app/core": "^1.0.4", "@pascal-app/editor": "^1.0.4", "@pascal-app/viewer": "^1.0.4", "@react-three/drei": "^10", "@react-three/fiber": "^9", "react": "^18 || ^19", "three": "^0.186.0", "zod": "^4.6.7" } }, "sudhir9297-pascal-cluster-d60ee24", "sha512-yKy3zcFmehZ6PzNzOvjWsc4gUKC+Hjo/Zwn58uIHrFAuYmLlp58sywzCJp56i+gt+NxKrtQgTpodDvSt8qsaEQ=="], - "@pascal-app/plugin-pool": ["@pascal-app/plugin-pool@github:sudhir9297/pool-pascal-plugin#b8e4081", { "dependencies": { "three-bvh-csg": "^0.0.18", "three-mesh-bvh": "^0.9.8" }, "peerDependencies": { "@pascal-app/core": ">=0.9.1 <1 || >=1.0.0-beta.0 <2", "@pascal-app/editor": ">=0.9.1 <1 || >=1.0.0-beta.0 <2", "@pascal-app/viewer": ">=0.9.1 <1 || >=1.0.0-beta.0 <2", "@react-three/fiber": "^9", "react": "^18 || ^19", "react-dom": "^18 || ^19", "three": "^0.186.0", "zod": "^4", "zustand": "^5" } }, "sudhir9297-pool-pascal-plugin-b8e4081", "sha512-tlsIyYUCLFqxBUr2WOBAp6vOLrgkHF+PuBlWTPTgoAMZNN435cwKzPfmzul5xl4FMQC3TZnC7G0oFpK0HXoiwQ=="], + "@pascal-app/plugin-pool": ["@pascal-app/plugin-pool@github:sudhir9297/pool-pascal-plugin#1da7fb0", { "dependencies": { "three-bvh-csg": "^0.0.18", "three-mesh-bvh": "^0.9.8" }, "peerDependencies": { "@pascal-app/core": ">=0.9.1 <1 || >=1.0.0-beta.0 <2", "@pascal-app/editor": ">=0.9.1 <1 || >=1.0.0-beta.0 <2", "@pascal-app/viewer": ">=0.9.1 <1 || >=1.0.0-beta.0 <2", "@react-three/fiber": "^9", "react": "^18 || ^19", "react-dom": "^18 || ^19", "three": "^0.186.0", "zod": "^4", "zustand": "^5" } }, "sudhir9297-pool-pascal-plugin-1da7fb0", "sha512-aYpRIiOviz+HlYuf115smIefCz+pVCDTatf6/SxUevBPkoIEN48uwF2VjOEgwx8ZzjDM5G0dTKsQ3tRDHMnH4g=="], "@pascal-app/plugin-streetscape": ["@pascal-app/plugin-streetscape@github:pascalorg/plugin-streetscape#bb563f3", { "dependencies": { "lucide-react": "^1.41.0", "maplibre-gl": "^6.7.0" }, "peerDependencies": { "@pascal-app/core": ">=0.9.1 <1", "@pascal-app/editor": ">=0.9.1 <1", "@pascal-app/viewer": ">=0.9.1 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "react-dom": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "pascalorg-plugin-streetscape-bb563f3", "sha512-QA8Z7PC2/GwyoabHCqVM1hdr7ZvrChsK7cklKG+L1ZqNln0O3rn6ooEqJxbBarbyCM82+Kbb/qz6KerjDtl3cw=="],