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 (
- {!isMobile && selectedId && activeExtension ? (
+ {selectedId && primaryExtension ? (
+
+ ) : !isMobile && selectedId && activeExtension ? (
+
+
+ )
+}
+
+function InspectorExtensionContent({
+ extension,
+ nodeId,
+}: {
+ extension: InspectorExtension
+ nodeId: AnyNodeId
}) {
const node = useScene((s) => s.nodes[nodeId])
if (!node) return null
const Extension = resolveExtensionComponent(extension)
return (
-
-
- “{extension.title}” hit an error and was unloaded for this session.
-
- }
- >
-
-
-
-
-
+
+ “{extension.title}” hit an error and was unloaded for this session.
+
+ }>
+
+
+
+
)
}
diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx
index f042f09ffe..532345f042 100644
--- a/packages/editor/src/index.tsx
+++ b/packages/editor/src/index.tsx
@@ -115,6 +115,17 @@ export type {
SelectionAffordanceProps,
} from './components/systems/selection-affordance-services'
export { StairEditSystem } from './components/systems/stair/stair-edit-system'
+// Phase 5 Stage D transitional exports — pure drafting / angle helpers
+// consumed by kind-owned drag actions in @pascal-app/nodes. Stage F
+// cleanup moves these into @pascal-app/nodes (fence/drafting.ts +
+// shared/segment-angle.ts) once every Stage D port is in.
+export {
+ createFenceOnCurrentLevel,
+ createSplineFenceOnCurrentLevel,
+ type FencePlanPoint,
+ getFenceDrawingSurface,
+ snapFenceDraftPoint,
+} from './components/tools/fence/fence-drafting'
export { MoveTool } from './components/tools/item/move-tool'
// Placement-math helpers — shared by kind-owned placement tools in
// `@pascal-app/nodes` (wall curve sagitta snap, door / window placement,
@@ -177,6 +188,8 @@ export {
getSegmentAngleReferenceAtPoint,
type SegmentAngleReference,
} from './components/tools/shared/segment-angle'
+export type { LandscapeStairSnap } from './components/tools/stair/landscape-snap'
+export { resolveLandscapeStairSnap } from './components/tools/stair/landscape-snap'
// Stair placement defaults — used by the kind-owned stair / stair-segment
// panels. Re-exported from `components/tools/stair/stair-defaults.ts`.
export {
@@ -864,6 +877,11 @@ export {
useWallDrawVariant,
type WallDrawVariant,
} from './lib/wall-draw-variant'
+export {
+ createWallPointerTracker,
+ wallPointerInParent,
+ wallSideAtPointer,
+} from './lib/wall-placement-pointer'
export {
addWallPolygonDraftCorner,
commitWallPolygonDraft,
diff --git a/packages/editor/src/lib/direct-manipulation.ts b/packages/editor/src/lib/direct-manipulation.ts
index bcd9411b26..a9af44a940 100644
--- a/packages/editor/src/lib/direct-manipulation.ts
+++ b/packages/editor/src/lib/direct-manipulation.ts
@@ -107,6 +107,7 @@ export function resolveDirectManipulationNode(
): AnyNode {
const target = nodes[resolveSelectionProxyId(node, nodes)] ?? node
const parentFrame = nodeRegistry.get(target.type)?.capabilities?.movable?.parentFrame
+ if (parentFrame?.independent) return target
const parent = parentFrame?.resolveParent(target, nodes as Readonly>)
return parent && canDirectRotateNode(parent) ? parent : target
}
@@ -116,6 +117,7 @@ export function resolveMoveActionNode(
nodes: Readonly>,
): AnyNode {
const parentFrame = nodeRegistry.get(node.type)?.capabilities?.movable?.parentFrame
+ if (parentFrame?.independent) return node
const parent = parentFrame?.resolveParent(node, nodes as Readonly>)
return parent && (parent.type === node.type || canDirectRotateNode(parent)) ? parent : node
}
diff --git a/packages/editor/src/lib/fresh-planar-placement.ts b/packages/editor/src/lib/fresh-planar-placement.ts
index 0ee837774e..727e56130d 100644
--- a/packages/editor/src/lib/fresh-planar-placement.ts
+++ b/packages/editor/src/lib/fresh-planar-placement.ts
@@ -8,6 +8,7 @@ import {
collectSubtree,
createSceneApi,
type DuplicableConfig,
+ getEffectiveNode,
getSceneHistoryPauseDepth,
getSurfaceProvider,
nodeRegistry,
@@ -300,7 +301,9 @@ export function commitFreshPlacementSubtree(
onReject?.(rejection)
return null
}
- const descendants = subtree.descendants.map((node) => cleanPlacementMetadata(node))
+ const descendants = subtree.descendants.map((node) =>
+ cleanPlacementMetadata(getEffectiveNode(node)),
+ )
const parentId = parentIdOf(root)
const cloned = cloneNodesInto([root, ...descendants], { rootId, parentId })
// The drafts' memberships, read before they are deleted.
diff --git a/packages/editor/src/lib/glb-export.ts b/packages/editor/src/lib/glb-export.ts
index b25f8efe78..01cb042011 100644
--- a/packages/editor/src/lib/glb-export.ts
+++ b/packages/editor/src/lib/glb-export.ts
@@ -903,7 +903,10 @@ function cloneSceneForExport(
): THREE.Object3D {
if (excludedObjects.has(source)) return new THREE.Group()
- const clone = source.clone(false)
+ // LOD.copy clones every level even when called through clone(false). Export
+ // walks the children itself, and plugin LOD constructors may require inputs.
+ const clone =
+ source instanceof THREE.LOD ? new THREE.Group().copy(source, false) : source.clone(false)
clone.userData = cloneExportUserData(source.userData)
const renderable = source as THREE.Mesh
const renderableClone = clone as THREE.Mesh
diff --git a/packages/editor/src/lib/selection-routing.ts b/packages/editor/src/lib/selection-routing.ts
index f050da6e9c..32d91ee11d 100644
--- a/packages/editor/src/lib/selection-routing.ts
+++ b/packages/editor/src/lib/selection-routing.ts
@@ -98,7 +98,7 @@ export function resolveCanvasSelectionNode({
const proxiedTarget = nodes[resolveSelectionProxyId(node, nodes)] ?? node
let target = shouldBypassSelectionProxy(node, proxiedTarget) ? node : proxiedTarget
const parentFrame = nodeRegistry.get(target.type)?.capabilities?.movable?.parentFrame
- if (parentFrame) {
+ if (parentFrame && !parentFrame.independent) {
const parent = parentFrame.resolveParent(target, nodes as Readonly>)
if (parent && selectedIds.length === 1 && selectedIds[0] === parent.id) {
target = parent
diff --git a/packages/editor/src/lib/wall-placement-pointer.ts b/packages/editor/src/lib/wall-placement-pointer.ts
new file mode 100644
index 0000000000..75ddef07ed
--- /dev/null
+++ b/packages/editor/src/lib/wall-placement-pointer.ts
@@ -0,0 +1,49 @@
+import { type Object3D, Vector3 } from 'three'
+
+type WallSide = 'front' | 'back'
+type Point = readonly [number, number]
+
+/** Keep the grabbed point only on its original face; new hosts follow the cursor. */
+export function createWallPointerTracker(source?: {
+ wallId: string | null
+ side?: WallSide
+ position: readonly number[]
+}) {
+ let forgotten = !source
+ let anchor: { wallId: string; side: WallSide; x: number; y: number } | null = null
+ return {
+ resolve(wallId: string, side: WallSide, point: Point): [number, number] {
+ if (!anchor || anchor.wallId !== wallId || anchor.side !== side) {
+ const preserve =
+ !forgotten && source?.wallId === wallId && (!source.side || source.side === side)
+ if (!preserve) forgotten = true
+ anchor = {
+ wallId,
+ side,
+ x: preserve ? source!.position[0]! - point[0] : 0,
+ y: preserve ? source!.position[1]! - point[1] : 0,
+ }
+ }
+ return [point[0] + anchor.x, point[1] + anchor.y]
+ },
+ leave() {
+ anchor = null
+ forgotten = true
+ },
+ }
+}
+
+/** Measure the hit in the same parent frame that renders the placed child. */
+export function wallPointerInParent(
+ worldPoint: readonly number[],
+ parent: Object3D,
+): [number, number, number] {
+ parent.updateWorldMatrix(true, false)
+ const point = parent.worldToLocal(new Vector3(worldPoint[0], worldPoint[1], worldPoint[2]))
+ return [point.x, point.y, point.z]
+}
+
+/** Collision mesh winding can differ from the wall frame; the hit identifies the touched side. */
+export function wallSideAtPointer(point: readonly number[]): WallSide {
+ return point[2]! >= 0 ? 'front' : 'back'
+}
diff --git a/packages/editor/src/store/use-stair-build-preview.ts b/packages/editor/src/store/use-stair-build-preview.ts
index b9e0a66c36..d3dc04bc6b 100644
--- a/packages/editor/src/store/use-stair-build-preview.ts
+++ b/packages/editor/src/store/use-stair-build-preview.ts
@@ -51,7 +51,6 @@ export const useStairBuildPreview = create((set) => ({
rise: rise === undefined ? state.rise : rise,
}
}),
- rotateBy: (deltaRadians) => set((state) => ({ rotation: state.rotation + deltaRadians })),
reset: () =>
set((state) =>
state.point === null && state.rotation === 0 && state.rise === null
diff --git a/packages/nodes/package.json b/packages/nodes/package.json
index 35d2729609..1258266512 100644
--- a/packages/nodes/package.json
+++ b/packages/nodes/package.json
@@ -15,6 +15,11 @@
"types": "./dist/procedural-item/index.d.ts",
"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"
}
},
"files": [
diff --git a/packages/nodes/src/fence/drafting.ts b/packages/nodes/src/fence/drafting.ts
index c2b327c977..e1c06dac50 100644
--- a/packages/nodes/src/fence/drafting.ts
+++ b/packages/nodes/src/fence/drafting.ts
@@ -1,8 +1,11 @@
+import { getLevelPresentationY, useViewer } from '@pascal-app/viewer'
import {
type AnyNodeId,
DEFAULT_ANGLE_STEP,
type FenceConstructionOptions as FenceCommitOptions,
FenceNode,
+ floorConstructionLift,
+ nodeRegistry,
getFenceCenterlineLength,
getFenceSplineLength,
resolveFenceConstructionSupport,
@@ -91,6 +94,60 @@ export function getFenceInheritedDefaults(
const FENCE_CORNER_SNAP_RADIUS = 0.28
const FENCE_SPAN_SNAP_RADIUS = 0.16
+const FENCE_CORNER_SNAP_RADIUS = 0.28
+const FENCE_SPAN_SNAP_RADIUS = 0.16
+
+export function getFenceDrawingSurface() {
+ const id = useEditor.getState().toolDefaults.fence?.supportSurfaceId
+ if (typeof id !== 'string') return null
+ const nodes = useScene.getState().nodes
+ const node = nodes[id as AnyNodeId]
+ if (
+ !node?.parentId ||
+ node.parentId !== useViewer.getState().selection.levelId ||
+ node.visible === false
+ )
+ return null
+ const top = nodeRegistry.get(node.type)?.capabilities?.surfaces?.top
+ if (!top?.boundary) return null
+ const position = (node as unknown as { position: number[] }).position
+ const height = typeof top.height === 'function' ? top.height(node, { nodes }) : top.height
+ return {
+ id,
+ boundary: top.boundary(node, 0.08),
+ levelElevation: getLevelPresentationY(node.parentId, nodes, useViewer.getState().levelMode),
+ elevation: (position?.[1] ?? 0) + height + floorConstructionLift(nodes, node),
+ }
+}
+
+function findSurfaceSnapTarget(point: FencePlanPoint): FencePlanPoint | null {
+ const surface = getFenceDrawingSurface()
+ if (!surface) return null
+ let corner: FencePlanPoint | null = null
+ let edge: FencePlanPoint | null = null
+ let cornerDistance = FENCE_CORNER_SNAP_RADIUS ** 2
+ let edgeDistance = FENCE_SPAN_SNAP_RADIUS ** 2
+ for (let index = 0; index < surface.boundary.length; index++) {
+ const a = surface.boundary[index]!
+ const b = surface.boundary[(index + 1) % surface.boundary.length]!
+ const candidate: FencePlanPoint = [a[0], a[1]]
+ const distance = distanceSquared(point, candidate)
+ if (distance <= cornerDistance) {
+ corner = candidate
+ cornerDistance = distance
+ }
+ const projection = projectPointOntoSegment(point, { start: candidate, end: [b[0], b[1]] })
+ if (projection) {
+ const distance = distanceSquared(point, projection)
+ if (distance <= edgeDistance) {
+ edge = projection
+ edgeDistance = distance
+ }
+ }
+ }
+ return corner ?? edge
+}
+
type SegmentNode = {
start: FencePlanPoint
end: FencePlanPoint
diff --git a/packages/nodes/src/fence/geometry.ts b/packages/nodes/src/fence/geometry.ts
index cbdffb782e..73a8b1282c 100644
--- a/packages/nodes/src/fence/geometry.ts
+++ b/packages/nodes/src/fence/geometry.ts
@@ -208,6 +208,7 @@ export function buildFenceGeometry(
return host?.type === 'slab' ? liftedManualSlab(nodes, host) : host
},
startGround,
+ nodes,
)
: 0
const lift = supportAt
diff --git a/packages/nodes/src/fence/lift.ts b/packages/nodes/src/fence/lift.ts
index 3d7eeb9200..997dd095a9 100644
--- a/packages/nodes/src/fence/lift.ts
+++ b/packages/nodes/src/fence/lift.ts
@@ -5,6 +5,7 @@ import {
floorConstructionLift,
levelBaseElevationAt,
liftedManualSlab,
+ nodeRegistry,
type SlabNode,
} from '@pascal-app/core'
import type { FenceNode } from './schema'
@@ -31,11 +32,21 @@ import type { FenceNode } from './schema'
* instead of following the hillside around it.
*/
export function resolveFenceLiftElevation(
- node: Pick,
+ node: Pick,
resolve: (id: string) => AnyNode | undefined,
levelBase = 0,
+ nodes: Record = {},
): number {
const offset = node.supportOffset ?? 0
+ if (node.supportSurfaceId) {
+ const surface = resolve(node.supportSurfaceId)
+ const top = surface && nodeRegistry.get(surface.type)?.capabilities?.surfaces?.top
+ if (surface && top && surface.parentId === node.parentId) {
+ const height = typeof top.height === 'function' ? top.height(surface, { nodes }) : top.height
+ const position = (surface as unknown as { position?: number[] }).position
+ return (position?.[1] ?? 0) + height + floorConstructionLift(nodes, surface) + offset
+ }
+ }
if (!node.supportSlabId) return levelBase + offset
const host = resolve(node.supportSlabId)
if (host?.type !== 'slab') return levelBase + offset
@@ -57,7 +68,10 @@ export function resolveFenceLiftElevation(
* as a closure rather than the store.
*/
export function resolveFenceLiftElevationForNodes(
- node: Pick,
+ node: Pick<
+ FenceNode,
+ 'id' | 'start' | 'supportSlabId' | 'supportSurfaceId' | 'supportOffset' | 'parentId'
+ >,
nodes: Record,
): number {
const levelId = findLevelAncestorId(node.id as AnyNodeId, nodes)
@@ -69,5 +83,6 @@ export function resolveFenceLiftElevationForNodes(
return host?.type === 'slab' ? liftedManualSlab(nodes, host) : host
},
levelBase + (nodes[node.id] ? floorConstructionLift(nodes, nodes[node.id]!) : 0),
+ nodes,
)
}
diff --git a/packages/nodes/src/fence/tool.tsx b/packages/nodes/src/fence/tool.tsx
index a1b0847af0..2c2304d4d3 100644
--- a/packages/nodes/src/fence/tool.tsx
+++ b/packages/nodes/src/fence/tool.tsx
@@ -29,11 +29,10 @@ import {
formatLinearMeasurement,
getAngleArcToSegmentReference,
getAngleToSegmentReference,
+ getFenceDrawingSurface,
getSegmentAngleReferenceAtPoint,
- getSegmentGridStep,
isAlignmentGuideActive,
isAngleSnapActive,
- isGridSnapActive,
isMagneticSnapActive,
isTypingTarget,
markToolCancelConsumed,
@@ -55,7 +54,17 @@ import {
import { createSceneSupportHeightSampler, getSceneTheme, useViewer } from '@pascal-app/viewer'
import { useThree } from '@react-three/fiber'
import { useEffect, useMemo, useRef, useState } from 'react'
-import { BufferGeometry, type Camera, DoubleSide, type Group, type Mesh, Vector3 } from 'three'
+import {
+ BufferGeometry,
+ type Camera,
+ DoubleSide,
+ type Group,
+ type Mesh,
+ Plane,
+ Raycaster,
+ Vector2,
+ Vector3,
+} from 'three'
import {
DraftAngleArc,
type DraftAngleLabel,
@@ -75,6 +84,30 @@ import { createFenceRailHeightSampler, generateFenceGeometry } from './geometry-
const FENCE_PREVIEW_HEIGHT = 1.8
const FENCE_PREVIEW_THICKNESS = 0.08
+
+function FenceSurfaceSnapPoints() {
+ useEditor((state) => state.toolDefaults.fence?.supportSurfaceId)
+ useViewer((state) => state.selection.levelId)
+ useViewer((state) => state.levelMode)
+ useScene((state) => state.nodes)
+ const surface = getFenceDrawingSurface()
+ if (!surface) return null
+ return (
+
+ {surface.boundary.map((point, index) => (
+
+
+
+
+ ))}
+
+ )
+}
// Grid-plane surface publish (pointer-decided): scratch + constant normal so
// per-move publishes don't allocate.
const SURFACE_UP = new Vector3(0, 1, 0)
@@ -482,11 +515,27 @@ function getCurrentLevelElements(): { walls: WallNode[]; fences: FenceNode[] } {
}
export const FenceTool: React.FC = () => {
+ const levelId = useViewer((state) => state.selection.levelId)
+ const levelMode = useViewer((state) => state.levelMode)
+ const nodes = useScene((state) => state.nodes)
+ const hostId = useEditor((state) => state.toolDefaults.fence?.supportSurfaceId)
+ const floorY = levelId ? getLevelPresentationY(levelId, nodes, levelMode) : 0
+ const draftKey = `${levelId}:${floorY}`
+ useEffect(() => {
+ if (typeof hostId === 'string' && nodes[hostId as AnyNodeId]?.parentId !== levelId) {
+ useEditor.getState().setTool(null)
+ }
+ }, [hostId, levelId, nodes])
const fenceMode = useEditor((s) => s.continuationByContext.fence)
const feature = useEditor((s) => s.toolDefaults.fence?.featurePlacement)
if (feature === 'gate' || feature === 'opening') return
if (fenceMode === 'curved') {
- return
+ return (
+ <>
+
+
+ >
+ )
}
if (fenceMode === 'freehand') return
return
@@ -564,6 +613,10 @@ const StraightFenceTool: React.FC = () => {
point: FencePlanPoint,
options?: { applySnap?: boolean },
): FencePlanPoint => {
+ if (getFenceDrawingSurface()) {
+ useAlignmentGuides.getState().clear()
+ return point
+ }
// Figma alignment lines onto existing corners / edges are DISPLAYED in
// every mode except Off (isAlignmentGuideActive); the magnetic pull onto
// them is applied only in 'lines' mode (isMagneticSnapActive).
@@ -632,6 +685,7 @@ const StraightFenceTool: React.FC = () => {
start: angleLocked ? [startingPoint.current.x, startingPoint.current.z] : undefined,
angleSnap: angleLocked,
magnetic: isMagneticSnapActive(),
+ bypassSnap: event.nativeEvent.altKey,
}),
{ applySnap: !angleLocked },
)
@@ -684,6 +738,7 @@ const StraightFenceTool: React.FC = () => {
walls,
fences,
magnetic: isMagneticSnapActive(),
+ bypassSnap: event.nativeEvent.altKey,
}),
)
cursorRef.current.position.set(snappedPoint[0], activeY, snappedPoint[1])
@@ -701,7 +756,10 @@ const StraightFenceTool: React.FC = () => {
const { walls, fences } = getCurrentLevelElements()
const pointed = pointedSurfaceFor(cameraRef.current, event)
- const localClick: FencePlanPoint = [event.localPosition[0], event.localPosition[2]]
+ const localClick: FencePlanPoint = [
+ pointed?.localPoint?.[0] ?? event.localPosition[0],
+ pointed?.localPoint?.[2] ?? event.localPosition[2],
+ ]
if (buildingState.current === 0) {
const snappedStart = alignPoint(
@@ -710,6 +768,7 @@ const StraightFenceTool: React.FC = () => {
walls,
fences,
magnetic: isMagneticSnapActive(),
+ bypassSnap: event.nativeEvent.altKey,
}),
)
startingPoint.current.set(
@@ -742,6 +801,7 @@ const StraightFenceTool: React.FC = () => {
start: angleLocked ? [startingPoint.current.x, startingPoint.current.z] : undefined,
angleSnap: angleLocked,
magnetic: isMagneticSnapActive(),
+ bypassSnap: event.nativeEvent.altKey,
}),
{ applySnap: !angleLocked },
)
@@ -1081,10 +1141,13 @@ const SplineFenceDraft: React.FC<{ freehand?: boolean }> = ({ freehand = false }
commit()
return
}
- const point = snapPoint([
- pointed?.localPoint?.[0] ?? event.localPosition[0],
- pointed?.localPoint?.[2] ?? event.localPosition[2],
- ])
+ const point = snapPoint(
+ [
+ pointed?.localPoint?.[0] ?? event.localPosition[0],
+ pointed?.localPoint?.[2] ?? event.localPosition[2],
+ ],
+ event.nativeEvent.altKey,
+ )
triggerSFX('sfx:grid-snap')
setDraftPoints((prev) => [...prev, point])
}
diff --git a/packages/nodes/src/shared/distribution-public.ts b/packages/nodes/src/shared/distribution-public.ts
new file mode 100644
index 0000000000..e7ea48f1d7
--- /dev/null
+++ b/packages/nodes/src/shared/distribution-public.ts
@@ -0,0 +1,19 @@
+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/distribution-run-tool.tsx b/packages/nodes/src/shared/distribution-run-tool.tsx
index 781975271d..0d161b167f 100644
--- a/packages/nodes/src/shared/distribution-run-tool.tsx
+++ b/packages/nodes/src/shared/distribution-run-tool.tsx
@@ -219,10 +219,10 @@ export function projectRunToSurfaceAngleLock(
]
}
-type DistributionRunToolConfig = {
+export type DistributionRunToolConfig = {
active: boolean
levelId: AnyNodeId | null
- toolName: 'duct-segment' | 'pipe-segment'
+ toolName: string
initialStart?: RunPoint | null
initialConnection?: RunConnection | null
getPorts: () => ScenePort[]
diff --git a/packages/nodes/src/shared/move-roof-tool.tsx b/packages/nodes/src/shared/move-roof-tool.tsx
index c777897550..e577c9ec30 100644
--- a/packages/nodes/src/shared/move-roof-tool.tsx
+++ b/packages/nodes/src/shared/move-roof-tool.tsx
@@ -26,8 +26,11 @@ import {
consumePlacementDragRelease,
DragBoundingBox,
getFloorStackPreviewPosition,
+ getMovingNode,
isMagneticSnapActive,
isTypingTarget,
+ type LandscapeStairSnap,
+ resolveLandscapeStairSnap,
resolvePlanarCursorPosition,
selectPlacedNode,
stripPlacementMetadataFlags,
@@ -41,6 +44,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'
/** Figma-style alignment-snap threshold (meters), matching the other tools. */
const ALIGNMENT_THRESHOLD_M = 0.08
@@ -79,7 +83,12 @@ function resolvePreviewRotationY(
export const MoveRoofTool: React.FC<{
node: RoofNode | RoofSegmentNode | StairNode | StairSegmentNode
-}> = ({ node: movingNode }) => {
+}> = ({ node: sourceNode }) => {
+ const currentNode = useScene((state) =>
+ sourceNode.type === 'stair' ? state.nodes[sourceNode.id] : undefined,
+ )
+ const movingNode =
+ sourceNode.type === 'stair' && currentNode?.type === 'stair' ? currentNode : sourceNode
const {
isFreshPlacement,
previewVisible: cursorVisible,
@@ -155,9 +164,24 @@ export const MoveRoofTool: React.FC<{
let wasCommitted = false
let wasCancelled = false
let hasMoved = false
+ let landscapeSnap: LandscapeStairSnap | null = null
+ const flightId =
+ movingNode.type === 'stair'
+ ? movingNode.children
+ .map((id) => useScene.getState().nodes[id])
+ .find(
+ (child): child is StairSegmentNode =>
+ child?.type === 'stair-segment' && child.segmentType === 'stair',
+ )?.id
+ : undefined
+ const clearLandscapePreview = () => {
+ if (movingNode.type !== 'stair') return
+ clearStairMovePreview(movingNode.id, flightId)
+ }
// Track pending rotation — no store updates during drag
let pendingRotation: number = movingNode.rotation as number
+ let freeRotation = pendingRotation
let lastLocalPosition: [number, number, number] = [
movingNode.position[0],
movingNode.position[1],
@@ -325,7 +349,7 @@ export const MoveRoofTool: React.FC<{
const y = event.position[1]
- const roofBypassSnap = event.nativeEvent?.shiftKey === true
+ const roofBypassSnap = event.nativeEvent?.altKey === true
const snappedLocal = snapFenceDraftPoint({
point: [event.localPosition[0], event.localPosition[2]],
walls: levelWalls,
@@ -351,17 +375,13 @@ export const MoveRoofTool: React.FC<{
let [localX, localZ] = resolved.point
if (alignTopLevel) {
- const aligned = alignLocalPoint(
- localX,
- localZ,
- event.nativeEvent?.altKey === true || event.nativeEvent?.shiftKey === true,
- )
+ const aligned = alignLocalPoint(localX, localZ, event.nativeEvent?.altKey === true)
localX = aligned[0]
localZ = aligned[1]
}
if (
- event.nativeEvent?.shiftKey !== true &&
+ event.nativeEvent?.altKey !== true &&
previousGridPosRef.current &&
(localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1])
) {
@@ -371,6 +391,43 @@ export const MoveRoofTool: React.FC<{
previousGridPosRef.current = [localX, localZ]
lastLocalPosition = [localX, movingNode.position[1], localZ]
+ const wasLandscapeSnapped = landscapeSnap !== null
+ if (movingNode.type === 'stair' && event.nativeEvent?.altKey !== true) {
+ const sceneNodes = useScene.getState().nodes
+ const segment = movingNode.children
+ .map((id) => sceneNodes[id])
+ .find(
+ (child): child is StairSegmentNode =>
+ child?.type === 'stair-segment' && child.segmentType === 'stair',
+ )
+ const baseElevation = getPreviewPosition(lastLocalPosition)[1]
+ landscapeSnap = resolveLandscapeStairSnap(
+ { ...movingNode, rotation: freeRotation },
+ sceneNodes,
+ lastLocalPosition,
+ segment?.length ?? 3,
+ baseElevation,
+ )
+ if (landscapeSnap) {
+ if (!wasLandscapeSnapped) freeRotation = pendingRotation
+ lastLocalPosition = landscapeSnap.position
+ pendingRotation = landscapeSnap.rotation
+ setPreviewRotation(landscapeSnap.rotation)
+ }
+ } else landscapeSnap = null
+ if (wasLandscapeSnapped && !landscapeSnap) {
+ pendingRotation = freeRotation
+ setPreviewRotation(resolvePreviewRotationY(movingNode, freeRotation))
+ }
+ if (movingNode.type === 'stair') {
+ publishStairMovePreview(
+ movingNode.id,
+ flightId,
+ lastLocalPosition,
+ pendingRotation,
+ landscapeSnap,
+ )
+ }
const previewPosition = getPreviewPosition(lastLocalPosition)
setCursorWorldPos(
isFloorPlaced ? previewPosition : localPositionToToolLocal(lastLocalPosition),
@@ -385,6 +442,8 @@ export const MoveRoofTool: React.FC<{
mesh.position.x = localX
mesh.position.z = localZ
}
+ if (movingNode.type === 'stair')
+ mesh.rotation.y = resolvePreviewRotationY(movingNode, pendingRotation)
}
// Publish canonical position so the 2D floorplan can track the drag.
@@ -414,15 +473,23 @@ export const MoveRoofTool: React.FC<{
position,
rotation: pendingRotation,
} as typeof movingNode
- const supportPatch = isFloorPlaced
- ? resolveSupportSlabPatch(effectiveNode, {
- ...useScene.getState().nodes,
- [movingNode.id]: effectiveNode,
- })
- : {}
+ const supportPatch = landscapeSnap
+ ? {}
+ : isFloorPlaced
+ ? resolveSupportSlabPatch(effectiveNode, {
+ ...useScene.getState().nodes,
+ [movingNode.id]: effectiveNode,
+ })
+ : {}
let committedId = movingNode.id as AnyNodeId
if (isNew) {
+ if (landscapeSnap && flightId)
+ useScene.getState().updateNode(flightId, {
+ height: landscapeSnap.totalRise,
+ length: landscapeSnap.length,
+ stepCount: landscapeSnap.stepCount,
+ })
committedId =
commitFreshPlacementSubtree(movingNode.id as AnyNodeId, {
position,
@@ -430,6 +497,17 @@ export const MoveRoofTool: React.FC<{
metadata: committedMeta,
visible: true,
...supportPatch,
+ ...(landscapeSnap
+ ? {
+ totalRise: landscapeSnap.totalRise,
+ stepCount: landscapeSnap.stepCount,
+ slabOpeningMode: 'none' as const,
+ landscapeSurfaceId: landscapeSnap.surfaceId,
+ railingMode: 'none' as const,
+ }
+ : movingNode.type === 'stair' && movingNode.landscapeSurfaceId
+ ? { landscapeSurfaceId: undefined }
+ : {}),
}) ?? committedId
} else {
// The store still holds the original values (we didn't update during drag).
@@ -447,13 +525,49 @@ export const MoveRoofTool: React.FC<{
scene.applyNodeChanges(
seatRoofs(scene.nodes, { update: [{ id: movingNode.id, data: patch }] }).changes,
)
- } else useScene.getState().updateNode(movingNode.id, patch)
+ } else {
+ useScene.getState().updateNodes([
+ {
+ id: movingNode.id,
+ data: {
+ ...(landscapeSnap
+ ? {
+ totalRise: landscapeSnap.totalRise,
+ stepCount: landscapeSnap.stepCount,
+ slabOpeningMode: 'none' as const,
+ landscapeSurfaceId: landscapeSnap.surfaceId,
+ railingMode: 'none' as const,
+ }
+ : movingNode.type === 'stair' && movingNode.landscapeSurfaceId
+ ? { landscapeSurfaceId: undefined }
+ : {}),
+ position,
+ rotation: pendingRotation,
+ metadata: committedMeta,
+ ...supportPatch,
+ },
+ },
+ ...(landscapeSnap && flightId
+ ? [
+ {
+ id: flightId,
+ data: {
+ height: landscapeSnap.totalRise,
+ length: landscapeSnap.length,
+ stepCount: landscapeSnap.stepCount,
+ },
+ },
+ ]
+ : []),
+ ])
+ }
useScene.temporal.getState().pause()
}
triggerSFX('sfx:item-place')
selectPlacedNode(committedId)
clearHostedPreview()
+ clearLandscapePreview()
useLiveTransforms.getState().clear(movingNode.id)
useEditor.getState().setMovingNodeOrigin('3d')
exitMoveMode()
@@ -468,6 +582,7 @@ export const MoveRoofTool: React.FC<{
const onCancel = () => {
wasCancelled = true
clearHostedPreview()
+ clearLandscapePreview()
useLiveTransforms.getState().clear(movingNode.id)
useAlignmentGuides.getState().clear()
if (isNew) {
@@ -496,7 +611,16 @@ export const MoveRoofTool: React.FC<{
triggerSFX('sfx:item-rotate')
pendingRotation += rotationDelta
+ freeRotation += rotationDelta
setPreviewRotation(resolvePreviewRotationY(movingNode, pendingRotation))
+ if (movingNode.type === 'stair')
+ publishStairMovePreview(
+ movingNode.id,
+ flightId,
+ lastLocalPosition,
+ pendingRotation,
+ landscapeSnap,
+ )
// Directly update the Three.js mesh — no store update during drag
const mesh = sceneRegistry.nodes.get(movingNode.id)
@@ -535,6 +659,7 @@ export const MoveRoofTool: React.FC<{
// Clear ephemeral live transform + any alignment guides
clearHostedPreview()
+ clearLandscapePreview()
useLiveTransforms.getState().clear(movingNode.id)
useAlignmentGuides.getState().clear()
@@ -547,7 +672,12 @@ export const MoveRoofTool: React.FC<{
// position back to the snapshot.
const finalisedBy2D = useEditor.getState().movingNodeOrigin === '2d'
- if (!(wasCommitted || wasCancelled || isNew || finalisedBy2D)) {
+ // 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,
diff --git a/packages/nodes/src/stair/floorplan-move.test.ts b/packages/nodes/src/stair/floorplan-move.test.ts
new file mode 100644
index 0000000000..ca27213723
--- /dev/null
+++ b/packages/nodes/src/stair/floorplan-move.test.ts
@@ -0,0 +1,116 @@
+import { afterEach, beforeEach, expect, test } from 'bun:test'
+import {
+ type AnyNode,
+ clearSceneHistory,
+ createSceneApi,
+ getEffectiveNode,
+ LevelNode,
+ StairNode,
+ StairSegmentNode,
+ useLiveNodeOverrides,
+ useScene,
+} from '@pascal-app/core'
+import { commitFreshPlacementSubtree, useEditor } from '@pascal-app/editor'
+import { stairDefinition } from './definition'
+
+const originalRaf = globalThis.requestAnimationFrame
+const originalCancelRaf = globalThis.cancelAnimationFrame
+beforeEach(() => {
+ globalThis.requestAnimationFrame = () => 0
+ globalThis.cancelAnimationFrame = () => {}
+})
+
+const initialScene = useScene.getState()
+const initialEditor = useEditor.getState()
+const modifiers = { altKey: false, shiftKey: false, ctrlKey: false, metaKey: false }
+
+afterEach(() => {
+ globalThis.requestAnimationFrame = originalRaf
+ globalThis.cancelAnimationFrame = originalCancelRaf
+ useLiveNodeOverrides.getState().clearAll()
+ useScene.setState(initialScene, true)
+ useEditor.setState(initialEditor, true)
+ clearSceneHistory()
+})
+
+function setup() {
+ const level = LevelNode.parse({ id: 'level_landscape_move' })
+ const flight = StairSegmentNode.parse({
+ id: 'sseg_landscape_move',
+ parentId: 'stair_landscape_move',
+ })
+ const stair = StairNode.parse({
+ id: 'stair_landscape_move',
+ parentId: level.id,
+ children: [flight.id],
+ position: [0, 0, -2],
+ })
+ const deck = {
+ id: 'deck_landscape_move',
+ type: 'landscape:deck',
+ parentId: level.id,
+ position: [0, 0, 0],
+ rotation: [0, 0, 0],
+ width: 4,
+ depth: 3,
+ thickness: 0.8,
+ shape: 'rectangle',
+ } as unknown as AnyNode
+ const nodes = { [level.id]: level, [stair.id]: stair, [flight.id]: flight, [deck.id]: deck }
+ useScene.setState({ nodes, rootNodeIds: [level.id], dirtyNodes: new Set() })
+ useScene.temporal.getState().resume()
+ clearSceneHistory()
+ const session = stairDefinition.floorplanMoveTarget!({
+ node: stair,
+ nodes,
+ sceneApi: createSceneApi(useScene),
+ })
+ return { stair, flight, deck, session }
+}
+
+test('2D landscape attachment previews without scene writes and commits the flight in one undo', () => {
+ const { stair, flight, deck, session } = setup()
+ const before = useScene.getState().nodes
+ session.apply({ planPoint: [0, -2], modifiers })
+ const previewStair = getEffectiveNode(stair)
+ const previewFlight = getEffectiveNode(flight)
+ expect(useScene.getState().nodes).toBe(before)
+ expect(previewStair.landscapeSurfaceId).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[flight.id]).toMatchObject({
+ height: previewFlight.height,
+ length: previewFlight.length,
+ })
+ useScene.temporal.getState().undo()
+ expect(useScene.getState().nodes[stair.id]).toEqual(stair)
+ expect(useScene.getState().nodes[flight.id]).toEqual(flight)
+})
+
+test('Alt 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()
+ session.apply({ planPoint: [0.13, -2.17], modifiers: { ...modifiers, altKey: true } })
+ expect(getEffectiveNode(stair).landscapeSurfaceId).toBeUndefined()
+ expect(getEffectiveNode(flight)).toEqual(flight)
+ session.commit!()
+ expect(useScene.getState().nodes[stair.id]).toMatchObject({ position: [0.13, 0, -2.17] })
+})
+
+test('fresh 2D stair replacement keeps the snapped flight preview', () => {
+ const { stair, flight, deck, session } = setup()
+ useScene.getState().updateNode(stair.id, { metadata: { isNew: true } })
+ session.apply({ planPoint: [0, -2], modifiers })
+ const expected = getEffectiveNode(flight)
+ 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(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 7b240ca4e9..ed34539334 100644
--- a/packages/nodes/src/stair/floorplan-move.ts
+++ b/packages/nodes/src/stair/floorplan-move.ts
@@ -3,8 +3,10 @@ import {
collectAlignmentAnchors,
type FloorplanMoveTarget,
type FloorplanMoveTargetSession,
+ getFloorStackedPosition,
movingAlignmentAnchors,
type StairNode,
+ type StairSegmentNode,
snapScalar,
useLiveNodeOverrides,
useScene,
@@ -14,6 +16,8 @@ import {
getSegmentGridStep,
isGridSnapActive,
isMagneticSnapActive,
+ type LandscapeStairSnap,
+ resolveLandscapeStairSnap,
} from '@pascal-app/editor'
import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
@@ -37,12 +41,19 @@ export const stairFloorplanMoveTarget: FloorplanMoveTarget = ({ node,
})
// Alignment candidates gathered once — the scene is stable during the drag.
const candidates = collectAlignmentAnchors(nodes, node.id)
- let lastValid: { position: [number, number, number] } | null = null
+ let lastValid: { position: [number, number, number]; rotation?: number } | null = null
+ const flight = node.children
+ .map((id) => nodes[id])
+ .find(
+ (child): child is StairSegmentNode =>
+ child?.type === 'stair-segment' && child.segmentType === 'stair',
+ )
+ let landscapeSnap: LandscapeStairSnap | null = null
const session: FloorplanMoveTargetSession = {
- affectedIds: [node.id as AnyNodeId],
- apply({ planPoint }) {
- const step = isGridSnapActive() ? getSegmentGridStep() : 0
+ affectedIds: flight ? [node.id as AnyNodeId, flight.id as AnyNodeId] : [node.id as AnyNodeId],
+ apply({ planPoint, modifiers }) {
+ const step = !modifiers.altKey && isGridSnapActive() ? getSegmentGridStep() : 0
const snap = (value: number) => (step > 0 ? snapScalar(value, step) : value)
const [gx, gz] = resolveCursor(planPoint, { snap })
// Figma alignment on the actual stair footprint, matching the 3D move
@@ -54,15 +65,60 @@ export const stairFloorplanMoveTarget: FloorplanMoveTarget = ({ node,
? movingAnchors
: [{ nodeId: node.id, kind: 'corner', x: gx, z: gz }],
candidates,
- { applySnap: isMagneticSnapActive() },
+ { applySnap: !modifiers.altKey && isMagneticSnapActive() },
)
const sx = aligned[0]
const sz = aligned[1]
- if (lastValid && lastValid.position[0] === sx && lastValid.position[2] === sz) return
- lastValid = { position: [sx, startY, sz] }
- useLiveNodeOverrides.getState().set(node.id as AnyNodeId, lastValid)
+ const sceneNodes = useScene.getState().nodes
+ const baseElevation = getFloorStackedPosition({
+ node,
+ nodes: sceneNodes,
+ position: [sx, startY, sz],
+ rotation: node.rotation,
+ levelId: node.parentId,
+ })[1]
+ landscapeSnap = modifiers.altKey
+ ? null
+ : resolveLandscapeStairSnap(
+ node,
+ sceneNodes,
+ [sx, startY, sz],
+ flight?.length ?? 3,
+ baseElevation,
+ )
+ const position = landscapeSnap?.position ?? ([sx, startY, sz] as [number, number, number])
+ if (
+ lastValid &&
+ lastValid.position[0] === position[0] &&
+ lastValid.position[2] === position[2] &&
+ lastValid.rotation === landscapeSnap?.rotation
+ )
+ return
+ lastValid = { position, ...(landscapeSnap ? { rotation: landscapeSnap.rotation } : {}) }
+ if (!landscapeSnap) useLiveNodeOverrides.getState().clear(node.id as AnyNodeId)
+ useLiveNodeOverrides.getState().set(node.id as AnyNodeId, {
+ ...lastValid,
+ ...(landscapeSnap
+ ? {
+ totalRise: landscapeSnap.totalRise,
+ stepCount: landscapeSnap.stepCount,
+ landscapeSurfaceId: landscapeSnap.surfaceId,
+ railingMode: 'none' as const,
+ }
+ : { landscapeSurfaceId: undefined }),
+ })
+ if (flight) {
+ if (landscapeSnap)
+ useLiveNodeOverrides.getState().set(flight.id as AnyNodeId, {
+ height: landscapeSnap.totalRise,
+ length: landscapeSnap.length,
+ stepCount: landscapeSnap.stepCount,
+ })
+ else useLiveNodeOverrides.getState().clear(flight.id as AnyNodeId)
+ }
useScene.getState().markDirty(node.id as AnyNodeId)
+ if (flight) useScene.getState().markDirty(flight.id as AnyNodeId)
},
canCommit() {
// No overlap / placement rules for stairs in 2D — any pointer-up
@@ -76,7 +132,38 @@ export const stairFloorplanMoveTarget: FloorplanMoveTarget = ({ node,
// door / window use.
if (!lastValid) return
useLiveNodeOverrides.getState().clear(node.id as AnyNodeId)
- useScene.getState().updateNodes([{ id: node.id as AnyNodeId, data: lastValid }])
+ if (flight) useLiveNodeOverrides.getState().clear(flight.id as AnyNodeId)
+ useScene.getState().updateNodes([
+ {
+ id: node.id as AnyNodeId,
+ data: {
+ ...lastValid,
+ ...(landscapeSnap
+ ? {
+ totalRise: landscapeSnap.totalRise,
+ stepCount: landscapeSnap.stepCount,
+ slabOpeningMode: 'none' as const,
+ landscapeSurfaceId: landscapeSnap.surfaceId,
+ railingMode: 'none' as const,
+ }
+ : node.landscapeSurfaceId
+ ? { landscapeSurfaceId: undefined }
+ : {}),
+ },
+ },
+ ...(landscapeSnap && flight
+ ? [
+ {
+ id: flight.id as AnyNodeId,
+ data: {
+ height: landscapeSnap.totalRise,
+ length: landscapeSnap.length,
+ stepCount: landscapeSnap.stepCount,
+ },
+ },
+ ]
+ : []),
+ ])
},
}
diff --git a/packages/nodes/src/stair/floorplan.test.ts b/packages/nodes/src/stair/floorplan.test.ts
index b1149e5dd9..96112c79e3 100644
--- a/packages/nodes/src/stair/floorplan.test.ts
+++ b/packages/nodes/src/stair/floorplan.test.ts
@@ -85,6 +85,66 @@ describe('buildStairFloorplan documentation', () => {
expect(geometry.children.some((child) => child.kind === 'polygon')).toBe(true)
expect(geometry.children.some((child) => 'strokeDasharray' in child)).toBe(false)
})
+ test('uses live segment dimensions during a magnetic move', () => {
+ const segment = StairSegmentNode.parse({
+ id: 'sseg_live',
+ width: 1,
+ length: 3,
+ height: 2.5,
+ stepCount: 10,
+ })
+ const preview = { ...segment, length: 0.84, height: 0.48, stepCount: 3 }
+ const stair = StairNode.parse({
+ id: 'stair_live',
+ parentId: 'level_ground',
+ children: [segment.id],
+ totalRise: 0.48,
+ stepCount: 3,
+ })
+ const geometry = buildStairFloorplan(stair, {
+ resolve: (id) => (id === segment.id ? (preview as never) : undefined),
+ children: [segment],
+ siblings: [],
+ parent: LevelNode.parse({ id: 'level_ground' }),
+ } satisfies GeometryContext)
+ expect(textValues(geometry)).toContain('3 R @ 0.16m · T 0.28m · CLR W 1m')
+ })
+
+ test('draws a surface child at the same level position as its unparented stair', () => {
+ const segment = StairSegmentNode.parse({
+ id: 'sseg_attached',
+ length: 1,
+ height: 0.4,
+ stepCount: 2,
+ })
+ const level = LevelNode.parse({ id: 'level_ground' })
+ const world = StairNode.parse({
+ id: 'stair_attached',
+ parentId: level.id,
+ position: [5, 0, 2],
+ rotation: Math.PI / 2,
+ children: [segment.id],
+ })
+ const surface = {
+ ...level,
+ id: 'patio_1',
+ type: 'landscape:patio',
+ parentId: level.id,
+ position: [4, 0, 3],
+ rotation: [0, Math.PI / 2, 0],
+ }
+ const local = StairNode.parse({
+ ...world,
+ parentId: surface.id,
+ landscapeSurfaceId: surface.id,
+ position: [1, 0, 1],
+ rotation: 0,
+ })
+ const context = { resolve: () => undefined, children: [segment], siblings: [] }
+ const expected = buildStairFloorplan(world, { ...context, parent: level } as GeometryContext)
+ const actual = buildStairFloorplan(local, { ...context, parent: surface } as GeometryContext)
+ expect(actual).toEqual(expected)
+ })
})
test('arc plans use the rendered two-step layout and preserve signed multi-turn arrival', () => {
diff --git a/packages/nodes/src/stair/floorplan.ts b/packages/nodes/src/stair/floorplan.ts
index a8a5f30620..9a73b4af3d 100644
--- a/packages/nodes/src/stair/floorplan.ts
+++ b/packages/nodes/src/stair/floorplan.ts
@@ -39,9 +39,10 @@ import { buildFloorplanStairEntry } from './plan-entry'
/** The stair parent emits its complete cumulative segment chain. */
export function buildStairFloorplan(
- stair: StairNode,
+ rawStair: StairNode,
ctx: GeometryContext,
): FloorplanGeometry | null {
+ const stair = rawStair
const segments = (ctx.children ?? []).filter(
(child): child is StairSegmentNode => child.type === 'stair-segment',
)
diff --git a/packages/nodes/src/stair/landscape-move-preview.test.ts b/packages/nodes/src/stair/landscape-move-preview.test.ts
new file mode 100644
index 0000000000..0b3e18b089
--- /dev/null
+++ b/packages/nodes/src/stair/landscape-move-preview.test.ts
@@ -0,0 +1,56 @@
+import { afterEach, expect, test } from 'bun:test'
+import {
+ getEffectiveNode,
+ StairNode,
+ StairSegmentNode,
+ useLiveNodeOverrides,
+} from '@pascal-app/core'
+import { clearStairMovePreview, publishStairMovePreview } from './landscape-move-preview'
+
+const flight = StairSegmentNode.parse({
+ id: 'sseg_preview',
+ height: 0.35,
+ length: 0.84,
+ stepCount: 2,
+})
+const stair = StairNode.parse({
+ id: 'stair_preview',
+ children: [flight.id],
+ position: [0, 0, -2.34],
+ totalRise: 0.35,
+ stepCount: 2,
+ railingMode: 'both',
+})
+
+afterEach(() => useLiveNodeOverrides.getState().clearAll())
+
+test('the visible stair model receives live pose and flight geometry during a snap', () => {
+ const position: [number, number, number] = [1, 0, -3]
+ publishStairMovePreview(stair.id, flight.id, position, Math.PI / 2, {
+ position,
+ rotation: Math.PI / 2,
+ totalRise: 1.2,
+ stepCount: 8,
+ length: 2.24,
+ surfaceId: 'deck_1',
+ })
+
+ const visibleStair = getEffectiveNode(stair)
+ const visibleFlight = getEffectiveNode(flight)
+ expect(visibleStair.position).toEqual(position)
+ 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(visibleFlight.height).toBe(1.2)
+ expect(visibleFlight.length).toBe(2.24)
+ expect(visibleFlight.stepCount).toBe(8)
+
+ 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(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/landscape-move-preview.ts
new file mode 100644
index 0000000000..f9f311bac2
--- /dev/null
+++ b/packages/nodes/src/stair/landscape-move-preview.ts
@@ -0,0 +1,58 @@
+import { type AnyNodeId, useLiveNodeOverrides, useScene } from '@pascal-app/core'
+import type { LandscapeStairSnap } from '@pascal-app/editor'
+
+export function publishStairMovePreview(
+ stairId: AnyNodeId,
+ flightId: AnyNodeId | undefined,
+ position: [number, number, number],
+ rotation: number,
+ snap: LandscapeStairSnap | null,
+) {
+ const overrides = useLiveNodeOverrides.getState()
+ if (snap) {
+ overrides.setMany([
+ [
+ stairId,
+ {
+ position,
+ rotation,
+ totalRise: snap.totalRise,
+ stepCount: snap.stepCount,
+ landscapeSurfaceId: snap.surfaceId,
+ railingMode: 'none',
+ },
+ ],
+ ...(flightId
+ ? [
+ [
+ flightId,
+ { height: snap.totalRise, length: snap.length, stepCount: snap.stepCount },
+ ] as const,
+ ]
+ : []),
+ ])
+ useScene.getState().markDirty(stairId)
+ if (flightId) useScene.getState().markDirty(flightId)
+ return
+ }
+
+ const hadSnap =
+ overrides.get(stairId)?.totalRise !== undefined ||
+ (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 })
+ if (hadSnap) {
+ useScene.getState().markDirty(stairId)
+ if (flightId) useScene.getState().markDirty(flightId)
+ }
+}
+
+export function clearStairMovePreview(stairId: AnyNodeId, flightId: AnyNodeId | undefined) {
+ const overrides = useLiveNodeOverrides.getState()
+ if (!overrides.get(stairId) && (!flightId || !overrides.get(flightId))) return
+ overrides.clear(stairId)
+ if (flightId) overrides.clear(flightId)
+ useScene.getState().markDirty(stairId)
+ if (flightId) useScene.getState().markDirty(flightId)
+}
diff --git a/packages/nodes/src/stair/landscape-transition.test.ts b/packages/nodes/src/stair/landscape-transition.test.ts
new file mode 100644
index 0000000000..f5bbcb2dfc
--- /dev/null
+++ b/packages/nodes/src/stair/landscape-transition.test.ts
@@ -0,0 +1,70 @@
+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/landscape-transition.ts b/packages/nodes/src/stair/landscape-transition.ts
new file mode 100644
index 0000000000..5620487e2b
--- /dev/null
+++ b/packages/nodes/src/stair/landscape-transition.ts
@@ -0,0 +1,86 @@
+import 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(
+ stair: StairNode,
+ surface: LandscapeSurface,
+ 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
+ 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],
+ ]
+ : 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
+ return [dx * cc - dz * cs, dx * cs + dz * cc]
+ })
+
+ const halfWidth = stair.width / 2
+ const samples: Point[] = []
+ const sampleXs = [
+ -halfWidth,
+ 0,
+ halfWidth,
+ ...outline.map(([x]) => x).filter((x) => x > -halfWidth && x < halfWidth),
+ ]
+ .sort((a, b) => a - b)
+ .filter((x, index, values) => index === 0 || x - values[index - 1]! > 1e-6)
+ for (const x of sampleXs) {
+ const crossings: number[] = []
+ for (let edge = 0; edge < outline.length; edge++) {
+ const a = outline[edge]!,
+ b = outline[(edge + 1) % outline.length]!
+ const dx = b[0] - a[0]
+ if (Math.abs(dx) < 1e-8) continue
+ const t = (x - a[0]) / dx
+ if (t >= 0 && t <= 1) crossings.push(a[1] + (b[1] - a[1]) * t)
+ }
+ const boundary = crossings.sort((a, b) => Math.abs(a) - Math.abs(b))[0]
+ if (boundary === undefined || boundary < -0.12 || boundary > 0.7) return null
+ samples.push([x, boundary])
+ }
+ if (samples.every(([, z]) => Math.abs(z) < 0.015)) return null
+ return { frontZ: 0, samples }
+}
diff --git a/packages/nodes/src/stair/panel.tsx b/packages/nodes/src/stair/panel.tsx
index 2b010e5042..8d1cc32a27 100644
--- a/packages/nodes/src/stair/panel.tsx
+++ b/packages/nodes/src/stair/panel.tsx
@@ -576,6 +576,25 @@ export default function StairPanel() {
onClick={() => applySizing(true)}
/>
+ {node.stairType === 'straight' &&
+ (landscapeSurfaces.length > 0 || node.landscapeSurfaceId) && (
+
+
+
handleUpdate({ autoLandscapeSnap: checked })}
+ />
+
+ {node.autoLandscapeSnap !== false
+ ? connectedSurface
+ ? `Connected to ${connectedSurface.name || 'landscape 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 6bfe9b787d..847f31a784 100644
--- a/packages/nodes/src/stair/renderer.tsx
+++ b/packages/nodes/src/stair/renderer.tsx
@@ -34,6 +34,7 @@ import { balusterGuardRails, buildBalusterGuard } from './baluster-guard'
import { boardsGuardRails, buildBoardsGuard } from './boards-guard'
import { buildCableGuard, cableGuardRails } from './cable-guard'
import { ContinuousStairRailings } from './continuous-railings'
+import { landscapeTransitionProfile } from './landscape-transition'
import {
barBox,
GUARD_PICKET_PITCH,
@@ -174,8 +175,7 @@ export const StairRenderer = ({ node: rawNode }: { node: StairNode }) => {
return (
{
// Clear preset-seeded defaults on deactivation so a later manual wall draw
// isn't built with a stale preset's parameters. Unmount-only.
- useEffect(() => () => useEditor.getState().setToolDefaults('wall', null), [])
+ useEffect(
+ () => () => {
+ const editor = useEditor.getState()
+ // Remounts keep the active preset; leaving the wall tool releases it.
+ if (editor.tool !== 'wall') editor.setToolDefaults('wall', null)
+ },
+ [],
+ )
// The drawing variant is picked in the Build panel's Rooms group.
const variant = useWallDrawVariant()
diff --git a/packages/viewer/src/lib/merged-outline-node.ts b/packages/viewer/src/lib/merged-outline-node.ts
index cf57382e78..89abbfe947 100644
--- a/packages/viewer/src/lib/merged-outline-node.ts
+++ b/packages/viewer/src/lib/merged-outline-node.ts
@@ -180,6 +180,10 @@ export class MergedOutlineNode extends TempNode {
private readonly _proxiesA = new Map()
private readonly _proxiesB = new Map()
private readonly _proxyMaskMaterials = new WeakMap()
+ private readonly _cutoutMasksA = new Map()
+ private readonly _cutoutMasksB = new Map()
+ private readonly _usedCutoutMasksA = new Set()
+ private readonly _usedCutoutMasksB = new Set()
private readonly _maskSceneA = new Scene()
private readonly _maskSceneB = new Scene()
@@ -370,8 +374,21 @@ export class MergedOutlineNode extends TempNode {
this._buildCache(this.secondaryObjects, this._cacheB)
const useProxiesA = this._syncProxies(this._cacheA, this._proxiesA, this._maskSceneA)
const useProxiesB = this._syncProxies(this._cacheB, this._proxiesB, this._maskSceneB)
+ this._usedCutoutMasksA.clear()
+ this._usedCutoutMasksB.clear()
+ for (const [cache, used] of [
+ [this._cacheA, this._usedCutoutMasksA],
+ [this._cacheB, this._usedCutoutMasksB],
+ ] as const) {
+ for (const object of cache) {
+ if (!(object instanceof Mesh)) continue
+ const materials = Array.isArray(object.material) ? object.material : [object.material]
+ for (const source of materials) if (source?.alphaTest > 0) used.add(source)
+ }
+ }
if (!hasAny) {
+ this._pruneCutoutMasks()
RendererUtils.restoreRendererAndSceneState(renderer, scene, _rendererState)
return
}
@@ -408,6 +425,7 @@ export class MergedOutlineNode extends TempNode {
if (hasPrimary) this._runEdgePipeline(renderer, 'A')
if (hasSecondary) this._runEdgePipeline(renderer, 'B')
+ this._pruneCutoutMasks()
RendererUtils.restoreRendererAndSceneState(renderer, scene, _rendererState)
}
@@ -418,6 +436,8 @@ export class MergedOutlineNode extends TempNode {
const maskScene = isA ? this._maskSceneA : this._maskSceneB
const material = isA ? this._prepareMaskMatA : this._prepareMaskMatB
const spriteMaterial = isA ? this._prepareMaskSpriteMatA : this._prepareMaskSpriteMatB
+ const cutoutMasks = isA ? this._cutoutMasksA : this._cutoutMasksB
+ const usedCutoutMasks = isA ? this._usedCutoutMasksA : this._usedCutoutMasksB
if (useProxies) {
for (const [source, proxy] of proxies) proxy.matrixWorld.copy(source.matrixWorld)
}
@@ -425,10 +445,48 @@ export class MergedOutlineNode extends TempNode {
// Keep source materials on proxies for material visibility and geometry
// groups; substitute only at submission, just like the full-scene path.
renderer.setRenderObjectFunction(
- (obj: any, sc: any, cam: any, geo: any, _mat: any, grp: any, lights: any, clip: any) => {
+ (
+ obj: any,
+ sc: any,
+ cam: any,
+ geo: any,
+ sourceMaterial: any,
+ grp: any,
+ lights: any,
+ clip: any,
+ ) => {
if (!hasDrawableGeometry(geo)) return
if (useProxies || cache.has(obj)) {
let maskMaterial = obj.isSprite ? spriteMaterial : material
+ if (!obj.isSprite && sourceMaterial?.alphaTest > 0) {
+ let cutoutMask = cutoutMasks.get(sourceMaterial)
+ const opacityNode =
+ sourceMaterial.opacityNode ??
+ (sourceMaterial.map
+ ? cutoutMask?.userData.sourceMap === sourceMaterial.map
+ ? cutoutMask.opacityNode
+ : texture(sourceMaterial.map).a
+ : null)
+ if (opacityNode) {
+ if (!cutoutMask) {
+ cutoutMask = material.clone()
+ cutoutMasks.set(sourceMaterial, cutoutMask)
+ }
+ const positionNode = sourceMaterial.positionNode ?? null
+ const changed =
+ cutoutMask.opacityNode !== opacityNode ||
+ cutoutMask.positionNode !== positionNode ||
+ cutoutMask.side !== sourceMaterial.side
+ cutoutMask.opacityNode = opacityNode
+ cutoutMask.positionNode = positionNode
+ cutoutMask.alphaTest = sourceMaterial.alphaTest
+ cutoutMask.side = sourceMaterial.side
+ cutoutMask.userData.sourceMap = sourceMaterial.map
+ if (changed) cutoutMask.needsUpdate = true
+ maskMaterial = cutoutMask
+ usedCutoutMasks.add(sourceMaterial)
+ }
+ }
if (useProxies) {
let ownedMaterial = this._proxyMaskMaterials.get(obj)
if (!ownedMaterial) {
@@ -437,8 +495,18 @@ export class MergedOutlineNode extends TempNode {
ownedMaterial = maskMaterial.clone()
this._proxyMaskMaterials.set(obj, ownedMaterial)
}
- if (ownedMaterial.colorNode !== maskMaterial.colorNode) {
+ if (
+ ownedMaterial.colorNode !== maskMaterial.colorNode ||
+ ownedMaterial.opacityNode !== maskMaterial.opacityNode ||
+ ownedMaterial.positionNode !== maskMaterial.positionNode ||
+ ownedMaterial.alphaTest !== maskMaterial.alphaTest ||
+ ownedMaterial.side !== maskMaterial.side
+ ) {
ownedMaterial.colorNode = maskMaterial.colorNode
+ ownedMaterial.opacityNode = maskMaterial.opacityNode
+ ownedMaterial.positionNode = maskMaterial.positionNode
+ ownedMaterial.alphaTest = maskMaterial.alphaTest
+ ownedMaterial.side = maskMaterial.side
ownedMaterial.needsUpdate = true
}
maskMaterial = ownedMaterial
@@ -525,6 +593,19 @@ export class MergedOutlineNode extends TempNode {
this._proxyMaskMaterials.delete(proxy)
}
+ private _pruneCutoutMasks() {
+ for (const [materials, used] of [
+ [this._cutoutMasksA, this._usedCutoutMasksA],
+ [this._cutoutMasksB, this._usedCutoutMasksB],
+ ] as const) {
+ for (const [source, mask] of materials) {
+ if (used.has(source)) continue
+ mask.dispose()
+ materials.delete(source)
+ }
+ }
+ }
+
private _runEdgePipeline(renderer: any, group: 'A' | 'B') {
const isA = group === 'A'
const g = isA ? this._groupA : this._groupB
@@ -759,6 +840,10 @@ export class MergedOutlineNode extends TempNode {
this._proxiesB.clear()
this._cacheA.clear()
this._cacheB.clear()
+ for (const mask of this._cutoutMasksA.values()) mask.dispose()
+ for (const mask of this._cutoutMasksB.values()) mask.dispose()
+ this._cutoutMasksA.clear()
+ this._cutoutMasksB.clear()
this._depthRT.dispose()
this._groupA.dispose()
this._groupB.dispose()
diff --git a/packages/viewer/src/systems/stair/stair-system.test.tsx b/packages/viewer/src/systems/stair/stair-system.test.tsx
new file mode 100644
index 0000000000..da59203e4d
--- /dev/null
+++ b/packages/viewer/src/systems/stair/stair-system.test.tsx
@@ -0,0 +1,64 @@
+import { afterEach, expect, test } from 'bun:test'
+import {
+ StairNode,
+ StairSegmentNode,
+ sceneRegistry,
+ useLiveNodeOverrides,
+ useScene,
+} from '@pascal-app/core'
+import ReactThreeTestRenderer from '@react-three/test-renderer'
+import { BufferGeometry, Group, Mesh } from 'three'
+import { StairSystem } from './stair-system'
+
+const initialScene = useScene.getState()
+
+afterEach(() => {
+ useLiveNodeOverrides.getState().clearAll()
+ sceneRegistry.clear()
+ useScene.setState({
+ nodes: initialScene.nodes,
+ rootNodeIds: initialScene.rootNodeIds,
+ dirtyNodes: initialScene.dirtyNodes,
+ })
+})
+
+test('rebuilds the visible stair body from a live snapped flight', async () => {
+ const segment = StairSegmentNode.parse({
+ id: 'sseg_live_body',
+ parentId: 'stair_live_body',
+ height: 0.35,
+ length: 0.84,
+ stepCount: 2,
+ })
+ const stair = StairNode.parse({
+ id: 'stair_live_body',
+ children: [segment.id],
+ totalRise: 0.35,
+ stepCount: 2,
+ })
+ const group = new Group()
+ const body = new Mesh(new BufferGeometry())
+ body.name = 'merged-stair'
+ group.add(body)
+ sceneRegistry.nodes.set(stair.id, group)
+ sceneRegistry.nodes.set(segment.id, new Mesh(new BufferGeometry()))
+ useScene.setState({
+ nodes: { [stair.id]: stair, [segment.id]: segment },
+ rootNodeIds: [stair.id],
+ dirtyNodes: new Set([stair.id, segment.id]),
+ } as never)
+
+ const renderer = await ReactThreeTestRenderer.create( )
+ await renderer.advanceFrames(1, 1 / 60)
+ body.geometry.computeBoundingBox()
+ const original = body.geometry.boundingBox!.clone()
+
+ useLiveNodeOverrides.getState().set(segment.id, { height: 1.2, length: 2.24, stepCount: 8 })
+ useScene.getState().markDirty(segment.id)
+ useScene.getState().markDirty(stair.id)
+ await renderer.advanceFrames(1, 1 / 60)
+ body.geometry.computeBoundingBox()
+ expect(body.geometry.boundingBox!.max.y).toBeGreaterThan(original.max.y + 0.8)
+ expect(body.geometry.boundingBox!.max.z).toBeGreaterThan(original.max.z + 1)
+ await renderer.unmount()
+})
From ebfce89b88afb8a4d1777a12a3f16f183ffa5b38 Mon Sep 17 00:00:00 2001
From: sudhir
Date: Tue, 6 Oct 2026 15:04:14 +0530
Subject: [PATCH 03/31] fix(editor): address plugin integration architecture
review
---
apps/editor/package.json | 11 +-
bun.lock | 24 ++-
package.json | 2 +-
.../core/src/contracts/reference-inventory.ts | 5 +-
.../src/lib/surface-support-references.ts | 27 ++++
packages/core/src/registry/scene-api.ts | 5 +
packages/core/src/registry/subtree.ts | 2 +
packages/core/src/registry/types.ts | 9 +-
packages/core/src/services/index.ts | 5 +
.../src/services/stair-surface-snap.test.ts} | 93 +++++++++--
.../core/src/services/stair-surface-snap.ts | 111 +++++++++++++
.../core/src/store/actions/node-actions.ts | 9 ++
packages/core/src/utils/clone-scene-graph.ts | 2 +
.../components/tools/stair/landscape-snap.ts | 146 ------------------
.../src/components/tools/stair/stair-tool.tsx | 23 ++-
.../components/ui/panels/panel-wrapper.tsx | 8 +-
packages/editor/src/index.tsx | 3 +-
packages/nodes/src/fence/definition.ts | 1 +
packages/nodes/src/fence/drafting.ts | 19 ++-
packages/nodes/src/fence/tool.tsx | 24 ++-
packages/nodes/src/shared/move-roof-tool.tsx | 15 +-
packages/nodes/src/stair/definition.ts | 1 +
.../nodes/src/stair/floorplan-move.test.ts | 32 ++++
packages/nodes/src/stair/floorplan-move.ts | 101 ++++++------
.../nodes/src/stair/landscape-move-preview.ts | 10 +-
packages/nodes/src/stair/panel.tsx | 12 ++
scripts/link-local-plugins.ts | 46 ++++++
27 files changed, 471 insertions(+), 275 deletions(-)
create mode 100644 packages/core/src/lib/surface-support-references.ts
rename packages/{editor/src/components/tools/stair/landscape-snap.test.ts => core/src/services/stair-surface-snap.test.ts} (53%)
create mode 100644 packages/core/src/services/stair-surface-snap.ts
delete mode 100644 packages/editor/src/components/tools/stair/landscape-snap.ts
create mode 100644 scripts/link-local-plugins.ts
diff --git a/apps/editor/package.json b/apps/editor/package.json
index bf8a86254c..b78443b96b 100644
--- a/apps/editor/package.json
+++ b/apps/editor/package.json
@@ -21,11 +21,10 @@
"@pascal-app/nodes": "*",
"@pascal-app/plugin-bones": "github:pascalorg/plugin-bones#5679260261ee1c733656ff6dfb99e30bb24b58a7",
"@pascal-app/plugin-environment": "github:AxiomeCG/environment#1aa0bc3cbed273dbf751e9af79e446f6de105c90",
- "@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-bath-space": "file:/Users/sudhir/Desktop/work/pascal-cluster/packages/bath-space",
+ "@pascal-app/plugin-landscape": "file:/Users/sudhir/Desktop/work/pascal-cluster/packages/landscape",
+ "@pascal-app/plugin-pool": "file:/Users/sudhir/Desktop/work/pascal-cluster/packages/pool",
+ "@pascal-app/plugin-streetscape-lab": "file:/Users/sudhir/Desktop/work/pascal-cluster/packages/streetscape",
"@pascal-app/plugin-trees": "github:pascalorg/plugin-trees#56d978cd9b409b716207b3f3d269455d3cd6f067",
"@pascal-app/viewer": "*",
"@radix-ui/react-tooltip": "^1.4.1",
@@ -33,7 +32,7 @@
"@react-three/fiber": "^9.8.1",
"@react-three/xr": "^6.6.30",
"@tailwindcss/postcss": "^4.2.1",
- "@webxr/plugin": "github:sudhir9297/pascal-cluster#5e980bebba12c3459d562f3b0e8bf5c67e42c228",
+ "@webxr/plugin": "file:/Users/sudhir/Desktop/work/pascal-cluster/packages/webxr",
"clsx": "^2.1.1",
"geist": "^1.7.2",
"lucide-react": "^1.57.0",
diff --git a/bun.lock b/bun.lock
index a04c56b756..5fd753eb0b 100644
--- a/bun.lock
+++ b/bun.lock
@@ -36,13 +36,12 @@
"@pascal-app/editor": "*",
"@pascal-app/mcp": "*",
"@pascal-app/nodes": "*",
- "@pascal-app/plugin-bath-space": "github:sudhir9297/pascal-cluster#8f3c0db2be7a33b19f406ecb35e23417ff850dcc",
+ "@pascal-app/plugin-bath-space": "file:/Users/sudhir/Desktop/work/pascal-cluster/packages/bath-space",
"@pascal-app/plugin-bones": "github:pascalorg/plugin-bones#5679260261ee1c733656ff6dfb99e30bb24b58a7",
"@pascal-app/plugin-environment": "github:AxiomeCG/environment#1aa0bc3cbed273dbf751e9af79e446f6de105c90",
- "@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-landscape": "file:/Users/sudhir/Desktop/work/pascal-cluster/packages/landscape",
+ "@pascal-app/plugin-pool": "file:/Users/sudhir/Desktop/work/pascal-cluster/packages/pool",
+ "@pascal-app/plugin-streetscape-lab": "file:/Users/sudhir/Desktop/work/pascal-cluster/packages/streetscape",
"@pascal-app/plugin-trees": "github:pascalorg/plugin-trees#56d978cd9b409b716207b3f3d269455d3cd6f067",
"@pascal-app/viewer": "*",
"@radix-ui/react-tooltip": "^1.4.1",
@@ -50,7 +49,7 @@
"@react-three/fiber": "^9.8.1",
"@react-three/xr": "^6.6.30",
"@tailwindcss/postcss": "^4.2.1",
- "@webxr/plugin": "github:sudhir9297/pascal-cluster#5e980bebba12c3459d562f3b0e8bf5c67e42c228",
+ "@webxr/plugin": "file:/Users/sudhir/Desktop/work/pascal-cluster/packages/webxr",
"clsx": "^2.1.1",
"geist": "^1.7.2",
"lucide-react": "^1.57.0",
@@ -831,19 +830,10 @@
"@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-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=="],
"@pascal-app/viewer": ["@pascal-app/viewer@workspace:packages/viewer"],
@@ -1022,6 +1012,8 @@
"@types/bun": ["@types/bun@1.4.3", "", { "dependencies": { "bun-types": "1.4.3" } }, "sha512-N67Pqy8JXbAY/j0tNraS3V5mubGvc6rrrNDmDCtZLlFzccbUJPDb0tvgUBC5q4m8ybkP93Jcn1QB+RYQ6PvBhw=="],
+ "@types/clipper-lib": ["@types/clipper-lib@6.4.0", "", {}, "sha512-y8WVQWLCIJobtcxk8SJj1f7OGVbQEL7RmfGtrWDkAKFxq2U6F0/7/hLYvDWlUnBayvnRYkq+QVZaUzm13mu+Ng=="],
+
"@types/draco3d": ["@types/draco3d@1.4.10", "", {}, "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw=="],
"@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="],
@@ -1040,6 +1032,8 @@
"@types/offscreencanvas": ["@types/offscreencanvas@2019.7.3", "", {}, "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A=="],
+ "@types/pako": ["@types/pako@2.0.4", "", {}, "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw=="],
+
"@types/pdfkit": ["@types/pdfkit@0.17.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig=="],
"@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="],
diff --git a/package.json b/package.json
index 75ff9bfdc9..000fc34c6d 100644
--- a/package.json
+++ b/package.json
@@ -15,7 +15,7 @@
"check-types": "turbo run check-types",
"test": "turbo run test",
"ci": "bun run check && bun run skills:validate && bun run check-types && bun run test && bun run build",
- "postinstall": "[ -n \"$CI\" ] || [ ! -e .git ] || git config core.hooksPath .githooks",
+ "postinstall": "bun scripts/link-local-plugins.ts && ([ -n \"$CI\" ] || [ ! -e .git ] || git config core.hooksPath .githooks)",
"skills:validate": "bun scripts/validate-skills.ts && bun test scripts/cursor-plugin-policy.test.ts scripts/clawhub-ignore-policy.test.ts scripts/claude-mcp-config-policy.test.ts scripts/openai-tool-annotation-policy.test.ts scripts/path-containment.test.ts scripts/public-skill-discovery-policy.test.ts",
"kill": "lsof -ti:3002 | xargs kill -9 2>/dev/null || echo 'No processes found on port 3002'",
"clean:cache": "rm -rf apps/*/.next apps/*/.swc apps/*/.turbo packages/*/.turbo tooling/*/.turbo .turbo node_modules/.cache",
diff --git a/packages/core/src/contracts/reference-inventory.ts b/packages/core/src/contracts/reference-inventory.ts
index 505223f588..a78117d68d 100644
--- a/packages/core/src/contracts/reference-inventory.ts
+++ b/packages/core/src/contracts/reference-inventory.ts
@@ -243,14 +243,15 @@ export const EXISTING_REFERENCES: readonly ExistingReference[] = [
kind: 'fence',
path: 'supportSurfaceId',
...policy('node', 'host', 'drop', 'strip'),
- remaps: [...CLONES, 'delete-nodes'],
+ remaps: [...CLONES, 'clone-nodes-into', '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.',
+ remaps: [...CLONES, 'clone-nodes-into', 'delete-nodes'],
+ note: 'A registered top surface supporting a stair connection.',
}),
row({
kind: 'roof',
diff --git a/packages/core/src/lib/surface-support-references.ts b/packages/core/src/lib/surface-support-references.ts
new file mode 100644
index 0000000000..d24b0f769f
--- /dev/null
+++ b/packages/core/src/lib/surface-support-references.ts
@@ -0,0 +1,27 @@
+import type { AnyNode } from '../schema/types'
+
+const fields = ['supportSurfaceId', 'landscapeSurfaceId'] as const
+
+export function remapSurfaceSupportReferences(
+ node: AnyNode,
+ ids: ReadonlyMap,
+): void {
+ const record = node as unknown as Record
+ for (const field of fields) {
+ const id = record[field]
+ if (typeof id === 'string') record[field] = ids.get(id) ?? id
+ }
+}
+
+export function deletedSurfaceSupportPatch(
+ node: AnyNode,
+ deleted: ReadonlySet,
+): Partial | null {
+ const record = node as unknown as Record
+ const patch: Record = {}
+ for (const field of fields) {
+ const id = record[field]
+ if (typeof id === 'string' && deleted.has(id)) patch[field] = undefined
+ }
+ return Object.keys(patch).length ? (patch as Partial) : null
+}
diff --git a/packages/core/src/registry/scene-api.ts b/packages/core/src/registry/scene-api.ts
index 4257c971a4..6ad0a40892 100644
--- a/packages/core/src/registry/scene-api.ts
+++ b/packages/core/src/registry/scene-api.ts
@@ -22,6 +22,7 @@ import type { SceneApi } from './types'
export type SceneStoreLike = {
getState: () => {
nodes: Record
+ installedPlugins?: string[]
rootNodeIds: AnyNodeId[]
dirtyNodes: Set
createNode: (node: AnyNode, parentId?: AnyNodeId) => void
@@ -98,6 +99,10 @@ export function createSceneApi(store: SceneStoreLike): SceneApi {
return store.getState().nodes
},
+ installedPlugins() {
+ return store.getState().installedPlugins
+ },
+
update(id, patch) {
const [update] = filterDerivedNodeWrites(store.getState().nodes, {
update: [{ id, data: patch }],
diff --git a/packages/core/src/registry/subtree.ts b/packages/core/src/registry/subtree.ts
index a6401017ff..4f8b1bb632 100644
--- a/packages/core/src/registry/subtree.ts
+++ b/packages/core/src/registry/subtree.ts
@@ -3,6 +3,7 @@ import {
remapConstructionDimensionReferences,
remapMeasurementReferences,
} from '../lib/measurement-geometry'
+import { remapSurfaceSupportReferences } from '../lib/surface-support-references'
import { generateId } from '../schema/base'
import type { AnyNode, AnyNodeId } from '../schema/types'
@@ -170,6 +171,7 @@ export function cloneNodesInto(
.filter((cid): cid is AnyNodeId => cid !== undefined)
}
+ remapSurfaceSupportReferences(cloned, idMap)
if (cloned.type === 'procedural-item') {
cloned.attachments = Object.fromEntries(
Object.entries(cloned.attachments).flatMap(([id, surface]) => {
diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts
index 6132520456..b96c39e270 100644
--- a/packages/core/src/registry/types.ts
+++ b/packages/core/src/registry/types.ts
@@ -2420,7 +2420,13 @@ export type SnapPointKind = 'start' | 'end' | 'midpoint' | 'center' | 'corners'
export type SurfacesConfig = {
hosting?: SurfaceProvider | false
top?: {
- height: number | ((n: AnyNode, context: { nodes: Record }) => number)
+ /** Height above the node origin; point, when supplied, is in the parent XZ frame. */
+ height:
+ | number
+ | ((
+ n: AnyNode,
+ context: { nodes: Readonly>; point?: readonly [number, number] },
+ ) => number)
/** Resolve support from node data; null means this point is outside the support footprint. */
supportHeight?: (
node: AnyNode,
@@ -3007,6 +3013,7 @@ export type SceneApi = {
* do not mutate.
*/
nodes: () => Readonly>
+ installedPlugins?: () => readonly string[] | undefined
update: (id: AnyNodeId, patch: Partial) => void
upsert: (node: AnyNode, parentId?: AnyNodeId) => AnyNodeId
createMany?: (ops: { node: AnyNode; parentId?: AnyNodeId }[]) => void
diff --git a/packages/core/src/services/index.ts b/packages/core/src/services/index.ts
index b647747b4e..f675bf17bc 100644
--- a/packages/core/src/services/index.ts
+++ b/packages/core/src/services/index.ts
@@ -142,6 +142,11 @@ export {
snapVec3ToGrid,
snapWorldXZToBuildingLocal,
} from './snap'
+export {
+ getBoundarySurfaces,
+ resolveStairSurfaceSnap,
+ type StairSurfaceSnap,
+} from './stair-surface-snap'
export {
CEILING_CLAMP_MARGIN,
CEILING_DRAW_OFFSET,
diff --git a/packages/editor/src/components/tools/stair/landscape-snap.test.ts b/packages/core/src/services/stair-surface-snap.test.ts
similarity index 53%
rename from packages/editor/src/components/tools/stair/landscape-snap.test.ts
rename to packages/core/src/services/stair-surface-snap.test.ts
index 54cbe312df..6ac019f9ba 100644
--- a/packages/editor/src/components/tools/stair/landscape-snap.test.ts
+++ b/packages/core/src/services/stair-surface-snap.test.ts
@@ -1,10 +1,70 @@
import { expect, test } from 'bun:test'
-import { type AnyNode, StairNode } from '@pascal-app/core'
-import { resolveLandscapeStairSnap } from './landscape-snap'
+import {
+ type AnyNode,
+ type AnyNodeDefinition,
+ nodeRegistry,
+ registerNode,
+ resolveStairSurfaceSnap,
+ StairNode,
+} from '@pascal-app/core'
+import { z } from 'zod'
const stair = StairNode.parse({ parentId: 'level_1', position: [0, 0, -2], width: 1 })
-const surface = (type: string, extra: Record = {}) =>
- ({
+type FixtureSurface = {
+ position: number[]
+ rotation: number[]
+ width: number
+ depth: number
+ thickness: number
+ elevation?: number
+ shape: string
+}
+const surface = (type: string, extra: Record = {}) => {
+ if (!nodeRegistry.has(type))
+ registerNode({
+ kind: type,
+ schemaVersion: 1,
+ schema: z.object({ type: z.literal(type) }),
+ category: 'site',
+ defaults: () => ({}),
+ capabilities: {
+ surfaces: {
+ top: {
+ height: (raw: AnyNode) => {
+ const node = raw as unknown as FixtureSurface
+ return (
+ node.thickness +
+ (type === 'landscape:patio'
+ ? (node.elevation ?? 0) + Math.min(0.045, node.thickness / 3)
+ : 0)
+ )
+ },
+ boundary: (raw: AnyNode) => {
+ const node = raw as unknown as FixtureSurface
+ const local: [number, number][] =
+ node.shape === 'circle'
+ ? Array.from({ length: 128 }, (_, i) => [
+ (Math.cos((i * Math.PI * 2) / 128) * node.width) / 2,
+ (Math.sin((i * Math.PI * 2) / 128) * node.width) / 2,
+ ])
+ : [
+ [-node.width / 2, -node.depth / 2],
+ [node.width / 2, -node.depth / 2],
+ [node.width / 2, node.depth / 2],
+ [-node.width / 2, node.depth / 2],
+ ]
+ const c = Math.cos(node.rotation[1]!),
+ s = Math.sin(node.rotation[1]!)
+ return local.map(([x, z]): [number, number] => [
+ node.position[0]! + x * c + z * s,
+ node.position[2]! - x * s + z * c,
+ ])
+ },
+ },
+ },
+ },
+ } as AnyNodeDefinition)
+ return {
id: `${type}_1`,
type,
parentId: 'level_1',
@@ -15,11 +75,12 @@ const surface = (type: string, extra: Record = {}) =>
thickness: 0.35,
shape: 'rectangle',
...extra,
- }) as unknown as AnyNode
+ } 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)
+ const result = resolveStairSurfaceSnap(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)
@@ -31,22 +92,22 @@ test('connects native stairs to a deck and sizes the flight', () => {
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,
+ resolveStairSurfaceSnap(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)
+ const result = resolveStairSurfaceSnap(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()
+ expect(resolveStairSurfaceSnap(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)
+ const result = resolveStairSurfaceSnap(stair, { [slab.id]: slab }, [0, 0, -1.5], 3)
expect(result?.totalRise).toBeCloseTo(0.18)
expect(result!.position[2] + result!.length).toBeCloseTo(-1.5)
})
@@ -54,13 +115,13 @@ 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 })
- expect(resolveLandscapeStairSnap(disabled, { [deck.id]: deck }, [0, 0, -2], 3)).toBeNull()
+ expect(resolveStairSurfaceSnap(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 first = resolveStairSurfaceSnap(stair, { [deck.id]: deck }, [0, 0, -2], 3)!
const placed = StairNode.parse({
...stair,
position: first.position,
@@ -70,7 +131,7 @@ test('reconnects a placed stair after its deck height changes', () => {
stepCount: first.stepCount,
})
const tallerDeck = surface('landscape:deck', { thickness: 1.2 })
- const result = resolveLandscapeStairSnap(
+ const result = resolveStairSurfaceSnap(
placed,
{ [tallerDeck.id]: tallerDeck },
[first.position[0], 0, first.position[2] - 0.4],
@@ -85,21 +146,21 @@ 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 result = resolveLandscapeStairSnap(rotated, { [deck.id]: deck }, [-2.3, 0, 0], 0.8)
+ 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)
})
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)
+ const result = resolveStairSurfaceSnap(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)
+ const result = resolveStairSurfaceSnap(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/core/src/services/stair-surface-snap.ts b/packages/core/src/services/stair-surface-snap.ts
new file mode 100644
index 0000000000..2201628042
--- /dev/null
+++ b/packages/core/src/services/stair-surface-snap.ts
@@ -0,0 +1,111 @@
+import { floorConstructionLift } from '../lib/floor-construction-lift'
+import { isNodeKindEnabled, nodeRegistry } from '../registry/registry'
+import type { StairNode } from '../schema/nodes/stair'
+import type { AnyNode } from '../schema/types'
+
+type Point = [number, number]
+const SNAP_DISTANCE = 0.75
+
+export type StairSurfaceSnap = {
+ position: [number, number, number]
+ rotation: number
+ totalRise: number
+ stepCount: number
+ length: number
+ surfaceId: string
+}
+
+/** Align a straight flight's high end to a nearby registered surface edge. */
+export function resolveStairSurfaceSnap(
+ stair: StairNode,
+ nodes: Readonly>,
+ candidatePosition: [number, number, number],
+ currentLength: number,
+ baseElevation = candidatePosition[1],
+ installedPlugins?: readonly string[],
+): StairSurfaceSnap | 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: StairSurfaceSnap } | null = null
+ for (const raw of Object.values(nodes)) {
+ if (
+ raw.parentId !== stair.parentId ||
+ raw.visible === false ||
+ !isNodeKindEnabled(raw.type, installedPlugins)
+ )
+ continue
+ 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)
+ if (outline.length < 3) continue
+ 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 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
+ 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
+}
+
+export function getBoundarySurfaces(
+ nodes: Readonly>,
+ parentId: string | null | undefined,
+ installedPlugins?: readonly string[],
+): AnyNode[] {
+ return Object.values(nodes).filter(
+ (node) =>
+ node.parentId === parentId &&
+ node.visible !== false &&
+ isNodeKindEnabled(node.type, installedPlugins) &&
+ Boolean(nodeRegistry.get(node.type)?.capabilities?.surfaces?.top?.boundary),
+ )
+}
diff --git a/packages/core/src/store/actions/node-actions.ts b/packages/core/src/store/actions/node-actions.ts
index 61b83fd954..fae33153cf 100644
--- a/packages/core/src/store/actions/node-actions.ts
+++ b/packages/core/src/store/actions/node-actions.ts
@@ -2,6 +2,7 @@ import { planWallDeletion } from '../../commands/structure/plan-wall-deletion'
import type { StructurePlan } from '../../commands/structure/shared'
import { withoutFloorStepOverrideKeys } from '../../lib/floor-step-finish'
import { isSpaceDetectionPaused } from '../../lib/space-detection'
+import { deletedSurfaceSupportPatch } from '../../lib/surface-support-references'
import { nodeRegistry } from '../../registry/registry'
import { validateNodeRelations } from '../../registry/validate-relations'
import {
@@ -1820,6 +1821,14 @@ export function planNodeDeletion(
}
}
+ for (const node of Object.values(nextNodes)) {
+ if (allIds.has(node.id)) continue
+ const patch = deletedSurfaceSupportPatch(node, allIds)
+ if (!patch) continue
+ nextNodes[node.id] = { ...node, ...patch } as AnyNode
+ nodesToMarkDirty.add(node.id)
+ }
+
// A deleted door takes its step paint with it, in the same undo step; every
// other doorway keeps its own.
for (const node of Object.values(nextNodes)) {
diff --git a/packages/core/src/utils/clone-scene-graph.ts b/packages/core/src/utils/clone-scene-graph.ts
index 0b3ace7afa..b300377dc7 100644
--- a/packages/core/src/utils/clone-scene-graph.ts
+++ b/packages/core/src/utils/clone-scene-graph.ts
@@ -5,6 +5,7 @@ import {
remapConstructionDimensionReferences,
remapMeasurementReferences,
} from '../lib/measurement-geometry'
+import { remapSurfaceSupportReferences } from '../lib/surface-support-references'
import type { AnyNode, AnyNodeId } from '../schema'
import { generateId } from '../schema/base'
import type { Collection, CollectionId } from '../schema/collections'
@@ -433,6 +434,7 @@ export function cloneLevelSubtree(
;(cloned as Record).deckSlabId =
idMap.get(cloned.deckSlabId) ?? cloned.deckSlabId
}
+ remapSurfaceSupportReferences(cloned, idMap)
remapFloorOpeningReferences(cloned, idMap)
// A corner window's sides are copied together, so the copies join each other; a side copied
diff --git a/packages/editor/src/components/tools/stair/landscape-snap.ts b/packages/editor/src/components/tools/stair/landscape-snap.ts
deleted file mode 100644
index 9064e81537..0000000000
--- a/packages/editor/src/components/tools/stair/landscape-snap.ts
+++ /dev/null
@@ -1,146 +0,0 @@
-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 8abe4b5f50..921fb2f3b7 100644
--- a/packages/editor/src/components/tools/stair/stair-tool.tsx
+++ b/packages/editor/src/components/tools/stair/stair-tool.tsx
@@ -15,9 +15,11 @@ import {
planStairSizing,
resolveAlignment,
resolveFrozenFloorPlacementPatch,
+ resolveStairSurfaceSnap,
resolveSupportSlabPatch,
StairNode,
type StairSegmentNode,
+ type StairSurfaceSnap,
syncAutoStairOpenings,
useScene,
} from '@pascal-app/core'
@@ -46,7 +48,6 @@ 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,
@@ -194,7 +195,7 @@ function commitStairPlacement(
position: [number, number, number],
rotation: number,
supportSurface: PointerSupportSurface | null,
- landscapeSnap: LandscapeStairSnap | null,
+ landscapeSnap: StairSurfaceSnap | null,
): void {
const { applyNodeChanges, nodes } = useScene.getState()
const placementLevelId = resolveStairPlacementLevelId(
@@ -313,7 +314,7 @@ export const StairTool: React.FC = () => {
const previewMeshRef = useRef(null)
const rotationRef = useRef(0)
const supportSurfaceRef = useRef(null)
- const landscapeSnapRef = 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)
@@ -351,7 +352,7 @@ export const StairTool: React.FC = () => {
position: [number, number, number],
rotation: number,
supportSurface: PointerSupportSurface | null,
- landscapeSnap: LandscapeStairSnap | null,
+ landscapeSnap: StairSurfaceSnap | null,
) => {
const nodes = useScene.getState().nodes
const placementLevelId = resolveStairPlacementLevelId(
@@ -613,9 +614,17 @@ export const StairTool: React.FC = () => {
levelId,
})[1]
: candidate[1]
- const snap = event.nativeEvent?.altKey
- ? null
- : resolveLandscapeStairSnap(draft, nodes, candidate, DEFAULT_STAIR_LENGTH, baseElevation)
+ const snap =
+ event.nativeEvent?.altKey || !isMagneticSnapActive()
+ ? null
+ : resolveStairSurfaceSnap(
+ draft,
+ nodes,
+ candidate,
+ DEFAULT_STAIR_LENGTH,
+ baseElevation,
+ useScene.getState().installedPlugins,
+ )
landscapeSnapRef.current = snap
if (snap) {
rotationRef.current = snap.rotation
diff --git a/packages/editor/src/components/ui/panels/panel-wrapper.tsx b/packages/editor/src/components/ui/panels/panel-wrapper.tsx
index c384123886..d313b005ea 100644
--- a/packages/editor/src/components/ui/panels/panel-wrapper.tsx
+++ b/packages/editor/src/components/ui/panels/panel-wrapper.tsx
@@ -169,13 +169,9 @@ 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])
+ if (primaryExtension) setCollapsed(false)
+ }, [primaryExtension, selectedId, setCollapsed])
const applyMode = useCallback(
(next: { collapsed: boolean; activeExtensionId: string | null }) => {
diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx
index 532345f042..6897c89d34 100644
--- a/packages/editor/src/index.tsx
+++ b/packages/editor/src/index.tsx
@@ -188,8 +188,6 @@ export {
getSegmentAngleReferenceAtPoint,
type SegmentAngleReference,
} from './components/tools/shared/segment-angle'
-export type { LandscapeStairSnap } from './components/tools/stair/landscape-snap'
-export { resolveLandscapeStairSnap } from './components/tools/stair/landscape-snap'
// Stair placement defaults — used by the kind-owned stair / stair-segment
// panels. Re-exported from `components/tools/stair/stair-defaults.ts`.
export {
@@ -711,6 +709,7 @@ export type {
ModelExportOptions,
} from './lib/model-export'
export { eyedropperMaterial } from './lib/paint-eyedropper'
+export { usePaintRegionMode } from './lib/paint-region-mode'
export {
cyclePaintScope,
type PaintHoverInfo,
diff --git a/packages/nodes/src/fence/definition.ts b/packages/nodes/src/fence/definition.ts
index 10f3646bd7..de15384e58 100644
--- a/packages/nodes/src/fence/definition.ts
+++ b/packages/nodes/src/fence/definition.ts
@@ -385,6 +385,7 @@ export const fenceDefinition: NodeDefinition = {
capabilities: {
reveal: { phase: 'structure', style: 'rise' },
+ hostRefFields: ['supportSurfaceId'],
surfacePlacement: 'floor-only',
selectable: { hitVolume: 'bbox' },
surfaces: { sides: { faces: 'all' }, hosting: false },
diff --git a/packages/nodes/src/fence/drafting.ts b/packages/nodes/src/fence/drafting.ts
index e1c06dac50..e2417c9bce 100644
--- a/packages/nodes/src/fence/drafting.ts
+++ b/packages/nodes/src/fence/drafting.ts
@@ -1,5 +1,6 @@
import { getLevelPresentationY, useViewer } from '@pascal-app/viewer'
import {
+ type AnyNode,
type AnyNodeId,
DEFAULT_ANGLE_STEP,
type FenceConstructionOptions as FenceCommitOptions,
@@ -97,17 +98,15 @@ const FENCE_SPAN_SNAP_RADIUS = 0.16
const FENCE_CORNER_SNAP_RADIUS = 0.28
const FENCE_SPAN_SNAP_RADIUS = 0.16
-export function getFenceDrawingSurface() {
- const id = useEditor.getState().toolDefaults.fence?.supportSurfaceId
+export function getFenceDrawingSurface(
+ nodes: Record = useScene.getState().nodes,
+ id = useEditor.getState().toolDefaults.fence?.supportSurfaceId,
+ levelId = useViewer.getState().selection.levelId,
+ levelMode = useViewer.getState().levelMode,
+) {
if (typeof id !== 'string') return null
- const nodes = useScene.getState().nodes
const node = nodes[id as AnyNodeId]
- if (
- !node?.parentId ||
- node.parentId !== useViewer.getState().selection.levelId ||
- node.visible === false
- )
- return null
+ if (!node?.parentId || node.parentId !== levelId || node.visible === false) return null
const top = nodeRegistry.get(node.type)?.capabilities?.surfaces?.top
if (!top?.boundary) return null
const position = (node as unknown as { position: number[] }).position
@@ -115,7 +114,7 @@ export function getFenceDrawingSurface() {
return {
id,
boundary: top.boundary(node, 0.08),
- levelElevation: getLevelPresentationY(node.parentId, nodes, useViewer.getState().levelMode),
+ levelElevation: getLevelPresentationY(node.parentId, nodes, levelMode),
elevation: (position?.[1] ?? 0) + height + floorConstructionLift(nodes, node),
}
}
diff --git a/packages/nodes/src/fence/tool.tsx b/packages/nodes/src/fence/tool.tsx
index 2c2304d4d3..377aaa3fc1 100644
--- a/packages/nodes/src/fence/tool.tsx
+++ b/packages/nodes/src/fence/tool.tsx
@@ -86,10 +86,14 @@ const FENCE_PREVIEW_HEIGHT = 1.8
const FENCE_PREVIEW_THICKNESS = 0.08
function FenceSurfaceSnapPoints() {
- useEditor((state) => state.toolDefaults.fence?.supportSurfaceId)
- useViewer((state) => state.selection.levelId)
- useViewer((state) => state.levelMode)
- useScene((state) => state.nodes)
+ const hostId = useEditor((state) => state.toolDefaults.fence?.supportSurfaceId)
+ const levelId = useViewer((state) => state.selection.levelId)
+ const levelMode = useViewer((state) => state.levelMode)
+ useScene((state) => (typeof hostId === 'string' ? state.nodes[hostId as AnyNodeId] : undefined))
+ useScene((state) => {
+ const surface = getFenceDrawingSurface(state.nodes, hostId, levelId, levelMode)
+ return surface ? surface.levelElevation + surface.elevation : null
+ })
const surface = getFenceDrawingSurface()
if (!surface) return null
return (
@@ -517,15 +521,19 @@ function getCurrentLevelElements(): { walls: WallNode[]; fences: FenceNode[] } {
export const FenceTool: React.FC = () => {
const levelId = useViewer((state) => state.selection.levelId)
const levelMode = useViewer((state) => state.levelMode)
- const nodes = useScene((state) => state.nodes)
const hostId = useEditor((state) => state.toolDefaults.fence?.supportSurfaceId)
- const floorY = levelId ? getLevelPresentationY(levelId, nodes, levelMode) : 0
+ const hostParentId = useScene((state) =>
+ typeof hostId === 'string' ? state.nodes[hostId as AnyNodeId]?.parentId : undefined,
+ )
+ const floorY = useScene((state) =>
+ levelId ? getLevelPresentationY(levelId, state.nodes, levelMode) : 0,
+ )
const draftKey = `${levelId}:${floorY}`
useEffect(() => {
- if (typeof hostId === 'string' && nodes[hostId as AnyNodeId]?.parentId !== levelId) {
+ if (typeof hostId === 'string' && hostParentId !== levelId) {
useEditor.getState().setTool(null)
}
- }, [hostId, levelId, nodes])
+ }, [hostId, levelId, hostParentId])
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/shared/move-roof-tool.tsx b/packages/nodes/src/shared/move-roof-tool.tsx
index e577c9ec30..327a96d695 100644
--- a/packages/nodes/src/shared/move-roof-tool.tsx
+++ b/packages/nodes/src/shared/move-roof-tool.tsx
@@ -10,9 +10,11 @@ import {
type RoofNode,
type RoofSegmentNode,
resolveAlignment,
+ resolveStairSurfaceSnap,
resolveSupportSlabPatch,
type StairNode,
type StairSegmentNode,
+ type StairSurfaceSnap,
sceneRegistry,
seatRoofs,
useLiveNodeOverrides,
@@ -29,8 +31,6 @@ import {
getMovingNode,
isMagneticSnapActive,
isTypingTarget,
- type LandscapeStairSnap,
- resolveLandscapeStairSnap,
resolvePlanarCursorPosition,
selectPlacedNode,
stripPlacementMetadataFlags,
@@ -164,7 +164,7 @@ export const MoveRoofTool: React.FC<{
let wasCommitted = false
let wasCancelled = false
let hasMoved = false
- let landscapeSnap: LandscapeStairSnap | null = null
+ let landscapeSnap: StairSurfaceSnap | null = null
const flightId =
movingNode.type === 'stair'
? movingNode.children
@@ -392,7 +392,11 @@ export const MoveRoofTool: React.FC<{
lastLocalPosition = [localX, movingNode.position[1], localZ]
const wasLandscapeSnapped = landscapeSnap !== null
- if (movingNode.type === 'stair' && event.nativeEvent?.altKey !== true) {
+ if (
+ movingNode.type === 'stair' &&
+ event.nativeEvent?.altKey !== true &&
+ isMagneticSnapActive()
+ ) {
const sceneNodes = useScene.getState().nodes
const segment = movingNode.children
.map((id) => sceneNodes[id])
@@ -401,12 +405,13 @@ export const MoveRoofTool: React.FC<{
child?.type === 'stair-segment' && child.segmentType === 'stair',
)
const baseElevation = getPreviewPosition(lastLocalPosition)[1]
- landscapeSnap = resolveLandscapeStairSnap(
+ landscapeSnap = resolveStairSurfaceSnap(
{ ...movingNode, rotation: freeRotation },
sceneNodes,
lastLocalPosition,
segment?.length ?? 3,
baseElevation,
+ useScene.getState().installedPlugins,
)
if (landscapeSnap) {
if (!wasLandscapeSnapped) freeRotation = pendingRotation
diff --git a/packages/nodes/src/stair/definition.ts b/packages/nodes/src/stair/definition.ts
index c0adaaf555..9dd072d170 100644
--- a/packages/nodes/src/stair/definition.ts
+++ b/packages/nodes/src/stair/definition.ts
@@ -439,6 +439,7 @@ export const stairDefinition: NodeDefinition = {
capabilities: {
reveal: { phase: 'circulation', style: 'drop', height: 2.5 },
+ hostRefFields: ['landscapeSurfaceId'],
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 ca27213723..2c956600d5 100644
--- a/packages/nodes/src/stair/floorplan-move.test.ts
+++ b/packages/nodes/src/stair/floorplan-move.test.ts
@@ -1,16 +1,20 @@
import { afterEach, beforeEach, expect, test } from 'bun:test'
import {
type AnyNode,
+ type AnyNodeDefinition,
clearSceneHistory,
createSceneApi,
getEffectiveNode,
LevelNode,
+ nodeRegistry,
+ registerNode,
StairNode,
StairSegmentNode,
useLiveNodeOverrides,
useScene,
} from '@pascal-app/core'
import { commitFreshPlacementSubtree, useEditor } from '@pascal-app/editor'
+import { z } from 'zod'
import { stairDefinition } from './definition'
const originalRaf = globalThis.requestAnimationFrame
@@ -34,6 +38,34 @@ afterEach(() => {
})
function setup() {
+ if (!nodeRegistry.has('landscape:deck'))
+ registerNode({
+ kind: 'landscape:deck',
+ schemaVersion: 1,
+ schema: z.object({ type: z.literal('landscape:deck') }),
+ category: 'site',
+ defaults: () => ({}),
+ capabilities: {
+ surfaces: {
+ top: {
+ height: (raw: AnyNode) => (raw as unknown as { thickness: number }).thickness,
+ boundary: () => [
+ [-2, -1.5],
+ [2, -1.5],
+ [2, 1.5],
+ [-2, 1.5],
+ ],
+ },
+ },
+ },
+ } as AnyNodeDefinition)
+ useEditor.setState({
+ tool: 'stair',
+ snappingModeByContext: {
+ ...useEditor.getState().snappingModeByContext,
+ item: 'lines',
+ },
+ })
const level = LevelNode.parse({ id: 'level_landscape_move' })
const flight = StairSegmentNode.parse({
id: 'sseg_landscape_move',
diff --git a/packages/nodes/src/stair/floorplan-move.ts b/packages/nodes/src/stair/floorplan-move.ts
index ed34539334..249d5132eb 100644
--- a/packages/nodes/src/stair/floorplan-move.ts
+++ b/packages/nodes/src/stair/floorplan-move.ts
@@ -5,19 +5,18 @@ import {
type FloorplanMoveTargetSession,
getFloorStackedPosition,
movingAlignmentAnchors,
+ resolveStairSurfaceSnap,
type StairNode,
type StairSegmentNode,
+ type StairSurfaceSnap,
snapScalar,
useLiveNodeOverrides,
- useScene,
} from '@pascal-app/core'
import {
applyFloorplanAlignment,
getSegmentGridStep,
isGridSnapActive,
isMagneticSnapActive,
- type LandscapeStairSnap,
- resolveLandscapeStairSnap,
} from '@pascal-app/editor'
import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
@@ -33,7 +32,11 @@ import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
* The position previews through the live override store and is written to
* scene once via `commit()`.
*/
-export const stairFloorplanMoveTarget: FloorplanMoveTarget = ({ node, nodes }) => {
+export const stairFloorplanMoveTarget: FloorplanMoveTarget = ({
+ node,
+ nodes,
+ sceneApi,
+}) => {
const startY = node.position[1]
const resolveCursor = createFloorplanCursorResolver({
original: [node.position[0], node.position[2]],
@@ -48,7 +51,7 @@ export const stairFloorplanMoveTarget: FloorplanMoveTarget = ({ node,
(child): child is StairSegmentNode =>
child?.type === 'stair-segment' && child.segmentType === 'stair',
)
- let landscapeSnap: LandscapeStairSnap | null = null
+ let landscapeSnap: StairSurfaceSnap | null = null
const session: FloorplanMoveTargetSession = {
affectedIds: flight ? [node.id as AnyNodeId, flight.id as AnyNodeId] : [node.id as AnyNodeId],
@@ -70,7 +73,7 @@ export const stairFloorplanMoveTarget: FloorplanMoveTarget = ({ node,
const sx = aligned[0]
const sz = aligned[1]
- const sceneNodes = useScene.getState().nodes
+ const sceneNodes = sceneApi?.nodes() ?? nodes
const baseElevation = getFloorStackedPosition({
node,
nodes: sceneNodes,
@@ -78,15 +81,17 @@ export const stairFloorplanMoveTarget: FloorplanMoveTarget = ({ node,
rotation: node.rotation,
levelId: node.parentId,
})[1]
- landscapeSnap = modifiers.altKey
- ? null
- : resolveLandscapeStairSnap(
- node,
- sceneNodes,
- [sx, startY, sz],
- flight?.length ?? 3,
- baseElevation,
- )
+ landscapeSnap =
+ modifiers.altKey || !isMagneticSnapActive()
+ ? null
+ : resolveStairSurfaceSnap(
+ node,
+ sceneNodes,
+ [sx, startY, sz],
+ flight?.length ?? 3,
+ baseElevation,
+ sceneApi?.installedPlugins?.(),
+ )
const position = landscapeSnap?.position ?? ([sx, startY, sz] as [number, number, number])
if (
lastValid &&
@@ -117,14 +122,14 @@ export const stairFloorplanMoveTarget: FloorplanMoveTarget = ({ node,
})
else useLiveNodeOverrides.getState().clear(flight.id as AnyNodeId)
}
- useScene.getState().markDirty(node.id as AnyNodeId)
- if (flight) useScene.getState().markDirty(flight.id as AnyNodeId)
+ sceneApi?.markDirty(node.id as AnyNodeId)
+ if (flight) sceneApi?.markDirty(flight.id as AnyNodeId)
},
canCommit() {
// No overlap / placement rules for stairs in 2D — any pointer-up
// position commits, as long as we actually moved. Mirrors the 3D
// move tool which also lets stairs land anywhere on the slab.
- return lastValid !== null
+ return lastValid !== null && Boolean(sceneApi?.applyChanges)
},
commit() {
// Own the atomic write so the overlay takes the deterministic
@@ -133,37 +138,39 @@ export const stairFloorplanMoveTarget: FloorplanMoveTarget = ({ node,
if (!lastValid) return
useLiveNodeOverrides.getState().clear(node.id as AnyNodeId)
if (flight) useLiveNodeOverrides.getState().clear(flight.id as AnyNodeId)
- useScene.getState().updateNodes([
- {
- id: node.id as AnyNodeId,
- data: {
- ...lastValid,
- ...(landscapeSnap
- ? {
- totalRise: landscapeSnap.totalRise,
- stepCount: landscapeSnap.stepCount,
- slabOpeningMode: 'none' as const,
- landscapeSurfaceId: landscapeSnap.surfaceId,
- railingMode: 'none' as const,
- }
- : node.landscapeSurfaceId
- ? { landscapeSurfaceId: undefined }
- : {}),
+ sceneApi?.applyChanges?.({
+ update: [
+ {
+ id: node.id as AnyNodeId,
+ data: {
+ ...lastValid,
+ ...(landscapeSnap
+ ? {
+ totalRise: landscapeSnap.totalRise,
+ stepCount: landscapeSnap.stepCount,
+ slabOpeningMode: 'none' as const,
+ landscapeSurfaceId: landscapeSnap.surfaceId,
+ railingMode: 'none' as const,
+ }
+ : node.landscapeSurfaceId
+ ? { landscapeSurfaceId: undefined }
+ : {}),
+ },
},
- },
- ...(landscapeSnap && flight
- ? [
- {
- id: flight.id as AnyNodeId,
- data: {
- height: landscapeSnap.totalRise,
- length: landscapeSnap.length,
- stepCount: landscapeSnap.stepCount,
+ ...(landscapeSnap && flight
+ ? [
+ {
+ id: flight.id as AnyNodeId,
+ data: {
+ height: landscapeSnap.totalRise,
+ length: landscapeSnap.length,
+ stepCount: landscapeSnap.stepCount,
+ },
},
- },
- ]
- : []),
- ])
+ ]
+ : []),
+ ],
+ })
},
}
diff --git a/packages/nodes/src/stair/landscape-move-preview.ts b/packages/nodes/src/stair/landscape-move-preview.ts
index f9f311bac2..f531f8a646 100644
--- a/packages/nodes/src/stair/landscape-move-preview.ts
+++ b/packages/nodes/src/stair/landscape-move-preview.ts
@@ -1,12 +1,16 @@
-import { type AnyNodeId, useLiveNodeOverrides, useScene } from '@pascal-app/core'
-import type { LandscapeStairSnap } from '@pascal-app/editor'
+import {
+ type AnyNodeId,
+ type StairSurfaceSnap,
+ useLiveNodeOverrides,
+ useScene,
+} from '@pascal-app/core'
export function publishStairMovePreview(
stairId: AnyNodeId,
flightId: AnyNodeId | undefined,
position: [number, number, number],
rotation: number,
- snap: LandscapeStairSnap | null,
+ snap: StairSurfaceSnap | null,
) {
const overrides = useLiveNodeOverrides.getState()
if (snap) {
diff --git a/packages/nodes/src/stair/panel.tsx b/packages/nodes/src/stair/panel.tsx
index 8d1cc32a27..835aa69aff 100644
--- a/packages/nodes/src/stair/panel.tsx
+++ b/packages/nodes/src/stair/panel.tsx
@@ -3,6 +3,7 @@
import {
type AnyNode,
type AnyNodeId,
+ getBoundarySurfaces,
getLevelDisplayName,
type LevelNode,
measureStair,
@@ -23,6 +24,7 @@ import {
type StairSlabOpeningMode,
type StairTopLandingMode,
type StairType,
+ useRegistryVersion,
useScene,
} from '@pascal-app/core'
import {
@@ -104,6 +106,8 @@ export default function StairPanel() {
const [walkingLineOffset, setWalkingLineOffset] = useState(0.5)
const [division, setDivision] = useState<'equal-going' | 'equal-angle'>('equal-going')
const [riseError, setRiseError] = useState<{ id: string; message: string } | null>(null)
+ const installedPlugins = useScene((s) => s.installedPlugins)
+ useRegistryVersion()
const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as StairNode | undefined) : undefined,
@@ -373,6 +377,14 @@ export default function StairPanel() {
.reduce((sum, segment) => sum + segment.height, 0)
: 0) + 0.001,
)
+ const landscapeLevelId =
+ node.landscapeSurfaceId && node.parentId === node.landscapeSurfaceId
+ ? nodes[node.parentId as AnyNodeId]?.parentId
+ : node.parentId
+ const landscapeSurfaces = getBoundarySurfaces(nodes, landscapeLevelId, installedPlugins)
+ const connectedSurface = node.landscapeSurfaceId
+ ? nodes[node.landscapeSurfaceId as AnyNodeId]
+ : undefined
return (
Date: Tue, 6 Oct 2026 15:10:47 +0530
Subject: [PATCH 04/31] fix(ci): use portable plugin pins and opt-in local
development links
---
SETUP.md | 16 ++++++++++++++++
apps/editor/local-plugins.ts | 18 ++++++++++++++++++
apps/editor/next.config.ts | 15 ++-------------
apps/editor/package.json | 10 +++++-----
bun.lock | 24 ++++++++++++++----------
scripts/link-local-plugins.ts | 6 ++----
6 files changed, 57 insertions(+), 32 deletions(-)
create mode 100644 apps/editor/local-plugins.ts
diff --git a/SETUP.md b/SETUP.md
index f17b5fc5b9..1fb00a81f6 100644
--- a/SETUP.md
+++ b/SETUP.md
@@ -18,6 +18,22 @@ plugins. Open **+ → Plugins → Environment** to manage its installation for t
current project, then open **Environment** in the sidebar. No separate plugin
checkout, local tarball, or synchronization script is needed.
+## Develop local Pascal cluster plugins
+
+The committed dependencies use pinned package revisions so a clean checkout and
+CI can install without a sibling repository. To edit the plugins locally, add
+this setting to the editor repository's ignored `.env.local`:
+
+```dotenv
+PASCAL_LOCAL_PLUGINS_ROOT=/absolute/path/to/pascal-cluster
+```
+
+Run `bun install` to link the Bath Space, Landscape, Pool, Streetscape and WebXR
+package directories and share the editor's peer dependencies. Restart the dev
+server after changing the setting. CI and portable builds use the pinned
+packages. To return to them locally, remove the setting and run `bun install
+--force`.
+
## Environment Variables (optional)
Copy `.env.example` to `.env` if you need:
diff --git a/apps/editor/local-plugins.ts b/apps/editor/local-plugins.ts
new file mode 100644
index 0000000000..9c5a4638d6
--- /dev/null
+++ b/apps/editor/local-plugins.ts
@@ -0,0 +1,18 @@
+import path from 'node:path'
+
+const packages = [
+ ['@pascal-app/plugin-bath-space', 'bath-space'],
+ ['@pascal-app/plugin-landscape', 'landscape'],
+ ['@pascal-app/plugin-pool', 'pool'],
+ ['@pascal-app/plugin-streetscape-lab', 'streetscape'],
+ ['@webxr/plugin', 'webxr'],
+] as const
+
+export function getLocalPluginPackages() {
+ const root = process.env.PASCAL_LOCAL_PLUGINS_ROOT
+ if (!root || process.env.CI || process.env.PASCAL_PORTABLE_BUILD === '1') return []
+ return packages.map(([name, folder]) => ({
+ name,
+ directory: path.resolve(root, 'packages', folder),
+ }))
+}
diff --git a/apps/editor/next.config.ts b/apps/editor/next.config.ts
index cf0592a59b..30aa91e6fd 100644
--- a/apps/editor/next.config.ts
+++ b/apps/editor/next.config.ts
@@ -1,22 +1,11 @@
-import { readFileSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import type { NextConfig } from 'next'
+import { getLocalPluginPackages } from './local-plugins'
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 localPluginPaths = getLocalPluginPackages().map((plugin) => plugin.directory)
const turbopackRoot = [path.resolve(appDirectory, '../..'), ...localPluginPaths].reduce(
(commonRoot, candidate) => {
const commonParts = commonRoot.split(path.sep)
diff --git a/apps/editor/package.json b/apps/editor/package.json
index b78443b96b..43d19290e9 100644
--- a/apps/editor/package.json
+++ b/apps/editor/package.json
@@ -21,10 +21,10 @@
"@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": "file:/Users/sudhir/Desktop/work/pascal-cluster/packages/bath-space",
- "@pascal-app/plugin-landscape": "file:/Users/sudhir/Desktop/work/pascal-cluster/packages/landscape",
- "@pascal-app/plugin-pool": "file:/Users/sudhir/Desktop/work/pascal-cluster/packages/pool",
- "@pascal-app/plugin-streetscape-lab": "file:/Users/sudhir/Desktop/work/pascal-cluster/packages/streetscape",
+ "@pascal-app/plugin-bath-space": "github:sudhir9297/pascal-cluster#8f3c0db2be7a33b19f406ecb35e23417ff850dcc",
+ "@pascal-app/plugin-landscape": "github:sudhir9297/pascal-cluster#ed858bcf52bc2ea252a3ab3fb201e0c356919278",
+ "@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",
@@ -32,7 +32,7 @@
"@react-three/fiber": "^9.8.1",
"@react-three/xr": "^6.6.30",
"@tailwindcss/postcss": "^4.2.1",
- "@webxr/plugin": "file:/Users/sudhir/Desktop/work/pascal-cluster/packages/webxr",
+ "@webxr/plugin": "github:sudhir9297/pascal-cluster#3dfbdf1e859368746e848489dab83f770b148255",
"clsx": "^2.1.1",
"geist": "^1.7.2",
"lucide-react": "^1.57.0",
diff --git a/bun.lock b/bun.lock
index 5fd753eb0b..92493b1cee 100644
--- a/bun.lock
+++ b/bun.lock
@@ -36,12 +36,12 @@
"@pascal-app/editor": "*",
"@pascal-app/mcp": "*",
"@pascal-app/nodes": "*",
- "@pascal-app/plugin-bath-space": "file:/Users/sudhir/Desktop/work/pascal-cluster/packages/bath-space",
+ "@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-landscape": "file:/Users/sudhir/Desktop/work/pascal-cluster/packages/landscape",
- "@pascal-app/plugin-pool": "file:/Users/sudhir/Desktop/work/pascal-cluster/packages/pool",
- "@pascal-app/plugin-streetscape-lab": "file:/Users/sudhir/Desktop/work/pascal-cluster/packages/streetscape",
+ "@pascal-app/plugin-landscape": "github:sudhir9297/pascal-cluster#ed858bcf52bc2ea252a3ab3fb201e0c356919278",
+ "@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",
@@ -49,7 +49,7 @@
"@react-three/fiber": "^9.8.1",
"@react-three/xr": "^6.6.30",
"@tailwindcss/postcss": "^4.2.1",
- "@webxr/plugin": "file:/Users/sudhir/Desktop/work/pascal-cluster/packages/webxr",
+ "@webxr/plugin": "github:sudhir9297/pascal-cluster#3dfbdf1e859368746e848489dab83f770b148255",
"clsx": "^2.1.1",
"geist": "^1.7.2",
"lucide-react": "^1.57.0",
@@ -830,10 +830,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-landscape": ["@pascal-app/plugin-landscape@github:sudhir9297/pascal-cluster#ed858bc", { "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-ed858bc", "sha512-qcTb+BqkGDXXotkgdNridDzsrjvT1q8lBwjBMY42AeohQGmUfwxA0o+ZKNuUnWc+GjEY28GuJhHhku2J5zrGGw=="],
+
+ "@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=="],
"@pascal-app/viewer": ["@pascal-app/viewer@workspace:packages/viewer"],
@@ -1012,8 +1020,6 @@
"@types/bun": ["@types/bun@1.4.3", "", { "dependencies": { "bun-types": "1.4.3" } }, "sha512-N67Pqy8JXbAY/j0tNraS3V5mubGvc6rrrNDmDCtZLlFzccbUJPDb0tvgUBC5q4m8ybkP93Jcn1QB+RYQ6PvBhw=="],
- "@types/clipper-lib": ["@types/clipper-lib@6.4.0", "", {}, "sha512-y8WVQWLCIJobtcxk8SJj1f7OGVbQEL7RmfGtrWDkAKFxq2U6F0/7/hLYvDWlUnBayvnRYkq+QVZaUzm13mu+Ng=="],
-
"@types/draco3d": ["@types/draco3d@1.4.10", "", {}, "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw=="],
"@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="],
@@ -1032,8 +1038,6 @@
"@types/offscreencanvas": ["@types/offscreencanvas@2019.7.3", "", {}, "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A=="],
- "@types/pako": ["@types/pako@2.0.4", "", {}, "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw=="],
-
"@types/pdfkit": ["@types/pdfkit@0.17.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig=="],
"@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="],
@@ -1138,7 +1142,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/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=="],
+ "@webxr/plugin": ["@webxr/plugin@github:sudhir9297/pascal-cluster#3dfbdf1", { "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-3dfbdf1", "sha512-fSzyfg0N9mow6boHgZCwEXnI4XNVkaxVm8LL0GpnnrDIJ/RS6JliXm5IpCCscTYjlLbVeLjvaK9NI2wYZD4W5Q=="],
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
diff --git a/scripts/link-local-plugins.ts b/scripts/link-local-plugins.ts
index b47597d39c..65d4b40925 100644
--- a/scripts/link-local-plugins.ts
+++ b/scripts/link-local-plugins.ts
@@ -8,12 +8,10 @@ import {
symlinkSync,
} from 'node:fs'
import path from 'node:path'
+import { getLocalPluginPackages } from '../apps/editor/local-plugins'
const appDirectory = path.resolve(import.meta.dir, '../apps/editor')
-const manifest = JSON.parse(readFileSync(path.join(appDirectory, 'package.json'), 'utf8'))
-for (const [name, source] of Object.entries(manifest.dependencies ?? {})) {
- if (typeof source !== 'string' || !source.startsWith('file:')) continue
- const target = path.resolve(appDirectory, source.slice(5))
+for (const { name, directory: target } of getLocalPluginPackages()) {
const packageManifest = JSON.parse(readFileSync(path.join(target, 'package.json'), 'utf8'))
if (packageManifest.name !== name)
throw new Error(`Local dependency ${name} has a mismatched package name`)
From 6dc1ed68f4c34ac91e4e6978fdea16eb8490e375 Mon Sep 17 00:00:00 2001
From: sudhir
Date: Tue, 6 Oct 2026 15:13:33 +0530
Subject: [PATCH 05/31] fix(ci): install Landscape vendor and Clipper
declarations
---
apps/editor/package.json | 2 +-
bun.lock | 6 ++++--
2 files changed, 5 insertions(+), 3 deletions(-)
diff --git a/apps/editor/package.json b/apps/editor/package.json
index 43d19290e9..e7711a053e 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#8f3c0db2be7a33b19f406ecb35e23417ff850dcc",
- "@pascal-app/plugin-landscape": "github:sudhir9297/pascal-cluster#ed858bcf52bc2ea252a3ab3fb201e0c356919278",
+ "@pascal-app/plugin-landscape": "github:sudhir9297/pascal-cluster#98cb3d13b0e49133774aad1783c7f3e032cb2daf",
"@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",
diff --git a/bun.lock b/bun.lock
index 92493b1cee..c57cbe86d2 100644
--- a/bun.lock
+++ b/bun.lock
@@ -39,7 +39,7 @@
"@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-landscape": "github:sudhir9297/pascal-cluster#ed858bcf52bc2ea252a3ab3fb201e0c356919278",
+ "@pascal-app/plugin-landscape": "github:sudhir9297/pascal-cluster#98cb3d13b0e49133774aad1783c7f3e032cb2daf",
"@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",
@@ -836,7 +836,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#ed858bc", { "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-ed858bc", "sha512-qcTb+BqkGDXXotkgdNridDzsrjvT1q8lBwjBMY42AeohQGmUfwxA0o+ZKNuUnWc+GjEY28GuJhHhku2J5zrGGw=="],
+ "@pascal-app/plugin-landscape": ["@pascal-app/plugin-landscape@github:sudhir9297/pascal-cluster#98cb3d1", { "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-98cb3d1", "sha512-XyoTDKs+8D3dYVlzeSOy6nP2D158WX4U8WrqN122y3T+Sr7AXaWhHvNKUNMfrusvFPT/LwCPqTWHGM4zbCPo4Q=="],
"@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=="],
@@ -1020,6 +1020,8 @@
"@types/bun": ["@types/bun@1.4.3", "", { "dependencies": { "bun-types": "1.4.3" } }, "sha512-N67Pqy8JXbAY/j0tNraS3V5mubGvc6rrrNDmDCtZLlFzccbUJPDb0tvgUBC5q4m8ybkP93Jcn1QB+RYQ6PvBhw=="],
+ "@types/clipper-lib": ["@types/clipper-lib@6.4.0", "", {}, "sha512-y8WVQWLCIJobtcxk8SJj1f7OGVbQEL7RmfGtrWDkAKFxq2U6F0/7/hLYvDWlUnBayvnRYkq+QVZaUzm13mu+Ng=="],
+
"@types/draco3d": ["@types/draco3d@1.4.10", "", {}, "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw=="],
"@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="],
From 13bdd430a0dc652cf93857ef03b8aeb9e9a96873 Mon Sep 17 00:00:00 2001
From: sudhir
Date: Tue, 6 Oct 2026 15:14:20 +0530
Subject: [PATCH 06/31] fix(core): classify the room boundary metadata flag
---
packages/core/src/contracts/reference-inventory.ts | 1 +
1 file changed, 1 insertion(+)
diff --git a/packages/core/src/contracts/reference-inventory.ts b/packages/core/src/contracts/reference-inventory.ts
index a78117d68d..979e6baa0a 100644
--- a/packages/core/src/contracts/reference-inventory.ts
+++ b/packages/core/src/contracts/reference-inventory.ts
@@ -924,6 +924,7 @@ const described = (reason: string, paths: readonly string[]) =>
/** Metadata keys found in editor sources that are not references. */
export const METADATA_NON_REFERENCES: readonly { path: string; reason: string }[] = [
+ { path: 'metadata.roomBoundary', reason: 'Boolean opt-out from room boundary detection.' },
...described('Flag, enum, number or tag; names nothing.', [
'annotationObstacle',
'annotationRole',
From d49b39f63a0935a75823afbe25fbb9f28600a383 Mon Sep 17 00:00:00 2001
From: sudhir
Date: Tue, 6 Oct 2026 15:16:17 +0530
Subject: [PATCH 07/31] test(nodes): exercise stair snapping in the structural
move context
---
packages/nodes/src/stair/floorplan-move.test.ts | 17 ++++++++++++++---
1 file changed, 14 insertions(+), 3 deletions(-)
diff --git a/packages/nodes/src/stair/floorplan-move.test.ts b/packages/nodes/src/stair/floorplan-move.test.ts
index 2c956600d5..0aa73bd383 100644
--- a/packages/nodes/src/stair/floorplan-move.test.ts
+++ b/packages/nodes/src/stair/floorplan-move.test.ts
@@ -13,7 +13,7 @@ import {
useLiveNodeOverrides,
useScene,
} from '@pascal-app/core'
-import { commitFreshPlacementSubtree, useEditor } from '@pascal-app/editor'
+import { commitFreshPlacementSubtree, useEditor, useInteractionScope } from '@pascal-app/editor'
import { z } from 'zod'
import { stairDefinition } from './definition'
@@ -31,6 +31,7 @@ const modifiers = { altKey: false, shiftKey: false, ctrlKey: false, metaKey: fal
afterEach(() => {
globalThis.requestAnimationFrame = originalRaf
globalThis.cancelAnimationFrame = originalCancelRaf
+ useInteractionScope.getState().end()
useLiveNodeOverrides.getState().clearAll()
useScene.setState(initialScene, true)
useEditor.setState(initialEditor, true)
@@ -38,6 +39,7 @@ afterEach(() => {
})
function setup() {
+ if (!nodeRegistry.has(stairDefinition.kind)) registerNode(stairDefinition)
if (!nodeRegistry.has('landscape:deck'))
registerNode({
kind: 'landscape:deck',
@@ -60,10 +62,12 @@ function setup() {
},
} as AnyNodeDefinition)
useEditor.setState({
- tool: 'stair',
+ mode: 'select',
+ tool: null,
+ magneticSnap: true,
snappingModeByContext: {
...useEditor.getState().snappingModeByContext,
- item: 'lines',
+ polygon: 'lines',
},
})
const level = LevelNode.parse({ id: 'level_landscape_move' })
@@ -92,6 +96,13 @@ function setup() {
useScene.setState({ nodes, rootNodeIds: [level.id], dirtyNodes: new Set() })
useScene.temporal.getState().resume()
clearSceneHistory()
+ useInteractionScope.getState().begin({
+ kind: 'moving',
+ node: stair,
+ nodeId: stair.id,
+ nodeType: stair.type,
+ view: '2d',
+ })
const session = stairDefinition.floorplanMoveTarget!({
node: stair,
nodes,
From b914db617d1c667f778a1e0da2fbc2d5be198405 Mon Sep 17 00:00:00 2001
From: sudhir
Date: Tue, 6 Oct 2026 15:18:51 +0530
Subject: [PATCH 08/31] docs: clarify restoring plugin pins before portable
builds
---
SETUP.md | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/SETUP.md b/SETUP.md
index 1fb00a81f6..21bf4fda1b 100644
--- a/SETUP.md
+++ b/SETUP.md
@@ -31,8 +31,9 @@ PASCAL_LOCAL_PLUGINS_ROOT=/absolute/path/to/pascal-cluster
Run `bun install` to link the Bath Space, Landscape, Pool, Streetscape and WebXR
package directories and share the editor's peer dependencies. Restart the dev
server after changing the setting. CI and portable builds use the pinned
-packages. To return to them locally, remove the setting and run `bun install
---force`.
+packages in clean installs. Before a portable build from a locally linked
+checkout, remove the setting and run `bun install --force` to restore the pinned
+dependencies.
## Environment Variables (optional)
From 1e1e3b7758c3ddfe701aa43616b64aaae90baad8 Mon Sep 17 00:00:00 2001
From: sudhir
Date: Tue, 6 Oct 2026 15:19:37 +0530
Subject: [PATCH 09/31] fix(cli): budget the portable runtime for integrated
plugin assets
---
packages/cli/scripts/smoke-packed-runtime.ts | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/packages/cli/scripts/smoke-packed-runtime.ts b/packages/cli/scripts/smoke-packed-runtime.ts
index 9db2d6db73..68d7b05b5e 100644
--- a/packages/cli/scripts/smoke-packed-runtime.ts
+++ b/packages/cli/scripts/smoke-packed-runtime.ts
@@ -277,7 +277,9 @@ 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}`)
}
- const maximumArchiveSize = 70 * 1024 * 1024
+ // 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
if (size > maximumArchiveSize) {
throw new Error(
`the web runtime archive exceeds its release budget: ${formatMb(size)} MB > ${formatMb(maximumArchiveSize)} MB`,
From 4c2a4bb254f6863c9687cc1f5f19b337a4ee5298 Mon Sep 17 00:00:00 2001
From: sudhir
Date: Tue, 6 Oct 2026 15:27:16 +0530
Subject: [PATCH 10/31] fix(editor): preserve drawing defaults and wall grab
offsets
---
.../editor/src/components/tools/stair/stair-tool.tsx | 2 +-
packages/editor/src/lib/wall-placement-pointer.ts | 9 ++++++---
packages/nodes/src/fence/drafting.ts | 11 +++++++++--
3 files changed, 16 insertions(+), 6 deletions(-)
diff --git a/packages/editor/src/components/tools/stair/stair-tool.tsx b/packages/editor/src/components/tools/stair/stair-tool.tsx
index 921fb2f3b7..9f06ca01a4 100644
--- a/packages/editor/src/components/tools/stair/stair-tool.tsx
+++ b/packages/editor/src/components/tools/stair/stair-tool.tsx
@@ -328,6 +328,7 @@ export const StairTool: React.FC = () => {
const ownedGeometryRef = useRef(previewGeometry)
const geometryRiseRef = useRef(DEFAULT_LEVEL_HEIGHT)
useEffect(() => () => ownedGeometryRef.current.dispose(), [])
+ useEffect(() => () => useEditor.getState().setToolDefaults('stair', null), [])
useEffect(() => {
if (!currentLevelId) return
@@ -742,7 +743,6 @@ 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/lib/wall-placement-pointer.ts b/packages/editor/src/lib/wall-placement-pointer.ts
index 75ddef07ed..d72fb0f4ab 100644
--- a/packages/editor/src/lib/wall-placement-pointer.ts
+++ b/packages/editor/src/lib/wall-placement-pointer.ts
@@ -10,6 +10,7 @@ export function createWallPointerTracker(source?: {
position: readonly number[]
}) {
let forgotten = !source
+ let grabOffset: { x: number; y: number } | null = null
let anchor: { wallId: string; side: WallSide; x: number; y: number } | null = null
return {
resolve(wallId: string, side: WallSide, point: Point): [number, number] {
@@ -17,18 +18,20 @@ export function createWallPointerTracker(source?: {
const preserve =
!forgotten && source?.wallId === wallId && (!source.side || source.side === side)
if (!preserve) forgotten = true
+ if (preserve && !grabOffset) {
+ grabOffset = { x: source!.position[0]! - point[0], y: source!.position[1]! - point[1] }
+ }
anchor = {
wallId,
side,
- x: preserve ? source!.position[0]! - point[0] : 0,
- y: preserve ? source!.position[1]! - point[1] : 0,
+ x: preserve ? grabOffset!.x : 0,
+ y: preserve ? grabOffset!.y : 0,
}
}
return [point[0] + anchor.x, point[1] + anchor.y]
},
leave() {
anchor = null
- forgotten = true
},
}
}
diff --git a/packages/nodes/src/fence/drafting.ts b/packages/nodes/src/fence/drafting.ts
index e2417c9bce..2975bec3b2 100644
--- a/packages/nodes/src/fence/drafting.ts
+++ b/packages/nodes/src/fence/drafting.ts
@@ -295,6 +295,13 @@ export function snapFenceDraftPoint(args: {
return fenceSnapTarget ?? findWallSnapTarget(basePoint, walls) ?? basePoint
}
+function getFenceDefaultsForCurrentLevel() {
+ const defaults = useEditor.getState().toolDefaults.fence ?? {}
+ if (typeof defaults.supportSurfaceId !== 'string' || getFenceDrawingSurface()) return defaults
+ const { supportSurfaceId: _supportSurfaceId, ...unhostedDefaults } = defaults
+ return unhostedDefaults
+}
+
export function createFenceOnCurrentLevel(
start: FencePlanPoint,
end: FencePlanPoint,
@@ -313,7 +320,7 @@ export function createFenceOnCurrentLevel(
// spacing, …) merge in first; `name`/`start`/`end` always win. The
// schema parse validates and drops anything unexpected.
const defaults = {
- ...useEditor.getState().toolDefaults.fence,
+ ...getFenceDefaultsForCurrentLevel(),
...getFenceInheritedDefaults(start, context),
}
const authoredFence = FenceNode.parse({
@@ -359,7 +366,7 @@ export function createSplineFenceOnCurrentLevel(
const fenceCount = Object.values(nodes).filter((node) => node.type === 'fence').length
const defaults = {
- ...useEditor.getState().toolDefaults.fence,
+ ...getFenceDefaultsForCurrentLevel(),
...getFenceInheritedDefaults(start, context),
}
const authoredFence = FenceNode.parse({
From f581e3e318985a13eeedef9794a96dc3c9a6e8ef Mon Sep 17 00:00:00 2001
From: sudhir
Date: Tue, 6 Oct 2026 15:36:04 +0530
Subject: [PATCH 11/31] fix(editor): separate snap yaw and reset grabs on host
transitions
---
.../tools/item/use-placement-coordinator.tsx | 2 ++
.../editor/src/components/tools/stair/stair-tool.tsx | 10 ++++------
packages/editor/src/lib/wall-placement-pointer.ts | 5 +++++
3 files changed, 11 insertions(+), 6 deletions(-)
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 2e777c976f..babca55aea 100644
--- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx
+++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx
@@ -1164,6 +1164,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
if (!cursorGroupRef.current) return
const result = floorStrategy.move(getContext(), floorEvent)
if (!result) return
+ grabForgotten = true
// Figma-style alignment snap layered on top of the floor strategy's
// grid snap: when the draft's edge lines up (on X or Z) with another
@@ -1394,6 +1395,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
let wallMoveEvent = event
if (preserveDragOffset && draftNode.current) {
preserveGrabOn(event.node.id, grabWallId)
+ if (grabForgotten) wallPointer.forget()
const [correctedX, correctedY] = wallPointer.resolve(
event.node.id,
getSideFromNormal(event.normal),
diff --git a/packages/editor/src/components/tools/stair/stair-tool.tsx b/packages/editor/src/components/tools/stair/stair-tool.tsx
index 9f06ca01a4..b9141afb4e 100644
--- a/packages/editor/src/components/tools/stair/stair-tool.tsx
+++ b/packages/editor/src/components/tools/stair/stair-tool.tsx
@@ -430,7 +430,8 @@ export const StairTool: React.FC = () => {
rotation: number,
supportSurface: PointerSupportSurface | null,
) => {
- const key = `${position[0].toFixed(3)},${position[2].toFixed(3)},${rotation.toFixed(4)},${supportSurface?.elevation.toFixed(3) ?? 'none'},${supportSurface?.sourceNodeId ?? 'floor'},${previewRiseRef.current}`
+ rotation = landscapeSnapRef.current?.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'}`
if (key === lastPreviewKey) return
lastPreviewKey = key
const preview = buildPreviewScene(position, rotation, supportSurface)
@@ -627,10 +628,7 @@ export const StairTool: React.FC = () => {
useScene.getState().installedPlugins,
)
landscapeSnapRef.current = snap
- if (snap) {
- rotationRef.current = snap.rotation
- return snap.position
- }
+ if (snap) return snap.position
return candidate
}
@@ -676,7 +674,7 @@ export const StairTool: React.FC = () => {
commitStairPlacement(
currentLevelId,
position,
- rotationRef.current,
+ landscapeSnapRef.current?.rotation ?? rotationRef.current,
supportSurfaceRef.current,
landscapeSnapRef.current,
)
diff --git a/packages/editor/src/lib/wall-placement-pointer.ts b/packages/editor/src/lib/wall-placement-pointer.ts
index d72fb0f4ab..06d288bbb8 100644
--- a/packages/editor/src/lib/wall-placement-pointer.ts
+++ b/packages/editor/src/lib/wall-placement-pointer.ts
@@ -33,6 +33,11 @@ export function createWallPointerTracker(source?: {
leave() {
anchor = null
},
+ forget() {
+ anchor = null
+ grabOffset = null
+ forgotten = true
+ },
}
}
From 838597f12bc8934aab16009904ad73f511f1569c Mon Sep 17 00:00:00 2001
From: sudhir
Date: Tue, 6 Oct 2026 15:36:53 +0530
Subject: [PATCH 12/31] fix(editor): forget wall grabs when entering another
host
---
.../tools/item/use-placement-coordinator.tsx | 11 +++++++----
1 file changed, 7 insertions(+), 4 deletions(-)
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 babca55aea..f60e0e84c1 100644
--- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx
+++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx
@@ -824,10 +824,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
// Alignment guides are floor-only; clear them when the cursor moves
// onto a wall / ceiling / item surface (only those paths call this).
useAlignmentGuides.getState().clear()
- // Roof faces carry no grab anchor, but landing on one still counts as
- // anchoring elsewhere — a later return to the grabbed wall must center
- // under the cursor, not restore the stale grab offset.
- if (result.stateUpdate.surface === 'roof-wall') grabForgotten = true
+ // A floor transition can be a hit gap; other hosted surfaces are a new anchor.
+ if (
+ result.stateUpdate.surface &&
+ result.stateUpdate.surface !== 'floor' &&
+ result.stateUpdate.surface !== 'wall'
+ )
+ grabForgotten = true
Object.assign(placementState.current, result.stateUpdate)
gridPosition.current.set(...result.gridPosition)
From b7c9123cecb58733a4abac6f6e9143b4ceda52e8 Mon Sep 17 00:00:00 2001
From: sudhir
Date: Tue, 6 Oct 2026 15:44:36 +0530
Subject: [PATCH 13/31] fix(editor): retain tall stair attachment and wall
grabs through floor gaps
---
packages/core/src/services/stair-surface-snap.ts | 2 +-
.../src/components/tools/item/use-placement-coordinator.tsx | 3 ++-
2 files changed, 3 insertions(+), 2 deletions(-)
diff --git a/packages/core/src/services/stair-surface-snap.ts b/packages/core/src/services/stair-surface-snap.ts
index 2201628042..c778997917 100644
--- a/packages/core/src/services/stair-surface-snap.ts
+++ b/packages/core/src/services/stair-surface-snap.ts
@@ -68,7 +68,7 @@ export function resolveStairSurfaceSnap(
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
+ if (outside < -SNAP_DISTANCE) 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
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 f60e0e84c1..63fb397e79 100644
--- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx
+++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx
@@ -1167,7 +1167,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
if (!cursorGroupRef.current) return
const result = floorStrategy.move(getContext(), floorEvent)
if (!result) return
- grabForgotten = true
+ // Attached items cannot land on the floor; its hit can be a gap in their host.
+ if (!asset.attachTo) grabForgotten = true
// Figma-style alignment snap layered on top of the floor strategy's
// grid snap: when the draft's edge lines up (on X or Z) with another
From 05cde6d701837263bf55ab0daa99e19ef4033872 Mon Sep 17 00:00:00 2001
From: sudhir
Date: Wed, 7 Oct 2026 10:51:28 +0530
Subject: [PATCH 14/31] fix(editor): integrate surface workflows with upstream
fence and stair APIs
---
.../src/components/editor/floorplan-panel.tsx | 7 +-
.../use-floorplan-background-placement.ts | 1 -
.../src/components/tools/stair/stair-tool.tsx | 69 +++++--
packages/editor/src/index.tsx | 11 --
.../src/store/use-stair-build-preview.ts | 31 +++-
packages/nodes/src/fence/drafting.ts | 14 +-
packages/nodes/src/fence/geometry.ts | 6 +-
packages/nodes/src/fence/system.tsx | 6 +-
packages/nodes/src/fence/tool.tsx | 66 ++++++-
packages/nodes/src/stair/floorplan.ts | 25 ++-
packages/nodes/src/stair/renderer.tsx | 169 +++++++++++++++++-
11 files changed, 351 insertions(+), 54 deletions(-)
diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx
index 9733a05b1d..d6fa6c9074 100644
--- a/packages/editor/src/components/editor/floorplan-panel.tsx
+++ b/packages/editor/src/components/editor/floorplan-panel.tsx
@@ -4203,6 +4203,8 @@ function FloorplanStairBuildPreviewLayer({
const point = useStairBuildPreview((s) => s.point)
const rotation = useStairBuildPreview((s) => s.rotation)
const rise = useStairBuildPreview((s) => s.rise)
+ const length = useStairBuildPreview((s) => s.length)
+ const stepCount = useStairBuildPreview((s) => s.stepCount)
const isActive = mode === 'build' && tool === 'stair'
const previewGeometry = useMemo(() => {
@@ -4211,6 +4213,8 @@ function FloorplanStairBuildPreviewLayer({
}
const previewSegment = {
...createSizedStairFlight(rise),
+ ...(length !== null ? { length } : {}),
+ ...(stepCount !== null ? { stepCount } : {}),
id: 'sseg_floorplan_preview' as const,
parentId: 'stair_floorplan_preview' as const,
}
@@ -4236,7 +4240,7 @@ function FloorplanStairBuildPreviewLayer({
}),
) ?? null
)
- }, [isActive, point, rotation, rise, palette])
+ }, [isActive, point, rotation, rise, length, stepCount, palette])
if (!previewGeometry) return null
return
@@ -9321,7 +9325,6 @@ export function FloorplanPanel({
)
const fenceGridBase = snapWallPointToGrid(planPoint)
const fenceLocked =
- getFenceDrawingSurface() !== null ||
event.altKey ||
fenceSnapped[0] !== fenceGridBase[0] ||
fenceSnapped[1] !== fenceGridBase[1]
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 63c6a15979..c62d584a28 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,6 @@ export function useFloorplanBackgroundPlacement({
)
const fenceGridBase = worldGridSnap(planPoint, fenceStep)
const fenceLocked =
- getFenceDrawingSurface() !== null ||
event.altKey ||
fenceSnapped[0] !== fenceGridBase[0] ||
fenceSnapped[1] !== fenceGridBase[1]
diff --git a/packages/editor/src/components/tools/stair/stair-tool.tsx b/packages/editor/src/components/tools/stair/stair-tool.tsx
index b9141afb4e..dd701156b3 100644
--- a/packages/editor/src/components/tools/stair/stair-tool.tsx
+++ b/packages/editor/src/components/tools/stair/stair-tool.tsx
@@ -25,7 +25,7 @@ import {
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useThree } from '@react-three/fiber'
-import { useEffect, useMemo, useRef, useState } from 'react'
+import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three'
import { EDITOR_LAYER } from '../../../lib/constants'
import { sfxEmitter } from '../../../lib/sfx-bus'
@@ -76,8 +76,16 @@ type MoveTriggerEvent = GridEvent | NodeEvent
* Generates the step-profile geometry for the ghost preview.
* Same algorithm as StairSystem's generateStairSegmentGeometry.
*/
-function createStairPreviewGeometry(rise: number): THREE.BufferGeometry {
- const { stepCount, length, riserHeight, going: treadDepth } = planStairSizing(rise)
+function createStairPreviewGeometry(
+ rise: number,
+ count?: number,
+ run?: number,
+): THREE.BufferGeometry {
+ const sizing = planStairSizing(rise)
+ const stepCount = count ?? sizing.stepCount
+ const length = run ?? sizing.length
+ const riserHeight = rise / stepCount
+ const treadDepth = length / stepCount
const shape = new THREE.Shape()
shape.moveTo(0, 0)
@@ -261,6 +269,13 @@ function commitStairPlacement(
if (error instanceof RangeError) return
throw error
}
+ if (landscapeSnap)
+ segment = {
+ ...segment,
+ height: landscapeSnap.totalRise,
+ length: landscapeSnap.length,
+ stepCount: landscapeSnap.stepCount,
+ }
stair.stepCount = segment.stepCount
const prospectiveNodes = {
...nodes,
@@ -326,7 +341,7 @@ export const StairTool: React.FC = () => {
previewRiseRef.current = previewRise
const previewGeometry = useMemo(() => createStairPreviewGeometry(DEFAULT_LEVEL_HEIGHT), [])
const ownedGeometryRef = useRef(previewGeometry)
- const geometryRiseRef = useRef(DEFAULT_LEVEL_HEIGHT)
+ const geometryRiseRef = useRef(DEFAULT_LEVEL_HEIGHT)
useEffect(() => () => ownedGeometryRef.current.dispose(), [])
useEffect(() => () => useEditor.getState().setToolDefaults('stair', null), [])
@@ -403,17 +418,29 @@ export const StairTool: React.FC = () => {
if (error instanceof RangeError) return null
throw error
}
+ if (landscapeSnap) {
+ stair.totalRise = landscapeSnap.totalRise
+ stair.landscapeSurfaceId = landscapeSnap.surfaceId
+ stair.slabOpeningMode = 'none'
+ stair.railingMode = 'none'
+ segment = {
+ ...segment,
+ height: landscapeSnap.totalRise,
+ length: landscapeSnap.length,
+ stepCount: landscapeSnap.stepCount,
+ }
+ }
stair.stepCount = segment.stepCount
const previewNodes = {
...nodes,
...(destinationPlan?.createdLevel
? { [destinationPlan.createdLevel.id]: destinationPlan.createdLevel }
: {}),
- [stair.id]: { ...previewStair, parentId: placementLevelId },
+ [stair.id]: { ...stair, parentId: placementLevelId },
[segment.id]: { ...segment, parentId: stair.id },
} as Record
- return { placementLevelId, previewNodes, stair: previewStair, rise: segment.height }
+ return { placementLevelId, previewNodes, stair, rise: segment.height }
}
// The preview rebuild (full-scene copy + destination-level resolution +
@@ -430,14 +457,21 @@ export const StairTool: React.FC = () => {
rotation: number,
supportSurface: PointerSupportSurface | null,
) => {
- rotation = landscapeSnapRef.current?.rotation ?? rotation
+ 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'}`
if (key === lastPreviewKey) return
lastPreviewKey = key
- const preview = buildPreviewScene(position, rotation, supportSurface)
+ const preview = buildPreviewScene(position, rotation, supportSurface, landscapeSnap)
useStairBuildPreview
.getState()
- .setPreview(preview ? [position[0], position[2]] : null, rotation, preview?.rise ?? null)
+ .setPreview(
+ preview ? [position[0], position[2]] : null,
+ rotation,
+ preview?.rise ?? null,
+ landscapeSnap?.length,
+ landscapeSnap?.stepCount,
+ )
const frozenPatch =
preview && supportSurface?.sourceNodeId && !landscapeSnap
? resolveFrozenFloorPlacementPatch(preview.stair, preview.previewNodes, {
@@ -475,12 +509,21 @@ export const StairTool: React.FC = () => {
previewRef.current.visible = Boolean(preview)
previewRef.current.position.set(...visualPosition)
previewRef.current.rotation.y = rotation
- if (preview && previewMeshRef.current && preview.rise !== geometryRiseRef.current) {
+ if (
+ preview &&
+ previewMeshRef.current &&
+ `${preview.rise}:${landscapeSnap?.stepCount}:${landscapeSnap?.length}` !==
+ String(geometryRiseRef.current)
+ ) {
const previous = previewMeshRef.current.geometry
- previewMeshRef.current.geometry = createStairPreviewGeometry(preview.rise)
+ previewMeshRef.current.geometry = createStairPreviewGeometry(
+ preview.rise,
+ landscapeSnap?.stepCount,
+ landscapeSnap?.length,
+ )
previous.dispose()
ownedGeometryRef.current = previewMeshRef.current.geometry
- geometryRiseRef.current = preview.rise
+ geometryRiseRef.current = `${preview.rise}:${landscapeSnap?.stepCount}:${landscapeSnap?.length}`
}
previewRef.current.scale.y = 1
}
@@ -623,7 +666,7 @@ export const StairTool: React.FC = () => {
draft,
nodes,
candidate,
- DEFAULT_STAIR_LENGTH,
+ planStairSizing(previewRiseRef.current).length,
baseElevation,
useScene.getState().installedPlugins,
)
diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx
index 6897c89d34..fec93f2e38 100644
--- a/packages/editor/src/index.tsx
+++ b/packages/editor/src/index.tsx
@@ -115,17 +115,6 @@ export type {
SelectionAffordanceProps,
} from './components/systems/selection-affordance-services'
export { StairEditSystem } from './components/systems/stair/stair-edit-system'
-// Phase 5 Stage D transitional exports — pure drafting / angle helpers
-// consumed by kind-owned drag actions in @pascal-app/nodes. Stage F
-// cleanup moves these into @pascal-app/nodes (fence/drafting.ts +
-// shared/segment-angle.ts) once every Stage D port is in.
-export {
- createFenceOnCurrentLevel,
- createSplineFenceOnCurrentLevel,
- type FencePlanPoint,
- getFenceDrawingSurface,
- snapFenceDraftPoint,
-} from './components/tools/fence/fence-drafting'
export { MoveTool } from './components/tools/item/move-tool'
// Placement-math helpers — shared by kind-owned placement tools in
// `@pascal-app/nodes` (wall curve sagitta snap, door / window placement,
diff --git a/packages/editor/src/store/use-stair-build-preview.ts b/packages/editor/src/store/use-stair-build-preview.ts
index d3dc04bc6b..1d9f5d7934 100644
--- a/packages/editor/src/store/use-stair-build-preview.ts
+++ b/packages/editor/src/store/use-stair-build-preview.ts
@@ -17,13 +17,20 @@ type StairBuildPreviewState = {
point: StairPreviewPoint | null
/** Yaw (radians), cycled by R / T. */
rotation: number
+ length: number | null
+ stepCount: number | null
rise: number | null
/** Set the snapped point. No-ops (skips the store update, so subscribers
* don't re-render) when the point is unchanged — `grid:move` fires far more
* often than the snapped cell actually changes. */
setPoint(point: StairPreviewPoint | null): void
- setPreview(point: StairPreviewPoint | null, rotation: number, rise?: number | null): void
- rotateBy(deltaRadians: number): void
+ setPreview(
+ point: StairPreviewPoint | null,
+ rotation: number,
+ rise?: number | null,
+ length?: number | null,
+ stepCount?: number | null,
+ ): void
reset(): void
}
@@ -31,6 +38,8 @@ export const useStairBuildPreview = create((set) => ({
point: null,
rotation: 0,
rise: null,
+ length: null,
+ stepCount: null,
setPoint: (point) =>
set((state) => {
const prev = state.point
@@ -38,23 +47,33 @@ export const useStairBuildPreview = create((set) => ({
if (point && prev && prev[0] === point[0] && prev[1] === point[1]) return state
return { point: point ? [point[0], point[1]] : null }
}),
- setPreview: (point, rotation, rise) =>
+ setPreview: (point, rotation, rise, length = null, stepCount = null) =>
set((state) => {
const samePoint =
(!point && !state.point) ||
Boolean(point && state.point && state.point[0] === point[0] && state.point[1] === point[1])
- return samePoint && state.rotation === rotation && (rise === undefined || rise === state.rise)
+ return samePoint &&
+ state.rotation === rotation &&
+ (rise === undefined || rise === state.rise) &&
+ length === state.length &&
+ stepCount === state.stepCount
? state
: {
point: point ? [point[0], point[1]] : null,
rotation,
+ length,
+ stepCount,
rise: rise === undefined ? state.rise : rise,
}
}),
reset: () =>
set((state) =>
- state.point === null && state.rotation === 0 && state.rise === null
+ state.point === null &&
+ state.rotation === 0 &&
+ state.rise === null &&
+ state.length === null &&
+ state.stepCount === null
? state
- : { point: null, rotation: 0, rise: null },
+ : { point: null, rotation: 0, rise: null, length: null, stepCount: null },
),
}))
diff --git a/packages/nodes/src/fence/drafting.ts b/packages/nodes/src/fence/drafting.ts
index 2975bec3b2..9f7dd60bc4 100644
--- a/packages/nodes/src/fence/drafting.ts
+++ b/packages/nodes/src/fence/drafting.ts
@@ -1,4 +1,3 @@
-import { getLevelPresentationY, useViewer } from '@pascal-app/viewer'
import {
type AnyNode,
type AnyNodeId,
@@ -6,13 +5,14 @@ import {
type FenceConstructionOptions as FenceCommitOptions,
FenceNode,
floorConstructionLift,
- nodeRegistry,
getFenceCenterlineLength,
getFenceSplineLength,
+ nodeRegistry,
resolveFenceConstructionSupport,
type SceneApi,
sampleFenceCenterline,
snapPointAlongAngleRay,
+ useScene,
type WallNode,
} from '@pascal-app/core'
import {
@@ -24,6 +24,7 @@ import {
useEditor,
type WallPlanPoint,
} from '@pascal-app/editor'
+import { getLevelPresentationY, useViewer } from '@pascal-app/viewer'
export type FencePlanPoint = WallPlanPoint
@@ -95,9 +96,6 @@ export function getFenceInheritedDefaults(
const FENCE_CORNER_SNAP_RADIUS = 0.28
const FENCE_SPAN_SNAP_RADIUS = 0.16
-const FENCE_CORNER_SNAP_RADIUS = 0.28
-const FENCE_SPAN_SNAP_RADIUS = 0.16
-
export function getFenceDrawingSurface(
nodes: Record = useScene.getState().nodes,
id = useEditor.getState().toolDefaults.fence?.supportSurfaceId,
@@ -276,7 +274,9 @@ export function snapFenceDraftPoint(args: {
if (start && angleSnap) {
const rawTarget =
magnetic &&
- (findFenceSnapTarget(point, fences, ignoreFenceIds) ?? findWallSnapTarget(point, walls))
+ (findSurfaceSnapTarget(point) ??
+ findFenceSnapTarget(point, fences, ignoreFenceIds) ??
+ findWallSnapTarget(point, walls))
if (rawTarget) return rawTarget
}
@@ -291,6 +291,8 @@ export function snapFenceDraftPoint(args: {
: snapPointToGrid(point, gridStep)
if (!magnetic) return basePoint
+ const surfaceSnapTarget = findSurfaceSnapTarget(basePoint)
+ if (surfaceSnapTarget) return surfaceSnapTarget
const fenceSnapTarget = findFenceSnapTarget(basePoint, fences, ignoreFenceIds)
return fenceSnapTarget ?? findWallSnapTarget(basePoint, walls) ?? basePoint
}
diff --git a/packages/nodes/src/fence/geometry.ts b/packages/nodes/src/fence/geometry.ts
index 73a8b1282c..606eb19705 100644
--- a/packages/nodes/src/fence/geometry.ts
+++ b/packages/nodes/src/fence/geometry.ts
@@ -149,7 +149,7 @@ export function buildFenceGeometry(
const nodes = ctx ? (plateLevelContext(ctx.parent, ctx.resolve).nodes ?? {}) : {}
const constructionLift = floorConstructionLift(nodes, node)
const startGround = (ctx?.levelBaseAt?.(node.start[0], node.start[1]) ?? 0) + constructionLift
- const surfaceId = node.supportSurfaceNodeId as AnyNodeId | undefined
+ const surfaceId = (node.supportSurfaceNodeId ?? node.supportSurfaceId) as AnyNodeId | undefined
const surfaceAt = surfaceId
? (x: number, z: number) => ctx?.surfaceHeightAt?.(surfaceId, x, z) ?? null
: undefined
@@ -158,7 +158,7 @@ export function buildFenceGeometry(
const followsTerrain = (node.path?.length ?? 0) >= 2 || Math.abs(node.curveOffset ?? 0) > 1e-4
const chosenHost =
node.surfaceMode === 'selected'
- ? (node.supportSurfaceNodeId as AnyNodeId | undefined)
+ ? ((node.supportSurfaceNodeId ?? node.supportSurfaceId) as AnyNodeId | undefined)
: undefined
const sampledSupport =
followsTerrain && !node.supportSlabId && ctx?.supportHeightAt
@@ -208,7 +208,7 @@ export function buildFenceGeometry(
return host?.type === 'slab' ? liftedManualSlab(nodes, host) : host
},
startGround,
- nodes,
+ nodes,
)
: 0
const lift = supportAt
diff --git a/packages/nodes/src/fence/system.tsx b/packages/nodes/src/fence/system.tsx
index b2dbd4cf23..9fdfd8ca85 100644
--- a/packages/nodes/src/fence/system.tsx
+++ b/packages/nodes/src/fence/system.tsx
@@ -103,9 +103,13 @@ const FenceSystems = () => {
if (followsSurfaces(fence)) {
const level = findLevelAncestorId(fence.id as AnyNodeId, currentNodes)
if (level && changedLevels.has(level as AnyNodeId)) markFenceAndChildren(fence.id)
- } else if (fence.supportSlabId || fence.supportSurfaceNodeId) {
+ } else if (fence.supportSlabId || fence.supportSurfaceNodeId || fence.supportSurfaceId) {
const previous = previousNodes[fence.id as AnyNodeId] as FenceNode | undefined
if (
+ previous?.supportSurfaceId !== fence.supportSurfaceId ||
+ (fence.supportSurfaceId &&
+ previousNodes[fence.supportSurfaceId as AnyNodeId] !==
+ currentNodes[fence.supportSurfaceId as AnyNodeId]) ||
previous?.supportSurfaceNodeId !== fence.supportSurfaceNodeId ||
(fence.supportSurfaceNodeId &&
previousNodes[fence.supportSurfaceNodeId as AnyNodeId] !==
diff --git a/packages/nodes/src/fence/tool.tsx b/packages/nodes/src/fence/tool.tsx
index 377aaa3fc1..18e29fc2ec 100644
--- a/packages/nodes/src/fence/tool.tsx
+++ b/packages/nodes/src/fence/tool.tsx
@@ -29,7 +29,6 @@ import {
formatLinearMeasurement,
getAngleArcToSegmentReference,
getAngleToSegmentReference,
- getFenceDrawingSurface,
getSegmentAngleReferenceAtPoint,
isAlignmentGuideActive,
isAngleSnapActive,
@@ -40,7 +39,6 @@ import {
publishPlacementSurface,
resolvePointerSupportSurface,
type SegmentAngleReference,
- snapScalarToGrid,
triggerSFX,
useAlignmentGuides,
useEditor,
@@ -51,7 +49,12 @@ import {
useSegmentDraftChain,
} from '@pascal-app/editor'
-import { createSceneSupportHeightSampler, getSceneTheme, useViewer } from '@pascal-app/viewer'
+import {
+ createSceneSupportHeightSampler,
+ getLevelPresentationY,
+ getSceneTheme,
+ useViewer,
+} from '@pascal-app/viewer'
import { useThree } from '@react-three/fiber'
import { useEffect, useMemo, useRef, useState } from 'react'
import {
@@ -76,6 +79,7 @@ import {
createFenceOnCurrentLevel,
createSplineFenceOnCurrentLevel,
type FencePlanPoint,
+ getFenceDrawingSurface,
getFenceInheritedDefaults,
snapFenceDraftPoint,
} from './drafting'
@@ -118,6 +122,40 @@ const SURFACE_UP = new Vector3(0, 1, 0)
const surfacePointScratch = new Vector3()
function pointedSurfaceFor(camera: Camera, event: GridEvent): PointerSupportSurface | null {
+ const surface = getFenceDrawingSurface()
+ if (surface) {
+ const buildingId = useViewer.getState().selection.buildingId
+ const buildingMesh = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
+ const point = new Vector3(
+ event.localPosition[0],
+ surface.levelElevation + surface.elevation,
+ event.localPosition[2],
+ )
+ if (buildingMesh) buildingMesh.localToWorld(point)
+ const worldY = point.y
+ if (event.nativeEvent?.target instanceof HTMLCanvasElement) {
+ const bounds = event.nativeEvent.target.getBoundingClientRect()
+ const raycaster = new Raycaster()
+ raycaster.setFromCamera(
+ new Vector2(
+ ((event.nativeEvent.clientX - bounds.left) / bounds.width) * 2 - 1,
+ -((event.nativeEvent.clientY - bounds.top) / bounds.height) * 2 + 1,
+ ),
+ camera,
+ )
+ if (!raycaster.ray.intersectPlane(new Plane(SURFACE_UP, -worldY), point)) return null
+ }
+ const worldPoint: [number, number, number] = [point.x, point.y, point.z]
+ if (buildingMesh) buildingMesh.worldToLocal(point)
+ return {
+ elevation: surface.elevation,
+ sourceNodeId: surface.id as PointerSupportSurface['sourceNodeId'],
+ supportSlabId: null,
+ localPoint: [point.x, point.y, point.z],
+ worldY,
+ worldPoint,
+ }
+ }
if (event.localRay) {
return resolvePointerSupportSurface(camera, event.position, { includeNodeTopSurfaces: true })
}
@@ -545,8 +583,13 @@ export const FenceTool: React.FC = () => {
>
)
}
- if (fenceMode === 'freehand') return
- return
+ if (fenceMode === 'freehand') return
+ return (
+ <>
+
+
+ >
+ )
}
const StraightFenceTool: React.FC = () => {
@@ -1047,11 +1090,16 @@ const SplineFenceDraft: React.FC<{ freehand?: boolean }> = ({ freehand = false }
)
useEffect(() => {
- const snapPoint = (local: FencePlanPoint): FencePlanPoint => {
+ const snapPoint = (local: FencePlanPoint, bypassSnap = false): FencePlanPoint => {
if (freehand) return local
- const step = isGridSnapActive() ? getSegmentGridStep() : 0
- if (step <= 0) return local
- return [snapScalarToGrid(local[0], step), snapScalarToGrid(local[1], step)]
+ const { walls, fences } = getCurrentLevelElements()
+ return snapFenceDraftPoint({
+ point: local,
+ walls,
+ fences,
+ magnetic: isMagneticSnapActive(),
+ bypassSnap,
+ })
}
const commit = (points = draftRef.current) => {
diff --git a/packages/nodes/src/stair/floorplan.ts b/packages/nodes/src/stair/floorplan.ts
index 9a73b4af3d..b12bcc2f90 100644
--- a/packages/nodes/src/stair/floorplan.ts
+++ b/packages/nodes/src/stair/floorplan.ts
@@ -42,7 +42,30 @@ export function buildStairFloorplan(
rawStair: StairNode,
ctx: GeometryContext,
): FloorplanGeometry | null {
- const stair = rawStair
+ const parent = ctx.parent as {
+ id?: string
+ type?: string
+ parentId?: string | null
+ position?: [number, number, number]
+ rotation?: [number, number, number]
+ } | null
+ let stair = rawStair
+ if (parent && parent.id === rawStair.landscapeSurfaceId && parent.position && parent.rotation) {
+ const angle = parent.rotation[1]
+ const c = Math.cos(angle),
+ s = Math.sin(angle)
+ const [x, y, z] = rawStair.position
+ stair = {
+ ...rawStair,
+ parentId: parent.parentId ?? null,
+ position: [
+ parent.position[0] + c * x + s * z,
+ parent.position[1] + y,
+ parent.position[2] - s * x + c * z,
+ ],
+ rotation: rawStair.rotation + angle,
+ }
+ }
const segments = (ctx.children ?? []).filter(
(child): child is StairSegmentNode => child.type === 'stair-segment',
)
diff --git a/packages/nodes/src/stair/renderer.tsx b/packages/nodes/src/stair/renderer.tsx
index 847f31a784..017f9e5fef 100644
--- a/packages/nodes/src/stair/renderer.tsx
+++ b/packages/nodes/src/stair/renderer.tsx
@@ -1,6 +1,7 @@
'use client'
import {
+ type AnyNodeId,
computeSegmentTransforms,
measureStairDetail,
resolveArcStairConstruction,
@@ -34,7 +35,6 @@ import { balusterGuardRails, buildBalusterGuard } from './baluster-guard'
import { boardsGuardRails, buildBoardsGuard } from './boards-guard'
import { buildCableGuard, cableGuardRails } from './cable-guard'
import { ContinuousStairRailings } from './continuous-railings'
-import { landscapeTransitionProfile } from './landscape-transition'
import {
barBox,
GUARD_PICKET_PITCH,
@@ -42,6 +42,7 @@ import {
type GuardBox,
type GuardRail,
} from './guard-path'
+import { landscapeTransitionProfile } from './landscape-transition'
import {
resolveStairBodySlotMaterials,
resolveStairSegmentMaterials,
@@ -213,6 +214,9 @@ export const StairRenderer = ({ node: rawNode }: { node: StairNode }) => {
) : null}
>
)}
+ {isSegmentBasedStair && node.landscapeSurfaceId && !detail.error ? (
+
+ ) : null}
{isSegmentBasedStair && !detail.error ? (
@@ -225,6 +229,169 @@ export const StairRenderer = ({ node: rawNode }: { node: StairNode }) => {
)
}
+function StairLandscapeTransition({
+ stair,
+ materials,
+}: {
+ stair: StairNode
+ materials: StairBodyMaterials
+}) {
+ const rawSurface = useScene((state) => state.nodes[stair.landscapeSurfaceId as AnyNodeId])
+ const surfaceOverride = useLiveNodeOverrides((state) =>
+ state.overrides.get(stair.landscapeSurfaceId as AnyNodeId),
+ )
+ const surface = useMemo(
+ () => (rawSurface && surfaceOverride ? { ...rawSurface, ...surfaceOverride } : rawSurface),
+ [rawSurface, surfaceOverride],
+ )
+ const flightId = stair.children[0]
+ const flight = useScene((state) => (flightId ? state.nodes[flightId] : undefined))
+ const flightOverride = useLiveNodeOverrides((state) =>
+ flightId ? state.overrides.get(flightId) : undefined,
+ )
+ const length =
+ (flightOverride?.length as number | undefined) ??
+ (flight as StairSegmentNode | undefined)?.length ??
+ 0
+ const segment = flight as StairSegmentNode | undefined
+ const fillToFloor =
+ (flightOverride?.fillToFloor as boolean | undefined) ??
+ segment?.fillToFloor ??
+ stair.fillToFloor ??
+ true
+ const thickness =
+ (flightOverride?.thickness as number | undefined) ??
+ segment?.thickness ??
+ stair.thickness ??
+ 0.25
+ const segmentHeight = (flightOverride?.height as number | undefined) ?? segment?.height ?? 0
+ const profile = useMemo(
+ () =>
+ surface
+ ? landscapeTransitionProfile(
+ stair,
+ surface as Parameters[1],
+ length,
+ )
+ : null,
+ [stair, surface, length],
+ )
+ const geometry = useMemo(() => {
+ if (!profile) return null
+ const vertices: number[] = []
+ const indices: number[] = []
+ const top = stair.totalRise ?? 0
+ const undersideOffset =
+ segment?.segmentType === 'landing'
+ ? thickness
+ : thickness / Math.cos(Math.atan2(segmentHeight, length || 1))
+ const bottom = fillToFloor ? 0 : top - undersideOffset
+ for (const [x, boundary] of profile.samples) {
+ vertices.push(
+ x,
+ top,
+ length + profile.frontZ,
+ x,
+ top,
+ length + boundary,
+ x,
+ bottom,
+ length + profile.frontZ,
+ x,
+ bottom,
+ length + boundary,
+ )
+ }
+ for (let i = 0; i < profile.samples.length - 1; i++) {
+ const a = i * 4,
+ b = (i + 1) * 4
+ indices.push(
+ a,
+ a + 1,
+ b,
+ b,
+ a + 1,
+ b + 1,
+ a + 2,
+ b + 2,
+ a + 3,
+ b + 2,
+ b + 3,
+ a + 3,
+ a,
+ b,
+ a + 2,
+ b,
+ b + 2,
+ a + 2,
+ a + 1,
+ a + 3,
+ b + 1,
+ b + 1,
+ a + 3,
+ b + 3,
+ )
+ }
+ const last = (profile.samples.length - 1) * 4
+ indices.push(0, 2, 1, 1, 2, 3, last, last + 1, last + 2, last + 1, last + 3, last + 2)
+ const indexed = new THREE.BufferGeometry()
+ indexed.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3))
+ indexed.setIndex(indices)
+ const result = indexed.toNonIndexed()
+ indexed.dispose()
+ result.computeVertexNormals()
+ const position = result.getAttribute('position')
+ const normals = result.getAttribute('normal')
+ const uvs: number[] = []
+ let currentMaterial = -1
+ let groupStart = 0
+ for (let i = 0; i < position.count; i += 3) {
+ const nx = Math.abs(normals.getX(i))
+ const ny = Math.abs(normals.getY(i))
+ const nz = Math.abs(normals.getZ(i))
+ const materialIndex =
+ normals.getY(i) > 0.75 ? STAIR_TREAD_MATERIAL_INDEX : STAIR_SIDE_MATERIAL_INDEX
+ if (materialIndex !== currentMaterial) {
+ if (i > groupStart) result.addGroup(groupStart, i - groupStart, currentMaterial)
+ groupStart = i
+ currentMaterial = materialIndex
+ }
+ for (let vertex = i; vertex < i + 3; vertex++) {
+ const x = position.getX(vertex),
+ y = position.getY(vertex),
+ z = position.getZ(vertex)
+ if (ny >= nx && ny >= nz) uvs.push(x, z)
+ else if (nx >= nz) uvs.push(z, y)
+ else uvs.push(x, y)
+ }
+ }
+ if (position.count > groupStart)
+ result.addGroup(groupStart, position.count - groupStart, currentMaterial)
+ result.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2))
+ result.setAttribute('uv2', new THREE.Float32BufferAttribute(uvs.slice(), 2))
+ return result
+ }, [
+ profile,
+ length,
+ stair.totalRise,
+ fillToFloor,
+ thickness,
+ segmentHeight,
+ segment?.segmentType,
+ ])
+ useEffect(() => () => geometry?.dispose(), [geometry])
+ return geometry ? (
+
+ ) : null
+}
+
function StairWalkingLine({
stair,
segments,
From af65ad15738471dc454453518ca164e6ae8e5262 Mon Sep 17 00:00:00 2001
From: sudhir
Date: Wed, 7 Oct 2026 11:04:51 +0530
Subject: [PATCH 15/31] fix(ci): pin WebXR reconciliation API update
---
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 e7711a053e..c22370428e 100644
--- a/apps/editor/package.json
+++ b/apps/editor/package.json
@@ -32,7 +32,7 @@
"@react-three/fiber": "^9.8.1",
"@react-three/xr": "^6.6.30",
"@tailwindcss/postcss": "^4.2.1",
- "@webxr/plugin": "github:sudhir9297/pascal-cluster#3dfbdf1e859368746e848489dab83f770b148255",
+ "@webxr/plugin": "github:sudhir9297/pascal-cluster#9cd90457fc823d5d633d38e9575856c854aeb4d4",
"clsx": "^2.1.1",
"geist": "^1.7.2",
"lucide-react": "^1.57.0",
diff --git a/bun.lock b/bun.lock
index c57cbe86d2..de80dc3ea4 100644
--- a/bun.lock
+++ b/bun.lock
@@ -49,7 +49,7 @@
"@react-three/fiber": "^9.8.1",
"@react-three/xr": "^6.6.30",
"@tailwindcss/postcss": "^4.2.1",
- "@webxr/plugin": "github:sudhir9297/pascal-cluster#3dfbdf1e859368746e848489dab83f770b148255",
+ "@webxr/plugin": "github:sudhir9297/pascal-cluster#9cd90457fc823d5d633d38e9575856c854aeb4d4",
"clsx": "^2.1.1",
"geist": "^1.7.2",
"lucide-react": "^1.57.0",
@@ -1144,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/pascal-cluster#3dfbdf1", { "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-3dfbdf1", "sha512-fSzyfg0N9mow6boHgZCwEXnI4XNVkaxVm8LL0GpnnrDIJ/RS6JliXm5IpCCscTYjlLbVeLjvaK9NI2wYZD4W5Q=="],
+ "@webxr/plugin": ["@webxr/plugin@github:sudhir9297/pascal-cluster#9cd9045", { "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-9cd9045", "sha512-/cAai/0CazOE7I2twUc0e5KxNfza7LQyA7QpUTYzBcwMH+l74b1zikU8zAqps2SNYtzufmAIroNQrVa93+sLFQ=="],
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
From c88ce924247d998485fce65e2994d737b68a568f Mon Sep 17 00:00:00 2001
From: sudhir
Date: Wed, 7 Oct 2026 11:05:51 +0530
Subject: [PATCH 16/31] fix(stair): resolve live segments for floorplan
documentation
---
packages/nodes/src/stair/floorplan.ts | 16 +++++++---------
1 file changed, 7 insertions(+), 9 deletions(-)
diff --git a/packages/nodes/src/stair/floorplan.ts b/packages/nodes/src/stair/floorplan.ts
index b12bcc2f90..ed3eb2365a 100644
--- a/packages/nodes/src/stair/floorplan.ts
+++ b/packages/nodes/src/stair/floorplan.ts
@@ -66,15 +66,13 @@ export function buildStairFloorplan(
rotation: rawStair.rotation + angle,
}
}
- const segments = (ctx.children ?? []).filter(
- (child): child is StairSegmentNode => child.type === 'stair-segment',
- )
- const detail = measureStairDetail(
- stair,
- (ctx.children ?? []).filter(
- (child): child is StairSegmentNode => child.type === 'stair-segment',
- ),
- )
+ const segments = (ctx.children ?? [])
+ .map((child) => ctx.resolve(child.id) ?? child)
+ .filter(
+ (child): child is StairSegmentNode =>
+ child.type === 'stair-segment' && child.visible !== false,
+ )
+ const detail = measureStairDetail(stair, segments)
const entry = buildFloorplanStairEntry(stair, segments, !!detail.error)
if (!entry) return null
if (detail.error)
From 8ed2b2d25ab6096b9bbe3ae1f87595d08b1ae115 Mon Sep 17 00:00:00 2001
From: sudhir
Date: Wed, 7 Oct 2026 18:05:21 +0530
Subject: [PATCH 17/31] fix(editor): address plugin and surface workflow
architecture review
---
.../editor/components/pool-section-banner.tsx | 11 ++-
apps/editor/lib/bootstrap.ts | 39 ++++++----
apps/editor/package.json | 2 +-
bun.lock | 4 +-
.../core/src/contracts/reference-inventory.ts | 8 +-
.../hooks/spatial-grid/support-host-patch.ts | 2 +-
.../src/lib/surface-support-references.ts | 2 +-
packages/core/src/schema/nodes/fence.ts | 7 +-
packages/core/src/store/use-scene.ts | 7 ++
.../src/components/tools/tool-manager.tsx | 2 -
packages/editor/src/index.tsx | 18 -----
packages/nodes/src/fence/definition.ts | 2 +-
packages/nodes/src/fence/drafting.ts | 6 +-
packages/nodes/src/fence/geometry.ts | 4 +-
packages/nodes/src/fence/lift.ts | 8 +-
packages/nodes/src/fence/system.tsx | 6 +-
packages/nodes/src/fence/tool.tsx | 22 ++----
packages/nodes/src/stair/definition.ts | 2 +
packages/nodes/src/stair/floorplan-move.ts | 27 ++++---
.../src}/stair/stair-click-guard.test.ts | 0
.../src}/stair/stair-click-guard.ts | 0
.../src}/stair/stair-defaults.ts | 0
packages/nodes/src/stair/stair-type.ts | 2 +-
.../src/stair/tool.tsx} | 73 +++++++++----------
24 files changed, 117 insertions(+), 137 deletions(-)
rename packages/{editor/src/components/tools => nodes/src}/stair/stair-click-guard.test.ts (100%)
rename packages/{editor/src/components/tools => nodes/src}/stair/stair-click-guard.ts (100%)
rename packages/{editor/src/components/tools => nodes/src}/stair/stair-defaults.ts (100%)
rename packages/{editor/src/components/tools/stair/stair-tool.tsx => nodes/src/stair/tool.tsx} (94%)
diff --git a/apps/editor/components/pool-section-banner.tsx b/apps/editor/components/pool-section-banner.tsx
index 6e249de9f6..711f1f069c 100644
--- a/apps/editor/components/pool-section-banner.tsx
+++ b/apps/editor/components/pool-section-banner.tsx
@@ -1,11 +1,18 @@
'use client'
-import { PoolSectionBar } from '@pascal-app/plugin-pool'
+import { lazy, Suspense } from 'react'
+
+const PoolSectionBar = lazy(async () => {
+ const pool = await import('@pascal-app/plugin-pool')
+ return { default: pool.PoolSectionBar }
+})
export function PoolSectionBanner() {
return (
)
}
diff --git a/apps/editor/lib/bootstrap.ts b/apps/editor/lib/bootstrap.ts
index 59d4ef2bc7..7b4013fcad 100644
--- a/apps/editor/lib/bootstrap.ts
+++ b/apps/editor/lib/bootstrap.ts
@@ -9,16 +9,12 @@ import {
} from '@pascal-app/core'
import { registerEditorHostPanel } from '@pascal-app/editor'
import { builtinPlugin } from '@pascal-app/nodes'
-import { bathSpaceHostPanel, bathSpacePlugin } from '@pascal-app/plugin-bath-space'
import { bonesHostPanel, bonesPlugin } from '@pascal-app/plugin-bones'
import {
environmentHostPanel,
environmentPlugin,
environmentPresentation,
} from '@pascal-app/plugin-environment'
-import { landscapeHostPanel, landscapePlugin } from '@pascal-app/plugin-landscape'
-import { poolHostPanel, poolPlugin } from '@pascal-app/plugin-pool'
-import { streetscapeHostPanel, streetscapePlugin } from '@pascal-app/plugin-streetscape-lab'
import { treesHostPanel, treesPlugin } from '@pascal-app/plugin-trees'
import { registerViewerPresentation } from '@pascal-app/viewer'
import { webXRHostPanel, webXRPlugin } from '@webxr/plugin'
@@ -107,17 +103,30 @@ extendPluginDiscovery(async () => [bonesPlugin])
registerEditorHostPanel({ ...bonesHostPanel, defaultInstalled: false })
extendPluginDiscovery(async () => [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({
- ...streetscapeHostPanel,
- creator: { name: 'Sudhir Yadav', url: 'https://github.com/sudhir9297' },
+extendPluginDiscovery(async () => {
+ const { poolHostPanel, poolPlugin } = await import('@pascal-app/plugin-pool')
+ registerEditorHostPanel(poolHostPanel)
+ return [poolPlugin]
+})
+extendPluginDiscovery(async () => {
+ const { landscapeHostPanel, landscapePlugin } = await import('@pascal-app/plugin-landscape')
+ registerEditorHostPanel(landscapeHostPanel)
+ return [landscapePlugin]
+})
+extendPluginDiscovery(async () => {
+ const { bathSpaceHostPanel, bathSpacePlugin } = await import('@pascal-app/plugin-bath-space')
+ registerEditorHostPanel(bathSpaceHostPanel)
+ return [bathSpacePlugin]
+})
+extendPluginDiscovery(async () => {
+ const { streetscapeHostPanel, streetscapePlugin } = await import(
+ '@pascal-app/plugin-streetscape-lab'
+ )
+ registerEditorHostPanel({
+ ...streetscapeHostPanel,
+ creator: { name: 'Sudhir Yadav', url: 'https://github.com/sudhir9297' },
+ })
+ return [streetscapePlugin]
})
extendPluginDiscovery(async () => [webXRPlugin])
registerEditorHostPanel(webXRHostPanel)
diff --git a/apps/editor/package.json b/apps/editor/package.json
index c22370428e..6a6dae0cff 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#8f3c0db2be7a33b19f406ecb35e23417ff850dcc",
- "@pascal-app/plugin-landscape": "github:sudhir9297/pascal-cluster#98cb3d13b0e49133774aad1783c7f3e032cb2daf",
+ "@pascal-app/plugin-landscape": "github:sudhir9297/pascal-cluster#aaa2a4801209c3bcb614dd7e0b6d5aca6dfbbdcf",
"@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",
diff --git a/bun.lock b/bun.lock
index de80dc3ea4..dd5e99480e 100644
--- a/bun.lock
+++ b/bun.lock
@@ -39,7 +39,7 @@
"@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-landscape": "github:sudhir9297/pascal-cluster#98cb3d13b0e49133774aad1783c7f3e032cb2daf",
+ "@pascal-app/plugin-landscape": "github:sudhir9297/pascal-cluster#aaa2a4801209c3bcb614dd7e0b6d5aca6dfbbdcf",
"@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",
@@ -836,7 +836,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#98cb3d1", { "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-98cb3d1", "sha512-XyoTDKs+8D3dYVlzeSOy6nP2D158WX4U8WrqN122y3T+Sr7AXaWhHvNKUNMfrusvFPT/LwCPqTWHGM4zbCPo4Q=="],
+ "@pascal-app/plugin-landscape": ["@pascal-app/plugin-landscape@github:sudhir9297/pascal-cluster#aaa2a48", { "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-aaa2a48", "sha512-jsgEITBltWoP9vDFbvTypHJj2C0/klcZSox8C43Y8O3Ae0T89PYjZJqWAnYYXJRoBuxSjqxRL+oZhEyhxr5n2g=="],
"@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=="],
diff --git a/packages/core/src/contracts/reference-inventory.ts b/packages/core/src/contracts/reference-inventory.ts
index 979e6baa0a..09d44f5490 100644
--- a/packages/core/src/contracts/reference-inventory.ts
+++ b/packages/core/src/contracts/reference-inventory.ts
@@ -226,12 +226,6 @@ export const EXISTING_REFERENCES: readonly ExistingReference[] = [
remaps: [...CLONES, 'delete-nodes'],
note: "'ground' pins the node to the level base; deleting the slab strips the field.",
}),
- row({
- kind: 'fence',
- path: 'supportSurfaceNodeId',
- ...policy('node', 'host', 'drop', 'strip'),
- remaps: ['clone-scene-graph'],
- }),
row({
kind: 'stair',
path: 'deckSlabId',
@@ -241,7 +235,7 @@ export const EXISTING_REFERENCES: readonly ExistingReference[] = [
}),
row({
kind: 'fence',
- path: 'supportSurfaceId',
+ path: 'supportSurfaceNodeId',
...policy('node', 'host', 'drop', 'strip'),
remaps: [...CLONES, 'clone-nodes-into', 'delete-nodes'],
note: 'A registered walking surface supporting a railing.',
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 caeabd7032..d09f1ff894 100644
--- a/packages/core/src/hooks/spatial-grid/support-host-patch.ts
+++ b/packages/core/src/hooks/spatial-grid/support-host-patch.ts
@@ -386,7 +386,7 @@ export function resolveFenceConstructionSupport(
supportSurfaceNodeId: options.supportSurfaceNodeId,
}
}
- const surface = fence.supportSurfaceId ? nodes[fence.supportSurfaceId] : null
+ const surface = fence.supportSurfaceNodeId ? nodes[fence.supportSurfaceNodeId] : null
if (
surface?.parentId === levelId &&
nodeRegistry.get(surface.type)?.capabilities?.surfaces?.top
diff --git a/packages/core/src/lib/surface-support-references.ts b/packages/core/src/lib/surface-support-references.ts
index d24b0f769f..ff380ce756 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 = ['supportSurfaceId', 'landscapeSurfaceId'] as const
+const fields = ['supportSurfaceNodeId', 'landscapeSurfaceId'] as const
export function remapSurfaceSupportReferences(
node: AnyNode,
diff --git a/packages/core/src/schema/nodes/fence.ts b/packages/core/src/schema/nodes/fence.ts
index a336296f4d..ef71e5a909 100644
--- a/packages/core/src/schema/nodes/fence.ts
+++ b/packages/core/src/schema/nodes/fence.ts
@@ -81,11 +81,8 @@ export const FenceNode = BaseNode.extend({
// Top surface selected when drawing on a shaped node. The sampled surface
// 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.
+ // Manual vertical offset from the elected slab, shaped surface, or level
+ // support. 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/store/use-scene.ts b/packages/core/src/store/use-scene.ts
index ba97963e7f..9b12a6a23d 100644
--- a/packages/core/src/store/use-scene.ts
+++ b/packages/core/src/store/use-scene.ts
@@ -642,6 +642,13 @@ 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] }
diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx
index d560493783..e7128bc87f 100644
--- a/packages/editor/src/components/tools/tool-manager.tsx
+++ b/packages/editor/src/components/tools/tool-manager.tsx
@@ -52,7 +52,6 @@ import {
import { FacingPoseIndicator } from './shared/facing-pose-indicator'
import { SiteBoundaryEditor } from './site/site-boundary-editor'
import { TerrainSculptTool } from './site/terrain-sculpt-tool'
-import { StairTool } from './stair/stair-tool'
import { ZoneBoundaryEditor } from './zone/zone-boundary-editor'
import { ZoneTool } from './zone/zone-tool'
@@ -113,7 +112,6 @@ function getRegistryTool(tool: Tool | null): ComponentType | null {
// item / shelf / spawn now go through the registry path above.
const tools: Partial> = {
'property-line': SiteBoundaryEditor,
- stair: StairTool,
zone: ZoneTool,
}
diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx
index fec93f2e38..d0a104e319 100644
--- a/packages/editor/src/index.tsx
+++ b/packages/editor/src/index.tsx
@@ -177,24 +177,6 @@ export {
getSegmentAngleReferenceAtPoint,
type SegmentAngleReference,
} from './components/tools/shared/segment-angle'
-// Stair placement defaults — used by the kind-owned stair / stair-segment
-// panels. Re-exported from `components/tools/stair/stair-defaults.ts`.
-export {
- 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_RAILING_HEIGHT,
- DEFAULT_STAIR_RAILING_MODE,
- DEFAULT_STAIR_THICKNESS,
- DEFAULT_STAIR_TYPE,
- DEFAULT_STAIR_WIDTH,
-} from './components/tools/stair/stair-defaults'
export { preloadRegistryToolModules, ToolManager } from './components/tools/tool-manager'
export {
chainEndJoinsExistingWall,
diff --git a/packages/nodes/src/fence/definition.ts b/packages/nodes/src/fence/definition.ts
index de15384e58..f94057027a 100644
--- a/packages/nodes/src/fence/definition.ts
+++ b/packages/nodes/src/fence/definition.ts
@@ -385,7 +385,7 @@ export const fenceDefinition: NodeDefinition = {
capabilities: {
reveal: { phase: 'structure', style: 'rise' },
- hostRefFields: ['supportSurfaceId'],
+ hostRefFields: ['supportSurfaceNodeId'],
surfacePlacement: 'floor-only',
selectable: { hitVolume: 'bbox' },
surfaces: { sides: { faces: 'all' }, hosting: false },
diff --git a/packages/nodes/src/fence/drafting.ts b/packages/nodes/src/fence/drafting.ts
index 9f7dd60bc4..f4c6c41d39 100644
--- a/packages/nodes/src/fence/drafting.ts
+++ b/packages/nodes/src/fence/drafting.ts
@@ -98,7 +98,7 @@ const FENCE_SPAN_SNAP_RADIUS = 0.16
export function getFenceDrawingSurface(
nodes: Record = useScene.getState().nodes,
- id = useEditor.getState().toolDefaults.fence?.supportSurfaceId,
+ id = useEditor.getState().toolDefaults.fence?.supportSurfaceNodeId,
levelId = useViewer.getState().selection.levelId,
levelMode = useViewer.getState().levelMode,
) {
@@ -299,8 +299,8 @@ export function snapFenceDraftPoint(args: {
function getFenceDefaultsForCurrentLevel() {
const defaults = useEditor.getState().toolDefaults.fence ?? {}
- if (typeof defaults.supportSurfaceId !== 'string' || getFenceDrawingSurface()) return defaults
- const { supportSurfaceId: _supportSurfaceId, ...unhostedDefaults } = defaults
+ if (typeof defaults.supportSurfaceNodeId !== 'string' || getFenceDrawingSurface()) return defaults
+ const { supportSurfaceNodeId: _supportSurfaceNodeId, ...unhostedDefaults } = defaults
return unhostedDefaults
}
diff --git a/packages/nodes/src/fence/geometry.ts b/packages/nodes/src/fence/geometry.ts
index 606eb19705..67970bd3b9 100644
--- a/packages/nodes/src/fence/geometry.ts
+++ b/packages/nodes/src/fence/geometry.ts
@@ -149,7 +149,7 @@ export function buildFenceGeometry(
const nodes = ctx ? (plateLevelContext(ctx.parent, ctx.resolve).nodes ?? {}) : {}
const constructionLift = floorConstructionLift(nodes, node)
const startGround = (ctx?.levelBaseAt?.(node.start[0], node.start[1]) ?? 0) + constructionLift
- const surfaceId = (node.supportSurfaceNodeId ?? node.supportSurfaceId) as AnyNodeId | undefined
+ const surfaceId = node.supportSurfaceNodeId as AnyNodeId | undefined
const surfaceAt = surfaceId
? (x: number, z: number) => ctx?.surfaceHeightAt?.(surfaceId, x, z) ?? null
: undefined
@@ -158,7 +158,7 @@ export function buildFenceGeometry(
const followsTerrain = (node.path?.length ?? 0) >= 2 || Math.abs(node.curveOffset ?? 0) > 1e-4
const chosenHost =
node.surfaceMode === 'selected'
- ? ((node.supportSurfaceNodeId ?? node.supportSurfaceId) as AnyNodeId | undefined)
+ ? (node.supportSurfaceNodeId as AnyNodeId | undefined)
: undefined
const sampledSupport =
followsTerrain && !node.supportSlabId && ctx?.supportHeightAt
diff --git a/packages/nodes/src/fence/lift.ts b/packages/nodes/src/fence/lift.ts
index 997dd095a9..9831916768 100644
--- a/packages/nodes/src/fence/lift.ts
+++ b/packages/nodes/src/fence/lift.ts
@@ -32,14 +32,14 @@ import type { FenceNode } from './schema'
* instead of following the hillside around it.
*/
export function resolveFenceLiftElevation(
- node: Pick,
+ node: Pick,
resolve: (id: string) => AnyNode | undefined,
levelBase = 0,
nodes: Record = {},
): number {
const offset = node.supportOffset ?? 0
- if (node.supportSurfaceId) {
- const surface = resolve(node.supportSurfaceId)
+ if (node.supportSurfaceNodeId) {
+ const surface = resolve(node.supportSurfaceNodeId)
const top = surface && nodeRegistry.get(surface.type)?.capabilities?.surfaces?.top
if (surface && top && surface.parentId === node.parentId) {
const height = typeof top.height === 'function' ? top.height(surface, { nodes }) : top.height
@@ -70,7 +70,7 @@ export function resolveFenceLiftElevation(
export function resolveFenceLiftElevationForNodes(
node: Pick<
FenceNode,
- 'id' | 'start' | 'supportSlabId' | 'supportSurfaceId' | 'supportOffset' | 'parentId'
+ 'id' | 'start' | 'supportSlabId' | 'supportSurfaceNodeId' | 'supportOffset' | 'parentId'
>,
nodes: Record,
): number {
diff --git a/packages/nodes/src/fence/system.tsx b/packages/nodes/src/fence/system.tsx
index 9fdfd8ca85..b2dbd4cf23 100644
--- a/packages/nodes/src/fence/system.tsx
+++ b/packages/nodes/src/fence/system.tsx
@@ -103,13 +103,9 @@ const FenceSystems = () => {
if (followsSurfaces(fence)) {
const level = findLevelAncestorId(fence.id as AnyNodeId, currentNodes)
if (level && changedLevels.has(level as AnyNodeId)) markFenceAndChildren(fence.id)
- } else if (fence.supportSlabId || fence.supportSurfaceNodeId || fence.supportSurfaceId) {
+ } else if (fence.supportSlabId || fence.supportSurfaceNodeId) {
const previous = previousNodes[fence.id as AnyNodeId] as FenceNode | undefined
if (
- previous?.supportSurfaceId !== fence.supportSurfaceId ||
- (fence.supportSurfaceId &&
- previousNodes[fence.supportSurfaceId as AnyNodeId] !==
- currentNodes[fence.supportSurfaceId as AnyNodeId]) ||
previous?.supportSurfaceNodeId !== fence.supportSurfaceNodeId ||
(fence.supportSurfaceNodeId &&
previousNodes[fence.supportSurfaceNodeId as AnyNodeId] !==
diff --git a/packages/nodes/src/fence/tool.tsx b/packages/nodes/src/fence/tool.tsx
index 18e29fc2ec..0a9f5fd66b 100644
--- a/packages/nodes/src/fence/tool.tsx
+++ b/packages/nodes/src/fence/tool.tsx
@@ -90,7 +90,7 @@ const FENCE_PREVIEW_HEIGHT = 1.8
const FENCE_PREVIEW_THICKNESS = 0.08
function FenceSurfaceSnapPoints() {
- const hostId = useEditor((state) => state.toolDefaults.fence?.supportSurfaceId)
+ const hostId = useEditor((state) => state.toolDefaults.fence?.supportSurfaceNodeId)
const levelId = useViewer((state) => state.selection.levelId)
const levelMode = useViewer((state) => state.levelMode)
useScene((state) => (typeof hostId === 'string' ? state.nodes[hostId as AnyNodeId] : undefined))
@@ -559,7 +559,7 @@ function getCurrentLevelElements(): { walls: WallNode[]; fences: FenceNode[] } {
export const FenceTool: React.FC = () => {
const levelId = useViewer((state) => state.selection.levelId)
const levelMode = useViewer((state) => state.levelMode)
- const hostId = useEditor((state) => state.toolDefaults.fence?.supportSurfaceId)
+ const hostId = useEditor((state) => state.toolDefaults.fence?.supportSurfaceNodeId)
const hostParentId = useScene((state) =>
typeof hostId === 'string' ? state.nodes[hostId as AnyNodeId]?.parentId : undefined,
)
@@ -736,7 +736,6 @@ const StraightFenceTool: React.FC = () => {
start: angleLocked ? [startingPoint.current.x, startingPoint.current.z] : undefined,
angleSnap: angleLocked,
magnetic: isMagneticSnapActive(),
- bypassSnap: event.nativeEvent.altKey,
}),
{ applySnap: !angleLocked },
)
@@ -789,7 +788,6 @@ const StraightFenceTool: React.FC = () => {
walls,
fences,
magnetic: isMagneticSnapActive(),
- bypassSnap: event.nativeEvent.altKey,
}),
)
cursorRef.current.position.set(snappedPoint[0], activeY, snappedPoint[1])
@@ -819,7 +817,6 @@ const StraightFenceTool: React.FC = () => {
walls,
fences,
magnetic: isMagneticSnapActive(),
- bypassSnap: event.nativeEvent.altKey,
}),
)
startingPoint.current.set(
@@ -852,7 +849,6 @@ const StraightFenceTool: React.FC = () => {
start: angleLocked ? [startingPoint.current.x, startingPoint.current.z] : undefined,
angleSnap: angleLocked,
magnetic: isMagneticSnapActive(),
- bypassSnap: event.nativeEvent.altKey,
}),
{ applySnap: !angleLocked },
)
@@ -1090,7 +1086,7 @@ const SplineFenceDraft: React.FC<{ freehand?: boolean }> = ({ freehand = false }
)
useEffect(() => {
- const snapPoint = (local: FencePlanPoint, bypassSnap = false): FencePlanPoint => {
+ const snapPoint = (local: FencePlanPoint): FencePlanPoint => {
if (freehand) return local
const { walls, fences } = getCurrentLevelElements()
return snapFenceDraftPoint({
@@ -1098,7 +1094,6 @@ const SplineFenceDraft: React.FC<{ freehand?: boolean }> = ({ freehand = false }
walls,
fences,
magnetic: isMagneticSnapActive(),
- bypassSnap,
})
}
@@ -1197,13 +1192,10 @@ const SplineFenceDraft: React.FC<{ freehand?: boolean }> = ({ freehand = false }
commit()
return
}
- const point = snapPoint(
- [
- pointed?.localPoint?.[0] ?? event.localPosition[0],
- pointed?.localPoint?.[2] ?? event.localPosition[2],
- ],
- event.nativeEvent.altKey,
- )
+ const point = snapPoint([
+ pointed?.localPoint?.[0] ?? event.localPosition[0],
+ pointed?.localPoint?.[2] ?? event.localPosition[2],
+ ])
triggerSFX('sfx:grid-snap')
setDraftPoints((prev) => [...prev, point])
}
diff --git a/packages/nodes/src/stair/definition.ts b/packages/nodes/src/stair/definition.ts
index 9dd072d170..7e820fc8bd 100644
--- a/packages/nodes/src/stair/definition.ts
+++ b/packages/nodes/src/stair/definition.ts
@@ -468,6 +468,8 @@ export const stairDefinition: NodeDefinition = {
move: () => import('../shared/move-roof-tool'),
},
+ tool: () => import('./tool'),
+
parametrics: stairParametrics,
handles: stairHandles,
diff --git a/packages/nodes/src/stair/floorplan-move.ts b/packages/nodes/src/stair/floorplan-move.ts
index 249d5132eb..16ed739fe3 100644
--- a/packages/nodes/src/stair/floorplan-move.ts
+++ b/packages/nodes/src/stair/floorplan-move.ts
@@ -55,8 +55,8 @@ export const stairFloorplanMoveTarget: FloorplanMoveTarget = ({
const session: FloorplanMoveTargetSession = {
affectedIds: flight ? [node.id as AnyNodeId, flight.id as AnyNodeId] : [node.id as AnyNodeId],
- apply({ planPoint, modifiers }) {
- const step = !modifiers.altKey && isGridSnapActive() ? getSegmentGridStep() : 0
+ apply({ planPoint }) {
+ const step = isGridSnapActive() ? getSegmentGridStep() : 0
const snap = (value: number) => (step > 0 ? snapScalar(value, step) : value)
const [gx, gz] = resolveCursor(planPoint, { snap })
// Figma alignment on the actual stair footprint, matching the 3D move
@@ -68,7 +68,7 @@ export const stairFloorplanMoveTarget: FloorplanMoveTarget = ({
? movingAnchors
: [{ nodeId: node.id, kind: 'corner', x: gx, z: gz }],
candidates,
- { applySnap: !modifiers.altKey && isMagneticSnapActive() },
+ { applySnap: isMagneticSnapActive() },
)
const sx = aligned[0]
const sz = aligned[1]
@@ -81,17 +81,16 @@ export const stairFloorplanMoveTarget: FloorplanMoveTarget = ({
rotation: node.rotation,
levelId: node.parentId,
})[1]
- landscapeSnap =
- modifiers.altKey || !isMagneticSnapActive()
- ? null
- : resolveStairSurfaceSnap(
- node,
- sceneNodes,
- [sx, startY, sz],
- flight?.length ?? 3,
- baseElevation,
- sceneApi?.installedPlugins?.(),
- )
+ landscapeSnap = !isMagneticSnapActive()
+ ? null
+ : resolveStairSurfaceSnap(
+ node,
+ sceneNodes,
+ [sx, startY, sz],
+ flight?.length ?? 3,
+ baseElevation,
+ sceneApi?.installedPlugins?.(),
+ )
const position = landscapeSnap?.position ?? ([sx, startY, sz] as [number, number, number])
if (
lastValid &&
diff --git a/packages/editor/src/components/tools/stair/stair-click-guard.test.ts b/packages/nodes/src/stair/stair-click-guard.test.ts
similarity index 100%
rename from packages/editor/src/components/tools/stair/stair-click-guard.test.ts
rename to packages/nodes/src/stair/stair-click-guard.test.ts
diff --git a/packages/editor/src/components/tools/stair/stair-click-guard.ts b/packages/nodes/src/stair/stair-click-guard.ts
similarity index 100%
rename from packages/editor/src/components/tools/stair/stair-click-guard.ts
rename to packages/nodes/src/stair/stair-click-guard.ts
diff --git a/packages/editor/src/components/tools/stair/stair-defaults.ts b/packages/nodes/src/stair/stair-defaults.ts
similarity index 100%
rename from packages/editor/src/components/tools/stair/stair-defaults.ts
rename to packages/nodes/src/stair/stair-defaults.ts
diff --git a/packages/nodes/src/stair/stair-type.ts b/packages/nodes/src/stair/stair-type.ts
index 718fd8a815..fe69eb1b46 100644
--- a/packages/nodes/src/stair/stair-type.ts
+++ b/packages/nodes/src/stair/stair-type.ts
@@ -6,7 +6,7 @@ import {
type StairSegmentNode,
type StairType,
} from '@pascal-app/core'
-import { DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE } from '@pascal-app/editor'
+import { DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE } from './stair-defaults'
export type StairTypeChange = {
updates: Partial
diff --git a/packages/editor/src/components/tools/stair/stair-tool.tsx b/packages/nodes/src/stair/tool.tsx
similarity index 94%
rename from packages/editor/src/components/tools/stair/stair-tool.tsx
rename to packages/nodes/src/stair/tool.tsx
index dd701156b3..f8804cd17b 100644
--- a/packages/editor/src/components/tools/stair/stair-tool.tsx
+++ b/packages/nodes/src/stair/tool.tsx
@@ -1,3 +1,5 @@
+'use client'
+
import {
type AnyNode,
collectAlignmentAnchors,
@@ -23,31 +25,28 @@ import {
syncAutoStairOpenings,
useScene,
} from '@pascal-app/core'
-import { useViewer } from '@pascal-app/viewer'
-import { useThree } from '@react-three/fiber'
-import { useEffect, useMemo, useRef } from 'react'
-import * as THREE from 'three'
-import { EDITOR_LAYER } from '../../../lib/constants'
-import { sfxEmitter } from '../../../lib/sfx-bus'
import {
- resolveStairDestinationLevel,
- resolveStairPlacementLevelId,
-} from '../../../lib/stair-levels'
-import { isTypingTarget } from '../../../lib/typing-target'
-import useAlignmentGuides from '../../../store/use-alignment-guides'
-import useEditor, {
+ CursorSphere,
+ EDITOR_LAYER,
+ getFloorStackPreviewPosition,
isAlignmentGuideActive,
isGridSnapActive,
isMagneticSnapActive,
-} from '../../../store/use-editor'
-import useFacingPose from '../../../store/use-facing-pose'
-import { useStairBuildPreview } from '../../../store/use-stair-build-preview'
-import { CursorSphere } from '../shared/cursor-sphere'
-import { getFloorStackPreviewPosition } from '../shared/floor-stack-preview'
-import {
+ isTypingTarget,
type PointerSupportSurface,
resolvePointerSupportSurface,
-} from '../shared/pointer-support-cap'
+ resolveStairDestinationLevel,
+ resolveStairPlacementLevelId,
+ triggerSFX,
+ useAlignmentGuides,
+ useEditor,
+ useFacingPose,
+ useStairBuildPreview,
+} from '@pascal-app/editor'
+import { useViewer } from '@pascal-app/viewer'
+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,
@@ -317,10 +316,10 @@ function commitStairPlacement(
update: destinationPlan?.liftedLevels ?? [],
})
- sfxEmitter.emit('sfx:structure-build')
+ triggerSFX('sfx:structure-build')
}
-export const StairTool: React.FC = () => {
+export default function StairTool() {
const camera = useThree((state) => state.camera)
const cameraRef = useRef(camera)
cameraRef.current = camera
@@ -626,15 +625,14 @@ 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(
- !bypassSnap && isGridSnapActive() ? Math.round(rawX / step) * step : rawX,
- !bypassSnap && isGridSnapActive() ? Math.round(rawZ / step) * step : rawZ,
+ isGridSnapActive() ? Math.round(rawX / step) * step : rawX,
+ isGridSnapActive() ? Math.round(rawZ / step) * step : rawZ,
rawX,
rawZ,
!isAlignmentGuideActive(),
- !bypassSnap && isMagneticSnapActive(),
+ isMagneticSnapActive(),
)
const nodes = useScene.getState().nodes
const levelId = resolveStairPlacementLevelId(
@@ -659,17 +657,16 @@ export const StairTool: React.FC = () => {
levelId,
})[1]
: candidate[1]
- const snap =
- event.nativeEvent?.altKey || !isMagneticSnapActive()
- ? null
- : resolveStairSurfaceSnap(
- draft,
- nodes,
- candidate,
- planStairSizing(previewRiseRef.current).length,
- baseElevation,
- useScene.getState().installedPlugins,
- )
+ const snap = !isMagneticSnapActive()
+ ? null
+ : resolveStairSurfaceSnap(
+ draft,
+ nodes,
+ candidate,
+ planStairSizing(previewRiseRef.current).length,
+ baseElevation,
+ useScene.getState().installedPlugins,
+ )
landscapeSnapRef.current = snap
if (snap) return snap.position
return candidate
@@ -687,7 +684,7 @@ export const StairTool: React.FC = () => {
previousGridPosRef.current &&
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
) {
- sfxEmitter.emit('sfx:grid-snap')
+ triggerSFX('sfx:grid-snap')
}
previousGridPosRef.current = [gridX, gridZ]
@@ -754,7 +751,7 @@ export const StairTool: React.FC = () => {
if (rotationDelta !== 0) {
event.preventDefault()
- sfxEmitter.emit('sfx:item-rotate')
+ triggerSFX('sfx:item-rotate')
rotationRef.current += rotationDelta
if (lastCanonicalPositionRef.current) {
applyDraftPreview(
From 7601a7b427647019c858c7b48ac228cf39a4c28c Mon Sep 17 00:00:00 2001
From: sudhir
Date: Wed, 7 Oct 2026 18:22:27 +0530
Subject: [PATCH 18/31] fix(ci): stabilize stair regressions and bound room
vertex scans
---
packages/nodes/src/stair/floorplan-move.test.ts | 5 ++++-
packages/nodes/src/stair/renderer.test.ts | 10 +++++++++-
2 files changed, 13 insertions(+), 2 deletions(-)
diff --git a/packages/nodes/src/stair/floorplan-move.test.ts b/packages/nodes/src/stair/floorplan-move.test.ts
index 0aa73bd383..5d4931b083 100644
--- a/packages/nodes/src/stair/floorplan-move.test.ts
+++ b/packages/nodes/src/stair/floorplan-move.test.ts
@@ -133,11 +133,14 @@ test('2D landscape attachment previews without scene writes and commits the flig
expect(useScene.getState().nodes[flight.id]).toEqual(flight)
})
-test('Alt releases the landscape preview and commits the free position', () => {
+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()
session.apply({ planPoint: [0.13, -2.17], modifiers: { ...modifiers, altKey: true } })
+ expect(getEffectiveNode(stair).landscapeSurfaceId).toBeDefined()
+ useEditor.getState().setSnappingMode('polygon', 'off')
+ session.apply({ planPoint: [0.13, -2.17], modifiers })
expect(getEffectiveNode(stair).landscapeSurfaceId).toBeUndefined()
expect(getEffectiveNode(flight)).toEqual(flight)
session.commit!()
diff --git a/packages/nodes/src/stair/renderer.test.ts b/packages/nodes/src/stair/renderer.test.ts
index 7bcdea2398..b72c1322d6 100644
--- a/packages/nodes/src/stair/renderer.test.ts
+++ b/packages/nodes/src/stair/renderer.test.ts
@@ -17,7 +17,13 @@ import {
useLiveNodeOverrides,
useScene,
} from '@pascal-app/core'
-import { StairEditSystem, ToolManager, useAlignmentGuides, useEditor } from '@pascal-app/editor'
+import {
+ preloadRegistryToolModules,
+ StairEditSystem,
+ ToolManager,
+ useAlignmentGuides,
+ useEditor,
+} from '@pascal-app/editor'
import { OVERLAY_LAYER, StairSystem, useViewer } from '@pascal-app/viewer'
import { act, create } from '@react-three/test-renderer'
import { createElement } from 'react'
@@ -286,6 +292,8 @@ test('stair placement preview follows raised support, survives level switches, a
camera.position.set(0, 10, 0)
camera.lookAt(0, 0, 0)
camera.updateMatrixWorld(true)
+ await loadPlugin(builtinPlugin)
+ await preloadRegistryToolModules('stair')
renderer = await create(createElement(ToolManager), { camera })
const move = { position: [0, 0, 0], localPosition: [0, 0, 0], nativeEvent: {} } as Parameters<
typeof emitter.emit<'grid:move'>
From bd0679dc99d0aa803fbd93df2338d758fdf1ac5f Mon Sep 17 00:00:00 2001
From: sudhir
Date: Fri, 9 Oct 2026 23:33:03 +0530
Subject: [PATCH 19/31] chore(plugins): replace local links with current GitHub
revisions
---
SETUP.md | 17 --------------
apps/editor/local-plugins.ts | 18 --------------
apps/editor/next.config.ts | 20 +---------------
apps/editor/package.json | 6 ++---
bun.lock | 12 +++++-----
package.json | 2 +-
scripts/link-local-plugins.ts | 44 -----------------------------------
7 files changed, 11 insertions(+), 108 deletions(-)
delete mode 100644 apps/editor/local-plugins.ts
delete mode 100644 scripts/link-local-plugins.ts
diff --git a/SETUP.md b/SETUP.md
index 21bf4fda1b..f17b5fc5b9 100644
--- a/SETUP.md
+++ b/SETUP.md
@@ -18,23 +18,6 @@ plugins. Open **+ → Plugins → Environment** to manage its installation for t
current project, then open **Environment** in the sidebar. No separate plugin
checkout, local tarball, or synchronization script is needed.
-## Develop local Pascal cluster plugins
-
-The committed dependencies use pinned package revisions so a clean checkout and
-CI can install without a sibling repository. To edit the plugins locally, add
-this setting to the editor repository's ignored `.env.local`:
-
-```dotenv
-PASCAL_LOCAL_PLUGINS_ROOT=/absolute/path/to/pascal-cluster
-```
-
-Run `bun install` to link the Bath Space, Landscape, Pool, Streetscape and WebXR
-package directories and share the editor's peer dependencies. Restart the dev
-server after changing the setting. CI and portable builds use the pinned
-packages in clean installs. Before a portable build from a locally linked
-checkout, remove the setting and run `bun install --force` to restore the pinned
-dependencies.
-
## Environment Variables (optional)
Copy `.env.example` to `.env` if you need:
diff --git a/apps/editor/local-plugins.ts b/apps/editor/local-plugins.ts
deleted file mode 100644
index 9c5a4638d6..0000000000
--- a/apps/editor/local-plugins.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import path from 'node:path'
-
-const packages = [
- ['@pascal-app/plugin-bath-space', 'bath-space'],
- ['@pascal-app/plugin-landscape', 'landscape'],
- ['@pascal-app/plugin-pool', 'pool'],
- ['@pascal-app/plugin-streetscape-lab', 'streetscape'],
- ['@webxr/plugin', 'webxr'],
-] as const
-
-export function getLocalPluginPackages() {
- const root = process.env.PASCAL_LOCAL_PLUGINS_ROOT
- if (!root || process.env.CI || process.env.PASCAL_PORTABLE_BUILD === '1') return []
- return packages.map(([name, folder]) => ({
- name,
- directory: path.resolve(root, 'packages', folder),
- }))
-}
diff --git a/apps/editor/next.config.ts b/apps/editor/next.config.ts
index 30aa91e6fd..2157280c2d 100644
--- a/apps/editor/next.config.ts
+++ b/apps/editor/next.config.ts
@@ -1,27 +1,9 @@
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import type { NextConfig } from 'next'
-import { getLocalPluginPackages } from './local-plugins'
const appDirectory = path.dirname(fileURLToPath(import.meta.url))
const portableBuild = process.env.PASCAL_PORTABLE_BUILD === '1'
-const localPluginPaths = getLocalPluginPackages().map((plugin) => plugin.directory)
-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
? { output: 'standalone' as const, outputFileTracingRoot: path.join(appDirectory, '../..') }
@@ -72,7 +54,7 @@ const nextConfig: NextConfig = {
'@dgreenheck/ez-tree',
],
turbopack: {
- root: turbopackRoot,
+ root: path.resolve(appDirectory, '../..'),
resolveAlias: {
react: './node_modules/react',
three: './node_modules/three',
diff --git a/apps/editor/package.json b/apps/editor/package.json
index 6a6dae0cff..4d877de02e 100644
--- a/apps/editor/package.json
+++ b/apps/editor/package.json
@@ -21,9 +21,9 @@
"@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#8f3c0db2be7a33b19f406ecb35e23417ff850dcc",
+ "@pascal-app/plugin-bath-space": "github:sudhir9297/pascal-cluster#34af749d09d7e063a1cbe595e58776b8de159381",
"@pascal-app/plugin-landscape": "github:sudhir9297/pascal-cluster#aaa2a4801209c3bcb614dd7e0b6d5aca6dfbbdcf",
- "@pascal-app/plugin-pool": "github:sudhir9297/pascal-cluster#92cb88c3c049e4c299062c962d9f6a0e579cc69b",
+ "@pascal-app/plugin-pool": "github:sudhir9297/pool-pascal-plugin#b8e408121feae51d91c89f7efe16f9d8f795cb49",
"@pascal-app/plugin-streetscape-lab": "github:sudhir9297/pascal-cluster#b31fe375fa47c53b7f792fb5cac7671c09667bd3",
"@pascal-app/plugin-trees": "github:pascalorg/plugin-trees#56d978cd9b409b716207b3f3d269455d3cd6f067",
"@pascal-app/viewer": "*",
@@ -32,7 +32,7 @@
"@react-three/fiber": "^9.8.1",
"@react-three/xr": "^6.6.30",
"@tailwindcss/postcss": "^4.2.1",
- "@webxr/plugin": "github:sudhir9297/pascal-cluster#9cd90457fc823d5d633d38e9575856c854aeb4d4",
+ "@webxr/plugin": "github:sudhir9297/webxr-pascal-plugin#9cd90457fc823d5d633d38e9575856c854aeb4d4",
"clsx": "^2.1.1",
"geist": "^1.7.2",
"lucide-react": "^1.57.0",
diff --git a/bun.lock b/bun.lock
index dd5e99480e..67eb65a75a 100644
--- a/bun.lock
+++ b/bun.lock
@@ -36,11 +36,11 @@
"@pascal-app/editor": "*",
"@pascal-app/mcp": "*",
"@pascal-app/nodes": "*",
- "@pascal-app/plugin-bath-space": "github:sudhir9297/pascal-cluster#8f3c0db2be7a33b19f406ecb35e23417ff850dcc",
+ "@pascal-app/plugin-bath-space": "github:sudhir9297/pascal-cluster#34af749d09d7e063a1cbe595e58776b8de159381",
"@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#aaa2a4801209c3bcb614dd7e0b6d5aca6dfbbdcf",
- "@pascal-app/plugin-pool": "github:sudhir9297/pascal-cluster#92cb88c3c049e4c299062c962d9f6a0e579cc69b",
+ "@pascal-app/plugin-pool": "github:sudhir9297/pool-pascal-plugin#b8e408121feae51d91c89f7efe16f9d8f795cb49",
"@pascal-app/plugin-streetscape-lab": "github:sudhir9297/pascal-cluster#b31fe375fa47c53b7f792fb5cac7671c09667bd3",
"@pascal-app/plugin-trees": "github:pascalorg/plugin-trees#56d978cd9b409b716207b3f3d269455d3cd6f067",
"@pascal-app/viewer": "*",
@@ -49,7 +49,7 @@
"@react-three/fiber": "^9.8.1",
"@react-three/xr": "^6.6.30",
"@tailwindcss/postcss": "^4.2.1",
- "@webxr/plugin": "github:sudhir9297/pascal-cluster#9cd90457fc823d5d633d38e9575856c854aeb4d4",
+ "@webxr/plugin": "github:sudhir9297/webxr-pascal-plugin#9cd90457fc823d5d633d38e9575856c854aeb4d4",
"clsx": "^2.1.1",
"geist": "^1.7.2",
"lucide-react": "^1.57.0",
@@ -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#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-bath-space": ["@pascal-app/plugin-bath-space@github:sudhir9297/pascal-cluster#34af749", { "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-34af749", "sha512-qHT7ciGB+4B8SPnzi6H3X1kn4mzqHebirKoXOOImGe4LtDibi0668wFo31l/50kDucehIzzmdUvpSQ5QSzD9Ag=="],
"@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=="],
@@ -838,7 +838,7 @@
"@pascal-app/plugin-landscape": ["@pascal-app/plugin-landscape@github:sudhir9297/pascal-cluster#aaa2a48", { "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-aaa2a48", "sha512-jsgEITBltWoP9vDFbvTypHJj2C0/klcZSox8C43Y8O3Ae0T89PYjZJqWAnYYXJRoBuxSjqxRL+oZhEyhxr5n2g=="],
- "@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-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#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=="],
@@ -1144,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/pascal-cluster#9cd9045", { "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-9cd9045", "sha512-/cAai/0CazOE7I2twUc0e5KxNfza7LQyA7QpUTYzBcwMH+l74b1zikU8zAqps2SNYtzufmAIroNQrVa93+sLFQ=="],
+ "@webxr/plugin": ["@webxr/plugin@github:sudhir9297/webxr-pascal-plugin#9cd9045", { "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-9cd9045", "sha512-oICXf9DgdzLR+dVdAzp8z7knF+fZUUX2MJKZ/G2ljT5dXg6gmjPH16HWRy/Reb/hKILvcDGh5lw8RYQj5OBAuA=="],
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
diff --git a/package.json b/package.json
index 000fc34c6d..7852d7a836 100644
--- a/package.json
+++ b/package.json
@@ -15,7 +15,7 @@
"check-types": "turbo run check-types",
"test": "turbo run test",
"ci": "bun run check && bun run skills:validate && bun run check-types && bun run test && bun run build",
- "postinstall": "bun scripts/link-local-plugins.ts && ([ -n \"$CI\" ] || [ ! -e .git ] || git config core.hooksPath .githooks)",
+ "postinstall": "([ -n \"$CI\" ] || [ ! -e .git ] || git config core.hooksPath .githooks)",
"skills:validate": "bun scripts/validate-skills.ts && bun test scripts/cursor-plugin-policy.test.ts scripts/clawhub-ignore-policy.test.ts scripts/claude-mcp-config-policy.test.ts scripts/openai-tool-annotation-policy.test.ts scripts/path-containment.test.ts scripts/public-skill-discovery-policy.test.ts",
"kill": "lsof -ti:3002 | xargs kill -9 2>/dev/null || echo 'No processes found on port 3002'",
"clean:cache": "rm -rf apps/*/.next apps/*/.swc apps/*/.turbo packages/*/.turbo tooling/*/.turbo .turbo node_modules/.cache",
diff --git a/scripts/link-local-plugins.ts b/scripts/link-local-plugins.ts
deleted file mode 100644
index 65d4b40925..0000000000
--- a/scripts/link-local-plugins.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-import {
- existsSync,
- lstatSync,
- mkdirSync,
- readFileSync,
- realpathSync,
- rmSync,
- symlinkSync,
-} from 'node:fs'
-import path from 'node:path'
-import { getLocalPluginPackages } from '../apps/editor/local-plugins'
-
-const appDirectory = path.resolve(import.meta.dir, '../apps/editor')
-for (const { name, directory: target } of getLocalPluginPackages()) {
- const packageManifest = JSON.parse(readFileSync(path.join(target, 'package.json'), 'utf8'))
- if (packageManifest.name !== name)
- throw new Error(`Local dependency ${name} has a mismatched package name`)
- linkDirectory(target, path.join(appDirectory, 'node_modules', name))
- const sharedDependencies = [
- ...Object.keys(packageManifest.peerDependencies ?? {}),
- '@types/react',
- '@types/react-dom',
- '@types/three',
- ]
- for (const dependency of sharedDependencies) {
- const hostDependency = path.resolve(appDirectory, '../../node_modules', dependency)
- if (!existsSync(hostDependency)) continue
- linkDirectory(realpathSync(hostDependency), path.join(target, 'node_modules', dependency))
- }
-}
-
-function linkDirectory(target: string, destination: string) {
- if (
- existsSync(destination) &&
- lstatSync(destination).isSymbolicLink() &&
- realpathSync(destination) === realpathSync(target)
- )
- return
- // Bun materializes file dependencies with individual file symlinks. Turbopack
- // needs the package directory link to resolve package.json and its exports.
- rmSync(destination, { recursive: true, force: true })
- mkdirSync(path.dirname(destination), { recursive: true })
- symlinkSync(target, destination, 'dir')
-}
From 89d50deb0ac4375bd37e5fc29bf587e6cde13dcf Mon Sep 17 00:00:00 2001
From: sudhir
Date: Fri, 9 Oct 2026 23:42:32 +0530
Subject: [PATCH 20/31] fix(editor): integrate plugin phases after main rebase
---
apps/editor/package.json | 6 +++---
bun.lock | 12 ++++++------
packages/editor/src/store/use-stair-build-preview.ts | 2 ++
3 files changed, 11 insertions(+), 9 deletions(-)
diff --git a/apps/editor/package.json b/apps/editor/package.json
index 4d877de02e..5d8319e841 100644
--- a/apps/editor/package.json
+++ b/apps/editor/package.json
@@ -21,10 +21,10 @@
"@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#34af749d09d7e063a1cbe595e58776b8de159381",
- "@pascal-app/plugin-landscape": "github:sudhir9297/pascal-cluster#aaa2a4801209c3bcb614dd7e0b6d5aca6dfbbdcf",
+ "@pascal-app/plugin-bath-space": "github:sudhir9297/pascal-cluster#050b0744078da5671f98122814b39746b0a715cd",
+ "@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#b31fe375fa47c53b7f792fb5cac7671c09667bd3",
+ "@pascal-app/plugin-streetscape-lab": "github:sudhir9297/pascal-cluster#54095f28938a23f57c538c72eb8f94808fb2569c",
"@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 67eb65a75a..c8d452478d 100644
--- a/bun.lock
+++ b/bun.lock
@@ -36,12 +36,12 @@
"@pascal-app/editor": "*",
"@pascal-app/mcp": "*",
"@pascal-app/nodes": "*",
- "@pascal-app/plugin-bath-space": "github:sudhir9297/pascal-cluster#34af749d09d7e063a1cbe595e58776b8de159381",
+ "@pascal-app/plugin-bath-space": "github:sudhir9297/pascal-cluster#050b0744078da5671f98122814b39746b0a715cd",
"@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#aaa2a4801209c3bcb614dd7e0b6d5aca6dfbbdcf",
+ "@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#b31fe375fa47c53b7f792fb5cac7671c09667bd3",
+ "@pascal-app/plugin-streetscape-lab": "github:sudhir9297/pascal-cluster#54095f28938a23f57c538c72eb8f94808fb2569c",
"@pascal-app/plugin-trees": "github:pascalorg/plugin-trees#56d978cd9b409b716207b3f3d269455d3cd6f067",
"@pascal-app/viewer": "*",
"@radix-ui/react-tooltip": "^1.4.1",
@@ -830,17 +830,17 @@
"@pascal-app/nodes": ["@pascal-app/nodes@workspace:packages/nodes"],
- "@pascal-app/plugin-bath-space": ["@pascal-app/plugin-bath-space@github:sudhir9297/pascal-cluster#34af749", { "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-34af749", "sha512-qHT7ciGB+4B8SPnzi6H3X1kn4mzqHebirKoXOOImGe4LtDibi0668wFo31l/50kDucehIzzmdUvpSQ5QSzD9Ag=="],
+ "@pascal-app/plugin-bath-space": ["@pascal-app/plugin-bath-space@github:sudhir9297/pascal-cluster#050b074", { "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-050b074", "sha512-3pm8I7+jCxR2iOB7uB3zM+ny0Mf1/+xr3rgokV4jSszygw2eUuF0mYhOMh1NTF9xWq+gffzUkmIovRGs7QPXxw=="],
"@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-landscape": ["@pascal-app/plugin-landscape@github:sudhir9297/pascal-cluster#aaa2a48", { "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-aaa2a48", "sha512-jsgEITBltWoP9vDFbvTypHJj2C0/klcZSox8C43Y8O3Ae0T89PYjZJqWAnYYXJRoBuxSjqxRL+oZhEyhxr5n2g=="],
+ "@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-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#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-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-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/store/use-stair-build-preview.ts b/packages/editor/src/store/use-stair-build-preview.ts
index 1d9f5d7934..aad3593def 100644
--- a/packages/editor/src/store/use-stair-build-preview.ts
+++ b/packages/editor/src/store/use-stair-build-preview.ts
@@ -31,6 +31,7 @@ type StairBuildPreviewState = {
length?: number | null,
stepCount?: number | null,
): void
+ rotateBy(deltaRadians: number): void
reset(): void
}
@@ -66,6 +67,7 @@ export const useStairBuildPreview = create((set) => ({
rise: rise === undefined ? state.rise : rise,
}
}),
+ rotateBy: (deltaRadians) => set((state) => ({ rotation: state.rotation + deltaRadians })),
reset: () =>
set((state) =>
state.point === null &&
From 22272a41806580d4b47a8b2c0efbeaa609390511 Mon Sep 17 00:00:00 2001
From: sudhir
Date: Tue, 13 Oct 2026 12:33:43 +0530
Subject: [PATCH 21/31] chore(plugins): pin committed Bath Space guided UI
subtree
---
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 5d8319e841..22c04591de 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#050b0744078da5671f98122814b39746b0a715cd",
+ "@pascal-app/plugin-bath-space": "github:sudhir9297/pascal-cluster#685f2f8542348925e43947ee6cb566cd19144b49",
"@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 c8d452478d..0c8ed7860c 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#050b0744078da5671f98122814b39746b0a715cd",
+ "@pascal-app/plugin-bath-space": "github:sudhir9297/pascal-cluster#685f2f8542348925e43947ee6cb566cd19144b49",
"@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#050b074", { "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-050b074", "sha512-3pm8I7+jCxR2iOB7uB3zM+ny0Mf1/+xr3rgokV4jSszygw2eUuF0mYhOMh1NTF9xWq+gffzUkmIovRGs7QPXxw=="],
+ "@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-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 a1fc4277d2be224787803a9f3f210e04ec415fff Mon Sep 17 00:00:00 2001
From: sudhir
Date: Tue, 13 Oct 2026 12:41:36 +0530
Subject: [PATCH 22/31] fix(editor): resolve GitHub plugins from installed
packages
---
apps/editor/app/globals.css | 10 +++++-----
apps/editor/next.config.ts | 18 ++++++++++++++----
2 files changed, 19 insertions(+), 9 deletions(-)
diff --git a/apps/editor/app/globals.css b/apps/editor/app/globals.css
index 85ebc5a59a..3a865f5d41 100644
--- a/apps/editor/app/globals.css
+++ b/apps/editor/app/globals.css
@@ -5,13 +5,13 @@
@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-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";
+@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 *));
diff --git a/apps/editor/next.config.ts b/apps/editor/next.config.ts
index 2157280c2d..91e204455f 100644
--- a/apps/editor/next.config.ts
+++ b/apps/editor/next.config.ts
@@ -56,10 +56,20 @@ const nextConfig: NextConfig = {
turbopack: {
root: path.resolve(appDirectory, '../..'),
resolveAlias: {
- react: './node_modules/react',
- three: './node_modules/three',
- '@react-three/fiber': './node_modules/@react-three/fiber',
- '@react-three/drei': './node_modules/@react-three/drei',
+ '@pascal-app/core': './../../node_modules/@pascal-app/core',
+ '@pascal-app/editor': './../../node_modules/@pascal-app/editor',
+ '@pascal-app/viewer': './../../node_modules/@pascal-app/viewer',
+ '@pascal-app/nodes': './../../node_modules/@pascal-app/nodes',
+ react: './../../node_modules/react',
+ 'react-dom': './../../node_modules/react-dom',
+ three: './../../node_modules/three',
+ '@react-three/fiber': './../../node_modules/@react-three/fiber',
+ '@react-three/drei': './../../node_modules/@react-three/drei',
+ '@react-three/xr': './../../node_modules/@react-three/xr',
+ zustand: './../../node_modules/zustand',
+ zod: './../../node_modules/zod',
+ iwer: './../../node_modules/iwer',
+ '@iwer/devui': './../../node_modules/@iwer/devui',
},
},
experimental: {
From 3536afd9a9b2a30be9e21fbb912286fd32f4f138 Mon Sep 17 00:00:00 2001
From: sudhir
Date: Tue, 13 Oct 2026 13:15:12 +0530
Subject: [PATCH 23/31] fix(editor): preserve plugin autosave and simplify
editing UI
---
apps/editor/components/scene-loader.tsx | 16 +++++++++---
apps/editor/lib/graph-schema.test.ts | 14 ++++++++++
apps/editor/lib/graph-schema.ts | 13 ++++++++--
.../ui/helpers/contextual-helper-panel.tsx | 26 ++++++++++++++++---
.../src/components/ui/primitives/sidebar.tsx | 2 +-
5 files changed, 61 insertions(+), 10 deletions(-)
diff --git a/apps/editor/components/scene-loader.tsx b/apps/editor/components/scene-loader.tsx
index 06654c6c06..f0a2a5cc4b 100644
--- a/apps/editor/components/scene-loader.tsx
+++ b/apps/editor/components/scene-loader.tsx
@@ -187,6 +187,7 @@ function SceneEditor({ initialScene, meta: initialMeta }: SceneLoaderProps) {
// store must never overwrite a populated server copy.
const serverNodeCountRef = useRef(meta.nodeCount)
const lastRemoteGraphJsonRef = useRef(null)
+ const pendingGraphSignaturesRef = useRef(new Set())
const suppressRemoteSaveUntilRef = useRef(0)
const [conflict, setConflict] = useState(false)
const [saveError, setSaveError] = useState(null)
@@ -249,6 +250,7 @@ function SceneEditor({ initialScene, meta: initialMeta }: SceneLoaderProps) {
return
}
+ pendingGraphSignaturesRef.current.add(graphJson)
try {
const response = await fetch(`/api/scenes/${meta.id}`, {
method: 'PUT',
@@ -285,11 +287,15 @@ function SceneEditor({ initialScene, meta: initialMeta }: SceneLoaderProps) {
}
const next = (await response.json()) as SceneMeta
- versionRef.current = next.version
- serverNodeCountRef.current = next.nodeCount
+ if (next.version >= versionRef.current) {
+ versionRef.current = next.version
+ serverNodeCountRef.current = next.nodeCount
+ }
setSaveError(null)
} catch (error) {
setSaveError(error instanceof Error ? error.message : 'Save failed')
+ } finally {
+ pendingGraphSignaturesRef.current.delete(graphJson)
}
},
[meta.id, meta.name],
@@ -310,7 +316,11 @@ function SceneEditor({ initialScene, meta: initialMeta }: SceneLoaderProps) {
versionRef.current = payload.version
serverNodeCountRef.current = countGraphNodes(payload.graph)
- lastRemoteGraphJsonRef.current = sceneGraphSignature(payload.graph)
+ 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
+ lastRemoteGraphJsonRef.current = signature
suppressRemoteSaveUntilRef.current = Date.now() + 2500
applySceneGraphToEditor(payload.graph)
setConflict(false)
diff --git a/apps/editor/lib/graph-schema.test.ts b/apps/editor/lib/graph-schema.test.ts
index 6aef42abf1..2ee6ac0ef4 100644
--- a/apps/editor/lib/graph-schema.test.ts
+++ b/apps/editor/lib/graph-schema.test.ts
@@ -34,6 +34,20 @@ test('accepts a graph containing a plugin node kind', () => {
expect(apiGraphSchema.safeParse(graph).success).toBe(true)
})
+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']) {
+ expect(
+ apiGraphSchema.safeParse(buildGraph({ [TREE_ID]: { ...node, thumbnail: url } })).success,
+ ).toBe(false)
+ expect(
+ apiGraphSchema.safeParse(buildGraph({ [TREE_ID]: { ...node, slotId: url } })).success,
+ ).toBe(false)
+ }
+})
+
test('accepts a builtin container whose children include a plugin node id', () => {
const graph = buildGraph({ [LEVEL_ID]: level([TREE_ID]), [TREE_ID]: pluginTree() }, [LEVEL_ID])
diff --git a/apps/editor/lib/graph-schema.ts b/apps/editor/lib/graph-schema.ts
index db537b46c2..dd3ed9a115 100644
--- a/apps/editor/lib/graph-schema.ts
+++ b/apps/editor/lib/graph-schema.ts
@@ -64,8 +64,7 @@ type ScanResult = { path: (string | number)[]; reason: 'url' | 'too-complex' }
/**
* First URL-shaped string `AssetUrl` rejects, or a breach of the walk bounds.
*
- * The node's own `type` is exempt: a namespaced kind (`trees:tree`) is
- * scheme-shaped by construction, and the envelope has already validated it.
+ * Namespaced kinds and attachment slot identifiers are not asset URLs.
*/
function findRejectedUrl(root: unknown): ScanResult | null {
let budget = MAX_SCAN_VALUES
@@ -85,6 +84,16 @@ 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.
+ if (
+ depth === 0 &&
+ key === 'slotId' &&
+ typeof child === 'string' &&
+ /^[\w.-]+(?::[\w.-]+)*$/.test(child)
+ ) {
+ continue
+ }
const hit = walk(child, [...path, key], depth + 1)
if (hit) return hit
}
diff --git a/packages/editor/src/components/ui/helpers/contextual-helper-panel.tsx b/packages/editor/src/components/ui/helpers/contextual-helper-panel.tsx
index aebb139ab9..2c5347066c 100644
--- a/packages/editor/src/components/ui/helpers/contextual-helper-panel.tsx
+++ b/packages/editor/src/components/ui/helpers/contextual-helper-panel.tsx
@@ -1,6 +1,6 @@
import { Icon } from '@iconify/react'
import type { ToolHint } from '@pascal-app/core'
-import { Fragment, useEffect, useSyncExternalStore } from 'react'
+import { Fragment, useEffect, useId, useState, useSyncExternalStore } from 'react'
import {
CONTINUATION_PROFILES,
type ContinuationContext,
@@ -472,6 +472,8 @@ export function ContextualHelperPanel({
notice?: string | null
}) {
const inStack = useInRightStack()
+ const [showAllHints, setShowAllHints] = useState(false)
+ const hintsId = useId()
const modeChips = chipHints.filter((hint) => hint.chip)
const hasChips = !!snapContext || !!continuationContext || modeChips.length > 0 || showPaintScope
const fenceFeature = useEditor((state) =>
@@ -488,6 +490,9 @@ export function ContextualHelperPanel({
const actionHints = hints.filter((hint) => !isEscHint(hint))
const escHints = hints.filter(isEscHint)
+ const visibleHints = showAllHints
+ ? actionHints
+ : actionHints.filter((hint, index) => index === 0 || hint.active)
return (
{title ?
: null}
- {actionHints.map((hint) => (
-
- ))}
+
+ {visibleHints.map((hint) => (
+
+ ))}
+
+ {actionHints.length > 1 ? (
+
setShowAllHints((value) => !value)}
+ type="button"
+ >
+ {showAllHints ? 'Fewer shortcuts' : 'More shortcuts'}
+
+ ) : 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=="],