feat: improve thumbnails, placement, and editor responsiveness (#258)

* wip: backport monorepo PRs 278 280 281 283 284

* refactor(editor): align floorplan panel with monorepo split version
This commit is contained in:
Pascal
2026-04-17 15:14:40 -04:00
committed by GitHub
parent d948f09b2c
commit 5c16aa4225
53 changed files with 1744 additions and 1409 deletions
+1 -1
View File
@@ -26,7 +26,7 @@
"react-dom": "^19.2.4",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1",
"three": "^0.183.1"
"three": "^0.184.0"
},
"devDependencies": {
"@pascal/typescript-config": "*",
+19 -20
View File
@@ -31,7 +31,7 @@
"react-dom": "^19.2.4",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1",
"three": "^0.183.1",
"three": "^0.184.0",
},
"devDependencies": {
"@pascal/typescript-config": "*",
@@ -63,14 +63,14 @@
"devDependencies": {
"@pascal/typescript-config": "*",
"@types/react": "^19.2.2",
"@types/three": "^0.183.0",
"@types/three": "^0.184.0",
"typescript": "5.9.3",
},
"peerDependencies": {
"@react-three/drei": "^10",
"@react-three/fiber": "^9",
"react": "^18 || ^19",
"three": "^0.182",
"three": "^0.184",
},
},
"packages/editor": {
@@ -110,9 +110,10 @@
"@pascal-app/viewer": "^0.5.1",
"@pascal/typescript-config": "*",
"@types/howler": "^2.2.12",
"@types/node": "^22.19.12",
"@types/react": "19.2.2",
"@types/react-dom": "19.2.2",
"@types/three": "^0.183.1",
"@types/three": "^0.184.0",
"typescript": "5.9.3",
},
"peerDependencies": {
@@ -123,7 +124,7 @@
"next": ">=15",
"react": "^18 || ^19",
"react-dom": "^18 || ^19",
"three": "^0.183",
"three": "^0.184",
},
},
"packages/eslint-config": {
@@ -175,7 +176,7 @@
"@pascal/typescript-config": "*",
"@types/node": "^25.5.0",
"@types/react": "^19.2.2",
"@types/three": "^0.183.0",
"@types/three": "^0.184.0",
"typescript": "5.9.3",
},
"peerDependencies": {
@@ -183,7 +184,7 @@
"@react-three/drei": "^10",
"@react-three/fiber": "^9",
"react": "^18 || ^19",
"three": "^0.183",
"three": "^0.184",
},
},
"tooling/typescript": {
@@ -612,7 +613,7 @@
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
"@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="],
"@types/offscreencanvas": ["@types/offscreencanvas@2019.7.3", "", {}, "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A=="],
@@ -624,7 +625,7 @@
"@types/stats.js": ["@types/stats.js@0.17.4", "", {}, "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA=="],
"@types/three": ["@types/three@0.183.1", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": ">=0.5.17", "@webgpu/types": "*", "fflate": "~0.8.2", "meshoptimizer": "~1.0.1" } }, "sha512-f2Pu5Hrepfgavttdye3PsH5RWyY/AvdZQwIVhrc4uNtvF7nOWJacQKcoVJn0S4f0yYbmAE6AR+ve7xDcuYtMGw=="],
"@types/three": ["@types/three@0.184.0", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": ">=0.5.17", "fflate": "~0.8.2", "meshoptimizer": "~1.1.1" } }, "sha512-4mY2tZAu0y0B0567w7013BBXSpsP0+Z48NJvmNo4Y/Pf76yCyz6Jw4P3tUVs10WuYNXXZ+wmHyGWpCek3amJxA=="],
"@types/webxr": ["@types/webxr@0.5.24", "", {}, "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg=="],
@@ -1090,7 +1091,7 @@
"meshline": ["meshline@3.3.1", "", { "peerDependencies": { "three": ">=0.137" } }, "sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ=="],
"meshoptimizer": ["meshoptimizer@1.0.1", "", {}, "sha512-Vix+QlA1YYT3FwmBBZ+49cE5y/b+pRrcXKqGpS5ouh33d3lSp2PoTpCw19E0cKDFWalembrHnIaZetf27a+W2g=="],
"meshoptimizer": ["meshoptimizer@1.1.1", "", {}, "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g=="],
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
@@ -1310,7 +1311,7 @@
"tapable": ["tapable@2.3.2", "", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="],
"three": ["three@0.183.2", "", {}, "sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ=="],
"three": ["three@0.184.0", "", {}, "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg=="],
"three-bvh-csg": ["three-bvh-csg@0.0.18", "", { "peerDependencies": { "three": ">=0.179.0", "three-mesh-bvh": ">=0.9.7" } }, "sha512-M3GCZMmGFgASGuDf+YMamM83nVlD/vdwzVHcYbFxgW+g1S7/nKPiuY00YVHOMbjmJPh8mLevGZL65ItHUuGt2w=="],
@@ -1358,7 +1359,7 @@
"unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"unplugin": ["unplugin@2.1.0", "", { "dependencies": { "acorn": "^8.14.0", "webpack-virtual-modules": "^0.6.2" } }, "sha512-us4j03/499KhbGP8BU7Hrzrgseo+KdfJYWcbcajCOqsAyb8Gk0Yn2kiUIcZISYCb1JFaZfIuG3b42HmguVOKCQ=="],
@@ -1412,6 +1413,8 @@
"@pascal-app/editor/@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
"@pascal-app/viewer/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
"@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
@@ -1434,8 +1437,6 @@
"@react-three/drei/three-mesh-bvh": ["three-mesh-bvh@0.8.3", "", { "peerDependencies": { "three": ">= 0.159.0" } }, "sha512-4G5lBaF+g2auKX3P0yqx+MJC6oVt6sB5k+CchS6Ob0qvH0YIhuUk1eYr7ktsIpY+albCqE80/FVQGV190PmiAg=="],
"@repo/ui/@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="],
"@repo/ui/@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
"@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
@@ -1464,8 +1465,6 @@
"dotenv-expand/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
"editor/@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="],
"editor/@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
"eslint-plugin-turbo/dotenv": ["dotenv@16.0.3", "", {}, "sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ=="],
@@ -1490,23 +1489,23 @@
"sharp/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"stats-gl/@types/three": ["@types/three@0.183.1", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": ">=0.5.17", "@webgpu/types": "*", "fflate": "~0.8.2", "meshoptimizer": "~1.0.1" } }, "sha512-f2Pu5Hrepfgavttdye3PsH5RWyY/AvdZQwIVhrc4uNtvF7nOWJacQKcoVJn0S4f0yYbmAE6AR+ve7xDcuYtMGw=="],
"stats-gl/three": ["three@0.170.0", "", {}, "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ=="],
"three-stdlib/fflate": ["fflate@0.6.10", "", {}, "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg=="],
"tunnel-rat/zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="],
"@repo/ui/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"@pascal-app/viewer/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="],
"editor/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"glob/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="],
"next/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"react-scan/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"stats-gl/@types/three/meshoptimizer": ["meshoptimizer@1.0.1", "", {}, "sha512-Vix+QlA1YYT3FwmBBZ+49cE5y/b+pRrcXKqGpS5ouh33d3lSp2PoTpCw19E0cKDFWalembrHnIaZetf27a+W2g=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
+2 -2
View File
@@ -30,7 +30,7 @@
"@react-three/drei": "^10",
"@react-three/fiber": "^9",
"react": "^18 || ^19",
"three": "^0.182"
"three": "^0.184"
},
"dependencies": {
"dedent": "^1.7.1",
@@ -47,7 +47,7 @@
"@pascal/typescript-config": "*",
"@types/react": "^19.2.2",
"typescript": "5.9.3",
"@types/three": "^0.183.0"
"@types/three": "^0.184.0"
},
"keywords": [
"3d",
+27 -4
View File
@@ -24,9 +24,10 @@ export interface GridEvent {
/** World-space intersection point on the grid plane. */
position: [number, number, number]
/**
* Building-local intersection point relative to the currently selected building.
* Building-local intersection point, relative to the currently selected building.
* Equals `position` when no building is selected.
* Use this for placing/committing anything that lives inside a building (walls, slabs, items, etc.).
* Use this for placing or committing anything that lives inside a building
* (walls, slabs, items, etc.).
*/
localPosition: [number, number, number]
nativeEvent: ThreeEvent<PointerEvent>
@@ -57,7 +58,7 @@ export type StairSegmentEvent = NodeEvent<StairSegmentNode>
export type WindowEvent = NodeEvent<WindowNode>
export type DoorEvent = NodeEvent<DoorNode>
// Event suffixes - exported for use in hooks
// Event suffixes, exported for use in hooks
export const eventSuffixes = [
'click',
'move',
@@ -85,6 +86,15 @@ export interface CameraControlEvent {
export interface ThumbnailGenerateEvent {
projectId: string
captureMode?: 'standard' | 'viewport' | 'area'
cropRegion?: { x: number; y: number; width: number; height: number }
/**
* When true, snap levels to their true positions before capturing (for a
* consistent auto-thumbnail angle) and defer the capture if the tab is
* hidden, the background auto-save path. Omit for user-driven captures
* that should fire immediately from the current camera pose.
*/
snapLevels?: boolean
}
type CameraControlEvents = {
@@ -111,6 +121,17 @@ type ThumbnailEvents = {
'thumbnail:after-capture': undefined
}
type SnapshotEvents = {
'snapshot:saved': undefined
'camera:go-to-position': { position: [number, number, number]; target: [number, number, number] }
}
type AIChatEvents = {
'ai-chat:attach-images': {
images: { url: string; name: string; kind: 'snapshot' | 'render' }[]
}
}
type EditorEvents = GridEvents &
NodeEvents<'wall', WallEvent> &
NodeEvents<'fence', FenceEvent> &
@@ -130,6 +151,8 @@ type EditorEvents = GridEvents &
CameraControlEvents &
ToolEvents &
PresetEvents &
ThumbnailEvents
ThumbnailEvents &
SnapshotEvents &
AIChatEvents
export const emitter = mitt<EditorEvents>()
@@ -4,6 +4,13 @@ interface GridCell {
itemIds: Set<string>
}
interface ItemBounds {
minX: number
maxX: number
minZ: number
maxZ: number
}
interface SpatialGridConfig {
cellSize: number // e.g., 0.5 meters = Sims-style half-tile
}
@@ -11,6 +18,7 @@ interface SpatialGridConfig {
export class SpatialGrid {
private readonly cells = new Map<CellKey, GridCell>()
private readonly itemCells = new Map<string, Set<CellKey>>() // reverse lookup
private readonly itemBounds = new Map<string, ItemBounds>() // actual AABB for narrow-phase
private readonly config: SpatialGridConfig
@@ -26,34 +34,35 @@ export class SpatialGrid {
return `${cx},${cz}`
}
// Get all cells an item occupies based on its AABB
private getItemCells(
// Compute the axis-aligned bounding box for a rotated item
private getAABB(
position: [number, number, number],
dimensions: [number, number, number],
rotation: [number, number, number],
): CellKey[] {
// Simplified: axis-aligned bounding box
// For full rotation support, compute rotated corners
): ItemBounds {
const [x, , z] = position
const [w, , d] = dimensions
const yRot = rotation[1] // Y-axis rotation
const yRot = rotation[1]
// Compute rotated footprint (simplified for 90° increments)
const cos = Math.abs(Math.cos(yRot))
const sin = Math.abs(Math.sin(yRot))
const rotatedW = w * cos + d * sin
const rotatedD = w * sin + d * cos
const minX = x - rotatedW / 2
const maxX = x + rotatedW / 2
const minZ = z - rotatedD / 2
const maxZ = z + rotatedD / 2
return {
minX: x - rotatedW / 2,
maxX: x + rotatedW / 2,
minZ: z - rotatedD / 2,
maxZ: z + rotatedD / 2,
}
}
// Get all cells an item occupies based on its AABB
private getItemCells(bounds: ItemBounds): CellKey[] {
const { minX, maxX, minZ, maxZ } = bounds
const [minCx, minCz] = this.posToCell(minX, minZ)
// Use exclusive upper bound: subtract epsilon so exact boundaries don't overlap
// This allows adjacent items (touching but not overlapping) to not conflict
const epsilon = 1e-6
const [maxCx, maxCz] = this.posToCell(maxX - epsilon, maxZ - epsilon)
const [maxCx, maxCz] = this.posToCell(maxX, maxZ)
const keys: CellKey[] = []
for (let cx = minCx; cx <= maxCx; cx++) {
@@ -71,9 +80,11 @@ export class SpatialGrid {
dimensions: [number, number, number],
rotation: [number, number, number],
) {
const cellKeys = this.getItemCells(position, dimensions, rotation)
const bounds = this.getAABB(position, dimensions, rotation)
const cellKeys = this.getItemCells(bounds)
this.itemCells.set(itemId, new Set(cellKeys))
this.itemBounds.set(itemId, bounds)
for (const key of cellKeys) {
if (!this.cells.has(key)) {
@@ -98,6 +109,7 @@ export class SpatialGrid {
}
}
this.itemCells.delete(itemId)
this.itemBounds.delete(itemId)
}
// Update = remove + insert
@@ -112,30 +124,51 @@ export class SpatialGrid {
}
// Query: is this placement valid?
// Uses cells as broad-phase, then checks actual AABB overlap (narrow-phase)
// to avoid false positives when adjacent items share a cell but don't overlap.
canPlace(
position: [number, number, number],
dimensions: [number, number, number],
rotation: [number, number, number],
ignoreIds: string[] = [],
): { valid: boolean; conflictIds: string[] } {
const cellKeys = this.getItemCells(position, dimensions, rotation)
const bounds = this.getAABB(position, dimensions, rotation)
const cellKeys = this.getItemCells(bounds)
const ignoreSet = new Set(ignoreIds)
const conflicts = new Set<string>()
// Broad phase: collect candidate items from overlapping cells
const candidates = new Set<string>()
for (const key of cellKeys) {
const cell = this.cells.get(key)
if (cell) {
for (const id of cell.itemIds) {
if (!ignoreSet.has(id)) {
conflicts.add(id)
candidates.add(id)
}
}
}
}
// Narrow phase: check actual AABB overlap
// Items that merely touch (share an edge) are allowed; only true overlap conflicts.
const EPSILON = 1e-4 // tolerance to allow touching
const conflicts: string[] = []
for (const id of candidates) {
const other = this.itemBounds.get(id)
if (!other) continue
if (
bounds.minX < other.maxX - EPSILON &&
bounds.maxX > other.minX + EPSILON &&
bounds.minZ < other.maxZ - EPSILON &&
bounds.maxZ > other.minZ + EPSILON
) {
conflicts.push(id)
}
}
return {
valid: conflicts.size === 0,
conflictIds: [...conflicts],
valid: conflicts.length === 0,
conflictIds: conflicts,
}
}
+3 -2
View File
@@ -18,7 +18,7 @@
"next": ">=15",
"react": "^18 || ^19",
"react-dom": "^18 || ^19",
"three": "^0.183"
"three": "^0.184"
},
"dependencies": {
"@iconify/react": "^6.0.2",
@@ -54,9 +54,10 @@
"@pascal-app/viewer": "^0.5.1",
"@pascal/typescript-config": "*",
"@types/howler": "^2.2.12",
"@types/node": "^22.19.12",
"@types/react": "19.2.2",
"@types/react-dom": "19.2.2",
"@types/three": "^0.183.1",
"@types/three": "^0.184.0",
"typescript": "5.9.3"
}
}
@@ -39,6 +39,15 @@ function LeftColumn({
}
}, [tabs, activePanel, setActivePanel])
// Leaving the items tab while furnishing should drop back to select mode
useEffect(() => {
if (activePanel === 'items') return
const { phase, mode, setMode } = useEditor.getState()
if (phase === 'furnish' && mode === 'build') {
setMode('select')
}
}, [activePanel])
const handleResizerDown = useCallback(
(e: React.PointerEvent) => {
e.preventDefault()
@@ -45,7 +45,6 @@ const HOLE_TYPES = ['slab', 'ceiling']
export function FloatingActionMenu() {
const selectedIds = useViewer((s) => s.selection.selectedIds)
const nodes = useScene((s) => s.nodes)
const updateNode = useScene((s) => s.updateNode)
const mode = useEditor((s) => s.mode)
const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
@@ -63,12 +62,19 @@ export function FloatingActionMenu() {
// Only show for single selection of specific types
const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
const node = selectedId ? nodes[selectedId as AnyNodeId] : null
// Subscribe just to the selected node so unrelated scene updates do not
// re-render this menu.
const node = useScene((s) => (selectedId ? (s.nodes[selectedId as AnyNodeId] ?? null) : null))
const isValidType = node ? ALLOWED_TYPES.includes(node.type) : false
const canCurveSelectedWall =
node?.type === 'wall' &&
!(node.children ?? []).some((childId) => {
const child = nodes[childId as AnyNodeId]
// Boolean selector, only re-renders when curving availability actually flips.
const canCurveSelectedWall = useScene((s) => {
if (!selectedId) return false
const selectedNode = s.nodes[selectedId as AnyNodeId]
if (selectedNode?.type !== 'wall') return false
return !(selectedNode.children ?? []).some((childId) => {
const child = s.nodes[childId as AnyNodeId]
if (!child) return false
if (child.type === 'door' || child.type === 'window') return true
if (child.type === 'item') {
@@ -77,6 +83,7 @@ export function FloatingActionMenu() {
}
return false
})
})
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
@@ -422,7 +429,11 @@ export function FloatingActionMenu() {
{node?.type === 'wall' && (
<>
<group ref={startEndpointGroupRef}>
<Html center style={{ pointerEvents: 'auto', touchAction: 'none' }} zIndexRange={[100, 0]}>
<Html
center
style={{ pointerEvents: 'auto', touchAction: 'none' }}
zIndexRange={[100, 0]}
>
<button
aria-label="Move wall start"
className={`pointer-events-auto flex h-8 w-8 items-center justify-center rounded-full border bg-background/95 shadow-lg backdrop-blur-md transition-colors ${
@@ -440,7 +451,11 @@ export function FloatingActionMenu() {
</Html>
</group>
<group ref={endEndpointGroupRef}>
<Html center style={{ pointerEvents: 'auto', touchAction: 'none' }} zIndexRange={[100, 0]}>
<Html
center
style={{ pointerEvents: 'auto', touchAction: 'none' }}
zIndexRange={[100, 0]}
>
<button
aria-label="Move wall end"
className={`pointer-events-auto flex h-8 w-8 items-center justify-center rounded-full border bg-background/95 shadow-lg backdrop-blur-md transition-colors ${
@@ -15,7 +15,6 @@ export function FloatingBuildingActionMenu() {
const levelId = useViewer((s) => s.selection.levelId)
const setMovingNode = useEditor((s) => s.setMovingNode)
const setSelection = useViewer((s) => s.setSelection)
const nodes = useScene((s) => s.nodes)
const groupRef = useRef<THREE.Group>(null)
@@ -36,13 +35,15 @@ export function FloatingBuildingActionMenu() {
(e: React.MouseEvent) => {
e.stopPropagation()
if (!buildingId) return
const node = nodes[buildingId]
// Read lazily at click time — no need to subscribe to nodes for a
// one-shot action.
const node = useScene.getState().nodes[buildingId]
if (!node || node.type !== 'building') return
sfxEmitter.emit('sfx:item-pick')
setMovingNode(node as BuildingNode)
setSelection({ buildingId: null })
},
[buildingId, nodes, setMovingNode, setSelection],
[buildingId, setMovingNode, setSelection],
)
// Only show when a building is selected without a level
File diff suppressed because it is too large Load Diff
@@ -20,12 +20,18 @@ function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
}
export function SiteEdgeLabels() {
const rootNodeIds = useScene((state) => state.rootNodeIds)
const nodes = useScene((state) => state.nodes)
// Narrow subscription to just the site node — subscribing to the full
// s.nodes dict re-rendered this on every wall/level mutation even though
// the site itself rarely changes.
const siteNode = useScene((state) => {
const firstRoot = state.rootNodeIds[0]
if (!firstRoot) return null
const node = state.nodes[firstRoot]
return node?.type === 'site' ? (node as SiteNode) : null
})
const unit = useViewer((state) => state.unit)
const theme = useViewer((state) => state.theme)
const siteNode = rootNodeIds[0] ? (nodes[rootNodeIds[0]] as SiteNode) : null
const siteNodeId = siteNode?.id
const isNight = theme === 'dark'
@@ -1,7 +1,8 @@
'use client'
import { emitter, useScene } from '@pascal-app/core'
import { SSGI_PARAMS, snapLevelsToTruePositions } from '@pascal-app/viewer'
import { emitter, sceneRegistry, useScene } from '@pascal-app/core'
import { SSGI_PARAMS, snapLevelsToTruePositions, useViewer } from '@pascal-app/viewer'
import type { CameraControls } from '@react-three/drei'
import { useThree } from '@react-three/fiber'
import { useCallback, useEffect, useRef } from 'react'
import * as THREE from 'three'
@@ -29,14 +30,24 @@ const THUMBNAIL_WIDTH = 1920
const THUMBNAIL_HEIGHT = 1080
const AUTO_SAVE_DELAY = 10_000
export interface SnapshotCameraData {
position: [number, number, number]
target: [number, number, number] | null
type?: 'perspective' | 'orthographic'
zoom?: number
captureMode?: 'standard' | 'viewport' | 'area'
resolution?: { w: number; h: number }
}
interface ThumbnailGeneratorProps {
onThumbnailCapture?: (blob: Blob) => void
onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void
}
export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorProps) => {
const gl = useThree((state) => state.gl)
const scene = useThree((state) => state.scene)
const mainCamera = useThree((state) => state.camera)
const controls = useThree((state) => state.controls) as CameraControls | null
const isGenerating = useRef(false)
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const pendingAutoRef = useRef(false)
@@ -50,7 +61,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
onThumbnailCaptureRef.current = onThumbnailCapture
}, [onThumbnailCapture])
// Build the thumbnail camera, SSGI pipeline, and render target once reused on every capture.
// Build the thumbnail camera, SSGI pipeline, and render target once, reused on every capture.
useEffect(() => {
const cam = new THREE.PerspectiveCamera(60, THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT, 0.1, 1000)
cam.layers.disable(EDITOR_LAYER)
@@ -64,7 +75,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
if (!mounted) return
// pass() handles MRT internally for all material types, including custom
// shaders unlike renderer.setMRT() which crashes on non-NodeMaterials.
// shaders, unlike renderer.setMRT() which crashes on non-NodeMaterials.
// pass() also respects camera.layers, so EDITOR_LAYER objects are filtered.
const scenePass = pass(scene, cam)
scenePass.setMRT(
@@ -106,15 +117,15 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
const ao = (denoisePass as any).r
const finalOutput = vec4(scenePassColor.rgb.mul(ao), scenePassColor.a)
// FXAA requires a texture node as input; convertToTexture renders finalOutput
// into an intermediate RT so FXAA can sample it with neighbour UV offsets.
// FXAA requires a texture node as input, convertToTexture renders finalOutput
// into an intermediate RT so FXAA can sample it with neighbor UV offsets.
const aaOutput = fxaa(convertToTexture(finalOutput))
const pipeline = new RenderPipeline(gl as unknown as WebGPURenderer)
pipeline.outputNode = aaOutput
pipelineRef.current = pipeline
// Dedicated render target pipeline outputs here instead of the canvas,
// Dedicated render target, pipeline outputs here instead of the canvas,
// so R3F's main render loop can never overwrite our capture.
const { width, height } = gl.domElement
renderTargetRef.current = new RenderTarget(width, height, { depthBuffer: true })
@@ -137,178 +148,304 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
}
}, [gl, scene])
const generate = useCallback(async () => {
if (isGenerating.current) return
if (!onThumbnailCaptureRef.current) return
const generate = useCallback(
async (
snapLevels: boolean,
captureMode?: 'standard' | 'viewport' | 'area',
cropRegion?: { x: number; y: number; width: number; height: number },
) => {
if (isGenerating.current) return
if (!onThumbnailCaptureRef.current) return
isGenerating.current = true
isGenerating.current = true
try {
const thumbnailCamera = thumbnailCameraRef.current
if (!thumbnailCamera) return
try {
const thumbnailCamera = thumbnailCameraRef.current
if (!thumbnailCamera) return
// Copy the main camera's transform and projection so the thumbnail
// matches exactly what the user sees in the viewport.
thumbnailCamera.position.copy(mainCamera.position)
thumbnailCamera.quaternion.copy(mainCamera.quaternion)
if (mainCamera instanceof THREE.PerspectiveCamera) {
thumbnailCamera.fov = mainCamera.fov
thumbnailCamera.near = mainCamera.near
thumbnailCamera.far = mainCamera.far
}
const { width, height } = gl.domElement
thumbnailCamera.aspect = width / height
thumbnailCamera.updateProjectionMatrix()
// Copy the main camera's transform and projection so the thumbnail
// matches exactly what the user sees in the viewport.
thumbnailCamera.position.copy(mainCamera.position)
thumbnailCamera.quaternion.copy(mainCamera.quaternion)
if (mainCamera instanceof THREE.PerspectiveCamera) {
thumbnailCamera.fov = mainCamera.fov
thumbnailCamera.near = mainCamera.near
thumbnailCamera.far = mainCamera.far
}
const { width, height } = gl.domElement
thumbnailCamera.aspect = width / height
thumbnailCamera.updateProjectionMatrix()
const restoreLevels = snapLevelsToTruePositions()
let blob: Blob
if (pipelineRef.current && renderTargetRef.current) {
const rt = renderTargetRef.current
// Resize RT if the canvas dimensions changed
if (rt.width !== width || rt.height !== height) {
rt.setSize(width, height)
// Capture camera data for snapshot storage.
const pos = mainCamera.position
let tgt: [number, number, number] | null = null
if (controls && 'getTarget' in controls) {
const v = new THREE.Vector3()
;(controls as any).getTarget(v)
tgt = [v.x, v.y, v.z]
}
const isOrtho = mainCamera instanceof THREE.OrthographicCamera
const cameraData: SnapshotCameraData = {
position: [pos.x, pos.y, pos.z],
target: tgt,
type: isOrtho ? 'orthographic' : 'perspective',
...(isOrtho && { zoom: (mainCamera as THREE.OrthographicCamera).zoom }),
}
const renderer = gl as unknown as WebGPURenderer
// For auto-save, snap levels to stacked positions and reset levelMode.
let restoreLevelMode: (() => void) | null = null
let restoreLevels: () => void = () => {}
if (snapLevels) {
const prevMode = useViewer.getState().levelMode
if (prevMode !== 'stacked') {
useViewer.getState().setLevelMode('stacked')
restoreLevelMode = () => useViewer.getState().setLevelMode(prevMode)
}
restoreLevels = snapLevelsToTruePositions()
}
// Swap selected-item materials back to originals for the capture,
// then re-apply highlights immediately after.
emitter.emit('thumbnail:before-capture', undefined)
;(renderer as any).setClearAlpha(0)
renderer.setRenderTarget(rt)
pipelineRef.current.render()
renderer.setRenderTarget(null)
emitter.emit('thumbnail:after-capture', undefined)
// Hide scan and guide nodes directly so they are excluded from the
// thumbnail regardless of whether ScanSystem or GuideSystem listeners are
// registered. Returns a function that restores the original visibility.
const restoreNodeVisibility = (() => {
const saved = new Map<THREE.Object3D, boolean>()
for (const type of ['scan', 'guide'] as const) {
const ids = sceneRegistry.byType[type]
ids.forEach((id) => {
const node = sceneRegistry.nodes.get(id)
if (node) {
saved.set(node, node.visible)
node.visible = false
}
})
}
return () => {
saved.forEach((wasVisible, node) => {
node.visible = wasVisible
})
}
})()
// Restore level positions immediately after the render — before the async GPU readback.
restoreLevels()
let blob: Blob
// Read pixels from the RT asynchronously.
// WebGPU copyTextureToBuffer aligns each row to 256 bytes, so we must
// depad the rows before constructing ImageData.
const pixels = (await (renderer as any).readRenderTargetPixelsAsync(
rt,
0,
0,
width,
height,
)) as Uint8Array
if (pipelineRef.current && renderTargetRef.current) {
const rt = renderTargetRef.current
const actualBytesPerRow = width * 4
const paddedBytesPerRow = Math.ceil(actualBytesPerRow / 256) * 256
let tightPixels: Uint8ClampedArray
if (paddedBytesPerRow === actualBytesPerRow) {
// No padding — use the buffer directly
tightPixels = new Uint8ClampedArray(pixels.buffer, pixels.byteOffset, pixels.byteLength)
// Resize RT if the canvas dimensions changed.
if (rt.width !== width || rt.height !== height) {
rt.setSize(width, height)
}
const renderer = gl as unknown as WebGPURenderer
// Notify other systems (wall cutouts, selection manager) to restore
// their overrides before capture and re-apply them after.
emitter.emit('thumbnail:before-capture', undefined)
;(renderer as any).setClearAlpha(0)
renderer.setRenderTarget(rt)
pipelineRef.current.render()
renderer.setRenderTarget(null)
emitter.emit('thumbnail:after-capture', undefined)
// Restore level positions, levelMode, and node visibility immediately after the
// render, before the async GPU readback.
restoreLevels()
restoreLevelMode?.()
restoreNodeVisibility()
// Read pixels from the RT asynchronously.
// WebGPU copyTextureToBuffer aligns each row to 256 bytes, so we must
// depad the rows before constructing ImageData.
const pixels = (await (renderer as any).readRenderTargetPixelsAsync(
rt,
0,
0,
width,
height,
)) as Uint8Array
const actualBytesPerRow = width * 4
const paddedBytesPerRow = Math.ceil(actualBytesPerRow / 256) * 256
let tightPixels: Uint8ClampedArray
if (paddedBytesPerRow === actualBytesPerRow) {
tightPixels = new Uint8ClampedArray(pixels.buffer, pixels.byteOffset, pixels.byteLength)
} else {
tightPixels = new Uint8ClampedArray(width * height * 4)
for (let row = 0; row < height; row++) {
tightPixels.set(
pixels.subarray(
row * paddedBytesPerRow,
row * paddedBytesPerRow + actualBytesPerRow,
),
row * actualBytesPerRow,
)
}
}
const imageData = new ImageData(
tightPixels as unknown as Uint8ClampedArray<ArrayBuffer>,
width,
height,
)
const srcCanvas = new OffscreenCanvas(width, height)
srcCanvas.getContext('2d')!.putImageData(imageData, 0, 0)
let outW: number
let outH: number
if (captureMode === 'viewport') {
outW = width
outH = height
const offscreen = new OffscreenCanvas(outW, outH)
offscreen.getContext('2d')!.drawImage(srcCanvas, 0, 0)
blob = await offscreen.convertToBlob({ type: 'image/png' })
} else if (captureMode === 'area' && cropRegion) {
const sx = Math.round(cropRegion.x * width)
const sy = Math.round(cropRegion.y * height)
outW = Math.round(cropRegion.width * width)
outH = Math.round(cropRegion.height * height)
const offscreen = new OffscreenCanvas(outW, outH)
offscreen.getContext('2d')!.drawImage(srcCanvas, sx, sy, outW, outH, 0, 0, outW, outH)
blob = await offscreen.convertToBlob({ type: 'image/png' })
} else {
const srcAspect = width / height
const dstAspect = THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT
let sx = 0,
sy = 0,
sWidth = width,
sHeight = height
if (srcAspect > dstAspect) {
sWidth = Math.round(height * dstAspect)
sx = Math.round((width - sWidth) / 2)
} else if (srcAspect < dstAspect) {
sHeight = Math.round(width / dstAspect)
sy = Math.round((height - sHeight) / 2)
}
outW = THUMBNAIL_WIDTH
outH = THUMBNAIL_HEIGHT
const offscreen = new OffscreenCanvas(outW, outH)
offscreen
.getContext('2d')!
.drawImage(srcCanvas, sx, sy, sWidth, sHeight, 0, 0, outW, outH)
blob = await offscreen.convertToBlob({ type: 'image/png' })
}
if (captureMode !== undefined) cameraData.captureMode = captureMode
cameraData.resolution = { w: outW, h: outH }
} else {
// Depad rows
tightPixels = new Uint8ClampedArray(width * height * 4)
for (let row = 0; row < height; row++) {
tightPixels.set(
pixels.subarray(row * paddedBytesPerRow, row * paddedBytesPerRow + actualBytesPerRow),
row * actualBytesPerRow,
// Fallback: plain render directly to the canvas.
emitter.emit('thumbnail:before-capture', undefined)
gl.render(scene, thumbnailCamera)
emitter.emit('thumbnail:after-capture', undefined)
restoreLevels()
restoreLevelMode?.()
restoreNodeVisibility()
let outW: number
let outH: number
if (captureMode === 'viewport') {
outW = width
outH = height
const offscreen = document.createElement('canvas')
offscreen.width = outW
offscreen.height = outH
offscreen.getContext('2d')!.drawImage(gl.domElement, 0, 0)
blob = await new Promise<Blob>((resolve, reject) =>
offscreen.toBlob(
(b) => (b ? resolve(b) : reject(new Error('Canvas capture failed'))),
'image/png',
),
)
} else if (captureMode === 'area' && cropRegion) {
const sx = Math.round(cropRegion.x * width)
const sy = Math.round(cropRegion.y * height)
outW = Math.round(cropRegion.width * width)
outH = Math.round(cropRegion.height * height)
const offscreen = document.createElement('canvas')
offscreen.width = outW
offscreen.height = outH
offscreen
.getContext('2d')!
.drawImage(gl.domElement, sx, sy, outW, outH, 0, 0, outW, outH)
blob = await new Promise<Blob>((resolve, reject) =>
offscreen.toBlob(
(b) => (b ? resolve(b) : reject(new Error('Canvas capture failed'))),
'image/png',
),
)
} else {
const srcAspect = width / height
const dstAspect = THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT
let sx = 0,
sy = 0,
sWidth = width,
sHeight = height
if (srcAspect > dstAspect) {
sWidth = Math.round(height * dstAspect)
sx = Math.round((width - sWidth) / 2)
} else if (srcAspect < dstAspect) {
sHeight = Math.round(width / dstAspect)
sy = Math.round((height - sHeight) / 2)
}
outW = THUMBNAIL_WIDTH
outH = THUMBNAIL_HEIGHT
const offscreen = document.createElement('canvas')
offscreen.width = outW
offscreen.height = outH
offscreen
.getContext('2d')!
.drawImage(gl.domElement, sx, sy, sWidth, sHeight, 0, 0, outW, outH)
blob = await new Promise<Blob>((resolve, reject) =>
offscreen.toBlob(
(b) => (b ? resolve(b) : reject(new Error('Canvas capture failed'))),
'image/png',
),
)
}
if (captureMode !== undefined) cameraData.captureMode = captureMode
cameraData.resolution = { w: outW, h: outH }
}
// Crop to thumbnail aspect ratio and draw to offscreen canvas
const srcAspect = width / height
const dstAspect = THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT
let sx = 0,
sy = 0,
sWidth = width,
sHeight = height
if (srcAspect > dstAspect) {
sWidth = Math.round(height * dstAspect)
sx = Math.round((width - sWidth) / 2)
} else if (srcAspect < dstAspect) {
sHeight = Math.round(width / dstAspect)
sy = Math.round((height - sHeight) / 2)
}
const imageData = new ImageData(
tightPixels as unknown as Uint8ClampedArray<ArrayBuffer>,
width,
height,
)
const srcCanvas = new OffscreenCanvas(width, height)
srcCanvas.getContext('2d')!.putImageData(imageData, 0, 0)
const offscreen = new OffscreenCanvas(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT)
offscreen
.getContext('2d')!
.drawImage(srcCanvas, sx, sy, sWidth, sHeight, 0, 0, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT)
blob = await offscreen.convertToBlob({ type: 'image/png' })
} else {
// Fallback: plain render directly to the canvas
gl.render(scene, thumbnailCamera)
restoreLevels()
const srcAspect = width / height
const dstAspect = THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT
let sx = 0,
sy = 0,
sWidth = width,
sHeight = height
if (srcAspect > dstAspect) {
sWidth = Math.round(height * dstAspect)
sx = Math.round((width - sWidth) / 2)
} else if (srcAspect < dstAspect) {
sHeight = Math.round(width / dstAspect)
sy = Math.round((height - sHeight) / 2)
}
const offscreen = document.createElement('canvas')
offscreen.width = THUMBNAIL_WIDTH
offscreen.height = THUMBNAIL_HEIGHT
const ctx = offscreen.getContext('2d')!
ctx.drawImage(
gl.domElement,
sx,
sy,
sWidth,
sHeight,
0,
0,
THUMBNAIL_WIDTH,
THUMBNAIL_HEIGHT,
)
blob = await new Promise<Blob>((resolve, reject) =>
offscreen.toBlob(
(b) => (b ? resolve(b) : reject(new Error('Canvas capture failed'))),
'image/png',
),
)
onThumbnailCaptureRef.current?.(blob, cameraData)
} catch (error) {
console.error('❌ Failed to generate thumbnail:', error)
} finally {
isGenerating.current = false
}
},
[gl, scene, mainCamera, controls],
)
onThumbnailCaptureRef.current?.(blob)
} catch (error) {
console.error('❌ Failed to generate thumbnail:', error)
} finally {
isGenerating.current = false
}
}, [gl, scene, mainCamera])
// Manual trigger via emitter
// Thumbnail request via emitter. Two call shapes:
// - user-driven capture: `{ projectId, captureMode, cropRegion }`, captures
// the current pose with the supplied crop.
// - auto-save capture: `{ projectId, snapLevels: true }`, snaps levels to
// their true positions first for a consistent auto-thumbnail angle.
useEffect(() => {
const handleGenerateThumbnail = async () => {
await generate()
if (!onThumbnailCapture) return
const handleGenerateThumbnail = async (event: {
captureMode?: 'standard' | 'viewport' | 'area'
cropRegion?: { x: number; y: number; width: number; height: number }
snapLevels?: boolean
}) => {
await generate(event.snapLevels === true, event.captureMode, event.cropRegion)
}
emitter.on('camera-controls:generate-thumbnail', handleGenerateThumbnail)
return () => emitter.off('camera-controls:generate-thumbnail', handleGenerateThumbnail)
}, [generate])
}, [generate, onThumbnailCapture])
// Auto-trigger: debounced on scene changes, deferred if tab is hidden
// OSS adaptation: keep local debounced auto-capture behavior because the
// community host-side autosave hook is not part of this repo.
useEffect(() => {
if (!onThumbnailCapture) return
const triggerNow = () => generate()
const triggerNow = () => {
void generate(true)
}
const scheduleOrDefer = () => {
if (document.visibilityState === 'visible') {
@@ -341,7 +478,32 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
unsubscribe()
document.removeEventListener('visibilitychange', onVisibilityChange)
}
}, [onThumbnailCapture, generate])
}, [generate, onThumbnailCapture])
// Go-to-camera: animate camera to a saved snapshot position or target.
useEffect(() => {
const handler = ({
position,
target,
}: {
position: [number, number, number]
target: [number, number, number]
}) => {
if (controls && 'setLookAt' in controls) {
;(controls as any).setLookAt(
position[0],
position[1],
position[2],
target[0],
target[1],
target[2],
true,
)
}
}
emitter.on('camera:go-to-position', handler)
return () => emitter.off('camera:go-to-position', handler)
}, [controls])
return null
}
@@ -81,7 +81,7 @@ export const floorStrategy = {
ctx.levelId,
pos,
getScaledDimensions(ctx.draftItem),
[0, 0, 0],
ctx.draftItem.rotation,
[ctx.draftItem.id],
).valid
@@ -558,7 +558,7 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato
ctx.levelId,
[ctx.gridPosition.x, 0, ctx.gridPosition.z],
getScaledDimensions(ctx.draftItem),
[0, 0, 0],
ctx.draftItem.rotation,
[ctx.draftItem.id],
).valid
}
@@ -216,6 +216,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
let previousGridPos: [number, number, number] | null = null
const onGridMove = (event: GridEvent) => {
// Lazy draft creation: if no draft yet (e.g. level wasn't ready during init), create now
if (draftNode.current === null && asset.attachTo === undefined) {
configRef.current.initDraft(gridPosition.current)
}
lastRawPos.current.set(event.localPosition[0], event.localPosition[1], event.localPosition[2])
const result = floorStrategy.move(getContext(), event)
if (!result) return
@@ -233,7 +239,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
// Only update X and Z for cursor - useFrame will handle Y (slab elevation)
cursorGroupRef.current.position.x = result.cursorPosition[0]
cursorGroupRef.current.position.z = result.cursorPosition[2]
lastRawPos.current.set(event.localPosition[0], event.localPosition[1], event.localPosition[2])
const draft = draftNode.current
if (draft) draft.position = result.gridPosition
@@ -499,13 +504,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
return
}
lastRawPos.current.set(event.position[0], event.position[1], event.position[2])
const result = itemSurfaceStrategy.move(ctx, event)
if (!result) return
event.stopPropagation()
gridPosition.current.set(...result.gridPosition)
lastRawPos.current.set(event.position[0], event.position[1], event.position[2])
const ic = worldToBuildingLocal(...result.cursorPosition)
cursorGroupRef.current.position.set(ic.x, ic.y, ic.z)
cursorGroupRef.current.rotation.y = result.cursorRotationY
@@ -609,6 +614,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
return
}
lastRawPos.current.set(event.position[0], event.position[1], event.position[2])
const result = ceilingStrategy.move(getContext(), event)
if (!result) return
@@ -625,7 +631,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
}
gridPosition.current.set(...result.gridPosition)
lastRawPos.current.set(event.position[0], event.position[1], event.position[2])
const cc = worldToBuildingLocal(...result.cursorPosition)
cursorGroupRef.current.position.set(cc.x, cc.y, cc.z)
@@ -701,23 +706,25 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
const ROTATION_STEP = Math.PI / 2
const onKeyDown = (event: KeyboardEvent) => {
// Don't intercept keys when focus is inside a text input
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
return
}
if (event.key === 'Shift') {
shiftFreeRef.current = true
revalidate()
return
}
// Don't intercept keys when focus is inside a text input
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
return
}
const draft = draftNode.current
if (!draft) return
let rotationDelta = 0
if (event.key === 'r' || event.key === 'R') rotationDelta = ROTATION_STEP
else if (event.key === 't' || event.key === 'T') rotationDelta = -ROTATION_STEP
if ((event.key === 'r' || event.key === 'R') && !event.metaKey && !event.ctrlKey)
rotationDelta = ROTATION_STEP
else if ((event.key === 't' || event.key === 'T') && !event.metaKey && !event.ctrlKey)
rotationDelta = -ROTATION_STEP
if (rotationDelta !== 0) {
event.preventDefault()
@@ -825,6 +832,29 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
const edgesGeometry = new EdgesGeometry(boxGeometry)
edgesRef.current.geometry = edgesGeometry
// ---- Undo protection ----
// Undo replaces the entire `nodes` object with a previous snapshot, which doesn't
// include the draft (created while temporal was paused). Re-insert it so the mesh
// doesn't disappear mid-placement.
// We defer via queueMicrotask to avoid nested setState during the undo callback.
// Temporal is already paused during placement, so createNode won't enter the undo stack.
let tearingDown = false
const unsubDraftWatch = useScene.subscribe((state) => {
if (tearingDown) return
const draft = draftNode.current
if (draft === null) return
if (draft.id in state.nodes) return
queueMicrotask(() => {
if (tearingDown) return
const draft = draftNode.current
if (draft === null) return
if (draft.id in useScene.getState().nodes) return
// Temporal is paused during placement, createNode won't be tracked
useScene.getState().createNode(draft, draft.parentId as AnyNodeId)
})
})
// ---- Subscribe ----
emitter.on('grid:move', onGridMove)
@@ -843,6 +873,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
emitter.on('ceiling:leave', onCeilingLeave)
return () => {
tearingDown = true
unsubDraftWatch()
// Clear live transform for any remaining draft
if (draftNode.current) {
useLiveTransforms.getState().clear(draftNode.current.id)
@@ -90,12 +90,17 @@ export function ControlModes() {
const setSelectionTool = useEditor((state) => state.setFloorplanSelectionTool)
const levelId = useViewer((s) => s.selection.levelId)
const levelNode = useScene((state) =>
levelId ? (state.nodes[levelId] as LevelNode | undefined) : undefined,
)
// Only subscribe to the primitive `level` number — when walls are added to
// this level the object ref changes but this number doesn't, so Object.is
// dedupes and we avoid a re-render.
const levelIndex = useScene((state) => {
if (!levelId) return null
const node = state.nodes[levelId]
return node?.type === 'level' ? (node as LevelNode).level : null
})
const isSiteEditing = phase === 'site'
const isGroundFloor = levelNode?.type === 'level' && levelNode.level === 0
const isGroundFloor = levelIndex === 0
const canEnterSiteEdit = isGroundFloor || isSiteEditing
const structureLayer = useEditor((state) => state.structureLayer)
@@ -44,8 +44,12 @@ export function EditorCommands() {
const register = useCommandRegistry((s) => s.register)
const { navigateTo, setInputValue, setOpen } = useCommandPalette()
const { setPhase, setMode, setTool, setStructureLayer, isPreviewMode, setPreviewMode } =
useEditor()
const setPhase = useEditor((s) => s.setPhase)
const setMode = useEditor((s) => s.setMode)
const setTool = useEditor((s) => s.setTool)
const setStructureLayer = useEditor((s) => s.setStructureLayer)
const isPreviewMode = useEditor((s) => s.isPreviewMode)
const setPreviewMode = useEditor((s) => s.setPreviewMode)
const exportScene = useViewer((s) => s.exportScene)
@@ -219,7 +219,6 @@ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEm
const views = usePaletteViewRegistry((s) => s.views)
const activeLevelId = useViewer((s) => s.selection.levelId)
const activeLevelNode = useScene((s) => (activeLevelId ? s.nodes[activeLevelId] : null))
const wallMode = useViewer((s) => s.wallMode)
const setWallMode = useViewer((s) => s.setWallMode)
@@ -13,18 +13,16 @@ import { SliderControl } from '../controls/slider-control'
import { PanelWrapper } from './panel-wrapper'
export function CeilingPanel() {
const selectedIds = useViewer((s) => s.selection.selectedIds)
const selectedId = useViewer((s) => s.selection.selectedIds[0])
const setSelection = useViewer((s) => s.setSelection)
const nodes = useScene((s) => s.nodes)
const updateNode = useScene((s) => s.updateNode)
const editingHole = useEditor((s) => s.editingHole)
const setEditingHole = useEditor((s) => s.setEditingHole)
const setMovingNode = useEditor((s) => s.setMovingNode)
const selectedId = selectedIds[0]
const node = selectedId
? (nodes[selectedId as AnyNode['id']] as CeilingNode | undefined)
: undefined
const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as CeilingNode | undefined) : undefined,
)
const handleUpdate = useCallback(
(updates: Partial<CeilingNode>) => {
@@ -129,7 +127,7 @@ export function CeilingPanel() {
setSelection({ selectedIds: [] })
}, [node, setMovingNode, setSelection])
if (!node || node.type !== 'ceiling' || selectedIds.length !== 1) return null
if (!(node && node.type === 'ceiling' && selectedId)) return null
const calculateArea = (polygon: Array<[number, number]>): number => {
if (polygon.length < 3) return 0
@@ -25,17 +25,17 @@ import { PanelWrapper } from './panel-wrapper'
import { PresetsPopover } from './presets/presets-popover'
export function DoorPanel() {
const selectedIds = useViewer((s) => s.selection.selectedIds)
const selectedId = useViewer((s) => s.selection.selectedIds[0])
const setSelection = useViewer((s) => s.setSelection)
const nodes = useScene((s) => s.nodes)
const updateNode = useScene((s) => s.updateNode)
const deleteNode = useScene((s) => s.deleteNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const adapter = usePresetsAdapter()
const selectedId = selectedIds[0]
const node = selectedId ? (nodes[selectedId as AnyNode['id']] as DoorNode | undefined) : undefined
const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as DoorNode | undefined) : undefined,
)
const handleUpdate = useCallback(
(updates: Partial<DoorNode>) => {
@@ -182,7 +182,7 @@ export function DoorPanel() {
[handleUpdate],
)
if (!node || node.type !== 'door' || selectedIds.length !== 1) return null
if (!(node && node.type === 'door' && selectedId)) return null
const hSum = node.segments.reduce((s, seg) => s + seg.heightRatio, 0)
const normHeights = node.segments.map((seg) => seg.heightRatio / hSum)
@@ -1,14 +1,6 @@
'use client'
import {
type AnyNode,
type AnyNodeId,
type FenceBaseStyle,
type FenceNode,
type FenceStyle,
type MaterialSchema,
useScene,
} from '@pascal-app/core'
import { type AnyNode, type AnyNodeId, type FenceNode, type MaterialSchema, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback } from 'react'
import { MaterialPicker } from '../controls/material-picker'
@@ -17,25 +9,29 @@ import { SegmentedControl } from '../controls/segmented-control'
import { SliderControl } from '../controls/slider-control'
import { PanelWrapper } from './panel-wrapper'
const FENCE_STYLE_OPTIONS: { label: string; value: FenceStyle }[] = [
type FenceStyleValue = 'slat' | 'rail' | 'privacy'
type FenceBaseStyleValue = 'grounded' | 'floating'
const FENCE_STYLE_OPTIONS: { label: string; value: FenceStyleValue }[] = [
{ label: 'Slat', value: 'slat' },
{ label: 'Rail', value: 'rail' },
{ label: 'Privacy', value: 'privacy' },
]
const FENCE_BASE_STYLE_OPTIONS: { label: string; value: FenceBaseStyle }[] = [
const FENCE_BASE_STYLE_OPTIONS: { label: string; value: FenceBaseStyleValue }[] = [
{ label: 'Grounded', value: 'grounded' },
{ label: 'Floating', value: 'floating' },
]
export function FencePanel() {
const selectedIds = useViewer((s) => s.selection.selectedIds)
const selectedId = useViewer((s) => s.selection.selectedIds[0])
const selectedCount = useViewer((s) => s.selection.selectedIds.length)
const setSelection = useViewer((s) => s.setSelection)
const nodes = useScene((s) => s.nodes)
const updateNode = useScene((s) => s.updateNode)
const selectedId = selectedIds[0]
const node = selectedId ? (nodes[selectedId as AnyNode['id']] as FenceNode | undefined) : undefined
const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as FenceNode | undefined) : undefined,
)
const handleUpdate = useCallback(
(updates: Partial<FenceNode>) => {
@@ -85,14 +81,19 @@ export function FencePanel() {
[handleUpdate],
)
if (!node || node.type !== 'fence' || selectedIds.length !== 1) return null
if (!(node && node.type === 'fence' && selectedId && selectedCount === 1)) return null
const dx = node.end[0] - node.start[0]
const dz = node.end[1] - node.start[1]
const length = Math.sqrt(dx * dx + dz * dz)
return (
<PanelWrapper icon="/icons/build.png" onClose={handleClose} title={node.name || 'Fence'} width={300}>
<PanelWrapper
icon="/icons/build.png"
onClose={handleClose}
title={node.name || 'Fence'}
width={300}
>
<PanelSection title="Style">
<SegmentedControl
onChange={(value) => handleUpdate({ style: value })}
@@ -14,15 +14,15 @@ import { CollectionsPopover } from './collections/collections-popover'
import { PanelWrapper } from './panel-wrapper'
export function ItemPanel() {
const selectedIds = useViewer((s) => s.selection.selectedIds)
const selectedId = useViewer((s) => s.selection.selectedIds[0])
const setSelection = useViewer((s) => s.setSelection)
const nodes = useScene((s) => s.nodes)
const updateNode = useScene((s) => s.updateNode)
const deleteNode = useScene((s) => s.deleteNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const selectedId = selectedIds[0]
const node = selectedId ? (nodes[selectedId as AnyNode['id']] as ItemNode | undefined) : undefined
const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as ItemNode | undefined) : undefined,
)
const [uniformScale, setUniformScale] = useState(true)
@@ -75,7 +75,7 @@ export function ItemPanel() {
setSelection({ selectedIds: [] })
}, [selectedId, deleteNode, setSelection])
if (!node || node.type !== 'item' || selectedIds.length !== 1) return null
if (!(node && node.type === 'item' && selectedId)) return null
return (
<PanelWrapper
@@ -19,7 +19,13 @@ import { WindowPanel } from './window-panel'
export function PanelManager() {
const selectedIds = useViewer((s) => s.selection.selectedIds)
const selectedReferenceId = useEditor((s) => s.selectedReferenceId)
const nodes = useScene((s) => s.nodes)
// Only subscribe to the *type* of the single-selected node — string primitive
// so we don't re-render on unrelated scene mutations.
const selectedNodeType = useScene((s) => {
if (selectedIds.length !== 1) return null
const id = selectedIds[0]
return id ? (s.nodes[id as AnyNodeId]?.type ?? null) : null
})
// Show reference panel if a reference is selected
if (selectedReferenceId) {
@@ -27,34 +33,30 @@ export function PanelManager() {
}
// Show appropriate panel based on selected node type
if (selectedIds.length === 1) {
const selectedNode = selectedIds[0]
const node = nodes[selectedNode as AnyNodeId]
if (node) {
switch (node.type) {
case 'item':
return <ItemPanel />
case 'roof':
return <RoofPanel />
case 'roof-segment':
return <RoofSegmentPanel />
case 'stair':
return <StairPanel />
case 'stair-segment':
return <StairSegmentPanel />
case 'slab':
return <SlabPanel />
case 'ceiling':
return <CeilingPanel />
case 'wall':
return <WallPanel />
case 'fence':
return <FencePanel />
case 'door':
return <DoorPanel />
case 'window':
return <WindowPanel />
}
if (selectedNodeType) {
switch (selectedNodeType) {
case 'item':
return <ItemPanel />
case 'roof':
return <RoofPanel />
case 'roof-segment':
return <RoofSegmentPanel />
case 'stair':
return <StairPanel />
case 'stair-segment':
return <StairSegmentPanel />
case 'slab':
return <SlabPanel />
case 'ceiling':
return <CeilingPanel />
case 'wall':
return <WallPanel />
case 'fence':
return <FencePanel />
case 'door':
return <DoorPanel />
case 'window':
return <WindowPanel />
}
}
@@ -15,12 +15,13 @@ type ReferenceNode = ScanNode | GuideNode
export function ReferencePanel() {
const selectedReferenceId = useEditor((s) => s.selectedReferenceId)
const setSelectedReferenceId = useEditor((s) => s.setSelectedReferenceId)
const nodes = useScene((s) => s.nodes)
const updateNode = useScene((s) => s.updateNode)
const node = selectedReferenceId
? (nodes[selectedReferenceId as AnyNode['id']] as ReferenceNode | undefined)
: undefined
const node = useScene((s) =>
selectedReferenceId
? (s.nodes[selectedReferenceId as AnyNode['id']] as ReferenceNode | undefined)
: undefined,
)
const handleUpdate = useCallback(
(updates: Partial<ReferenceNode>) => {
@@ -13,6 +13,7 @@ import {
import { useViewer } from '@pascal-app/viewer'
import { Copy, Move, Plus, Trash2 } from 'lucide-react'
import { useCallback } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button'
@@ -22,15 +23,24 @@ import { SliderControl } from '../controls/slider-control'
import { PanelWrapper } from './panel-wrapper'
export function RoofPanel() {
const selectedIds = useViewer((s) => s.selection.selectedIds)
const selectedId = useViewer((s) => s.selection.selectedIds[0])
const setSelection = useViewer((s) => s.setSelection)
const nodes = useScene((s) => s.nodes)
const updateNode = useScene((s) => s.updateNode)
const createNode = useScene((s) => s.createNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const selectedId = selectedIds[0]
const node = selectedId ? (nodes[selectedId as AnyNode['id']] as RoofNode | undefined) : undefined
const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as RoofNode | undefined) : undefined,
)
// Shallow selector — only re-renders when the segment list content changes.
const segments = useScene(
useShallow((s) => {
if (!node) return []
return (node.children ?? [])
.map((childId) => s.nodes[childId as AnyNodeId] as RoofSegmentNode | undefined)
.filter((n): n is RoofSegmentNode => n?.type === 'roof-segment')
}),
)
const handleUpdate = useCallback(
(updates: Partial<RoofNode>) => {
@@ -137,11 +147,7 @@ export function RoofPanel() {
setSelection({ selectedIds: [] })
}, [selectedId, node, setSelection])
if (!node || node.type !== 'roof' || selectedIds.length !== 1) return null
const segments = (node.children ?? [])
.map((childId) => nodes[childId as AnyNodeId] as RoofSegmentNode | undefined)
.filter((n): n is RoofSegmentNode => n?.type === 'roof-segment')
if (!(node && node.type === 'roof' && selectedId)) return null
return (
<PanelWrapper
@@ -35,16 +35,14 @@ const ROOF_TYPE_OPTIONS_2: { label: string; value: RoofType }[] = [
]
export function RoofSegmentPanel() {
const selectedIds = useViewer((s) => s.selection.selectedIds)
const selectedId = useViewer((s) => s.selection.selectedIds[0])
const setSelection = useViewer((s) => s.setSelection)
const nodes = useScene((s) => s.nodes)
const updateNode = useScene((s) => s.updateNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const selectedId = selectedIds[0]
const node = selectedId
? (nodes[selectedId as AnyNode['id']] as RoofSegmentNode | undefined)
: undefined
const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as RoofSegmentNode | undefined) : undefined,
)
const handleUpdate = useCallback(
(updates: Partial<RoofSegmentNode>) => {
@@ -123,7 +121,7 @@ export function RoofSegmentPanel() {
}
}, [selectedId, node, setSelection])
if (!node || node.type !== 'roof-segment' || selectedIds.length !== 1) return null
if (!(node && node.type === 'roof-segment' && selectedId)) return null
return (
<PanelWrapper
@@ -13,16 +13,16 @@ import { SliderControl } from '../controls/slider-control'
import { PanelWrapper } from './panel-wrapper'
export function SlabPanel() {
const selectedIds = useViewer((s) => s.selection.selectedIds)
const selectedId = useViewer((s) => s.selection.selectedIds[0])
const setSelection = useViewer((s) => s.setSelection)
const nodes = useScene((s) => s.nodes)
const updateNode = useScene((s) => s.updateNode)
const editingHole = useEditor((s) => s.editingHole)
const setEditingHole = useEditor((s) => s.setEditingHole)
const setMovingNode = useEditor((s) => s.setMovingNode)
const selectedId = selectedIds[0]
const node = selectedId ? (nodes[selectedId as AnyNode['id']] as SlabNode | undefined) : undefined
const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as SlabNode | undefined) : undefined,
)
const handleUpdate = useCallback(
(updates: Partial<SlabNode>) => {
@@ -127,7 +127,7 @@ export function SlabPanel() {
setSelection({ selectedIds: [] })
}, [node, setMovingNode, setSelection])
if (!node || node.type !== 'slab' || selectedIds.length !== 1) return null
if (!(node && node.type === 'slab' && selectedId)) return null
const calculateArea = (polygon: Array<[number, number]>): number => {
if (polygon.length < 3) return 0
@@ -18,6 +18,7 @@ import {
import { useViewer } from '@pascal-app/viewer'
import { Copy, Move, Plus, Trash2 } from 'lucide-react'
import { useCallback } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE } from '../../tools/stair/stair-defaults'
@@ -54,18 +55,34 @@ const STAIR_SLAB_OPENING_OPTIONS: { label: string; value: StairSlabOpeningMode }
]
export function StairPanel() {
const selectedIds = useViewer((s) => s.selection.selectedIds)
const selectedId = useViewer((s) => s.selection.selectedIds[0])
const selectedCount = useViewer((s) => s.selection.selectedIds.length)
const setSelection = useViewer((s) => s.setSelection)
const nodes = useScene((s) => s.nodes)
const updateNode = useScene((s) => s.updateNode)
const createNode = useScene((s) => s.createNode)
const createNodes = useScene((s) => s.createNodes)
const setMovingNode = useEditor((s) => s.setMovingNode)
const selectedId = selectedIds[0]
const node = selectedId
? (nodes[selectedId as AnyNode['id']] as StairNode | undefined)
: undefined
const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as StairNode | undefined) : undefined,
)
const levels = useScene(
useShallow((s) =>
Object.values(s.nodes)
.filter((entry): entry is LevelNode => entry.type === 'level')
.sort((left, right) => left.level - right.level),
),
)
const segments = useScene(
useShallow((s) => {
if (!selectedId) return []
const stairNode = s.nodes[selectedId as AnyNode['id']] as StairNode | undefined
if (stairNode?.type !== 'stair') return []
return (stairNode.children ?? [])
.map((childId) => s.nodes[childId as AnyNodeId] as StairSegmentNode | undefined)
.filter((entry): entry is StairSegmentNode => entry?.type === 'stair-segment')
}),
)
const handleUpdate = useCallback(
(updates: Partial<StairNode>) => {
@@ -98,13 +115,15 @@ export function StairPanel() {
const children = node.children ?? []
const lastChildId = children[children.length - 1]
if (lastChildId) {
const lastChild = nodes[lastChildId as AnyNodeId] as StairSegmentNode | undefined
const lastChild = useScene.getState().nodes[lastChildId as AnyNodeId] as
| StairSegmentNode
| undefined
if (lastChild?.type === 'stair-segment') {
return { fillToFloor: lastChild.fillToFloor }
}
}
return { fillToFloor: true }
}, [node, nodes])
}, [node])
const handleAddFlight = useCallback(() => {
if (!node) return
@@ -208,16 +227,10 @@ export function StairPanel() {
setSelection({ selectedIds: [] })
}, [selectedId, node, setSelection])
if (!node || node.type !== 'stair' || selectedIds.length !== 1) return null
if (!(node && node.type === 'stair' && selectedId && selectedCount === 1)) return null
const levels = Object.values(nodes)
.filter((entry): entry is LevelNode => entry.type === 'level')
.sort((left, right) => left.level - right.level)
const resolvedFromLevelId = node.fromLevelId ?? node.parentId ?? levels[0]?.id ?? null
const resolvedToLevelId = node.toLevelId ?? resolvedFromLevelId
const segments = (node.children ?? [])
.map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined)
.filter((n): n is StairSegmentNode => n?.type === 'stair-segment')
return (
<PanelWrapper
@@ -34,25 +34,24 @@ const ATTACHMENT_SIDE_OPTIONS: { label: string; value: AttachmentSide }[] = [
]
export function StairSegmentPanel() {
const selectedIds = useViewer((s) => s.selection.selectedIds)
const selectedId = useViewer((s) => s.selection.selectedIds[0])
const setSelection = useViewer((s) => s.setSelection)
const nodes = useScene((s) => s.nodes)
const updateNode = useScene((s) => s.updateNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const selectedId = selectedIds[0]
const node = selectedId
? (nodes[selectedId as AnyNode['id']] as StairSegmentNode | undefined)
: undefined
const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as StairSegmentNode | undefined) : undefined,
)
// Check if this is the first segment in the parent stair
const isFirstSegment = (() => {
// Boolean selector — re-renders only when this segment's position among the
// parent stair's children flips to/from "first".
const isFirstSegment = useScene((s) => {
if (!node?.parentId) return true
const parent = nodes[node.parentId as AnyNodeId]
const parent = s.nodes[node.parentId as AnyNodeId]
if (!parent || parent.type !== 'stair') return true
const children = (parent as any).children ?? []
return children[0] === node.id
})()
})
const handleUpdate = useCallback(
(updates: Partial<StairSegmentNode>) => {
@@ -130,7 +129,7 @@ export function StairSegmentPanel() {
}
}, [selectedId, node, setSelection])
if (!node || node.type !== 'stair-segment' || selectedIds.length !== 1) return null
if (!(node && node.type === 'stair-segment' && selectedId)) return null
return (
<PanelWrapper
@@ -23,15 +23,31 @@ import { SliderControl } from '../controls/slider-control'
import { PanelWrapper } from './panel-wrapper'
export function WallPanel() {
const selectedIds = useViewer((s) => s.selection.selectedIds)
const selectedId = useViewer((s) => s.selection.selectedIds[0])
const setSelection = useViewer((s) => s.setSelection)
const nodes = useScene((s) => s.nodes)
const updateNode = useScene((s) => s.updateNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const setCurvingWall = useEditor((s) => s.setCurvingWall)
const selectedId = selectedIds[0]
const node = selectedId ? (nodes[selectedId as AnyNode['id']] as WallNode | undefined) : undefined
const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as WallNode | undefined) : undefined,
)
// Boolean selector — re-renders only when this specific wall's child
// composition crosses the "has a door/window/wall-item" threshold.
const hasWallChildrenBlockingCurve = useScene((s) => {
if (!node) return false
return (node.children ?? []).some((childId) => {
const child = s.nodes[childId as AnyNodeId]
if (!child) return false
if (child.type === 'door' || child.type === 'window') return true
if (child.type === 'item') {
const attachTo = child.asset?.attachTo
return attachTo === 'wall' || attachTo === 'wall-side'
}
return false
})
})
const handleUpdate = useCallback(
(updates: Partial<WallNode>) => {
@@ -97,7 +113,7 @@ export function WallPanel() {
setSelection({ selectedIds: [] })
}, [node, setCurvingWall, setSelection])
if (!node || node.type !== 'wall' || selectedIds.length !== 1) return null
if (!(node && node.type === 'wall' && selectedId)) return null
const dx = node.end[0] - node.start[0]
const dz = node.end[1] - node.start[1]
@@ -107,16 +123,6 @@ export function WallPanel() {
const thickness = node.thickness ?? 0.1
const curveOffset = getClampedWallCurveOffset(node)
const maxCurveOffset = getMaxWallCurveOffset(node)
const hasWallChildrenBlockingCurve = (node.children ?? []).some((childId) => {
const child = nodes[childId as AnyNodeId]
if (!child) return false
if (child.type === 'door' || child.type === 'window') return true
if (child.type === 'item') {
const attachTo = child.asset?.attachTo
return attachTo === 'wall' || attachTo === 'wall-side'
}
return false
})
return (
<PanelWrapper
@@ -24,19 +24,17 @@ import { PanelWrapper } from './panel-wrapper'
import { PresetsPopover } from './presets/presets-popover'
export function WindowPanel() {
const selectedIds = useViewer((s) => s.selection.selectedIds)
const selectedId = useViewer((s) => s.selection.selectedIds[0])
const setSelection = useViewer((s) => s.setSelection)
const nodes = useScene((s) => s.nodes)
const updateNode = useScene((s) => s.updateNode)
const deleteNode = useScene((s) => s.deleteNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const adapter = usePresetsAdapter()
const selectedId = selectedIds[0]
const node = selectedId
? (nodes[selectedId as AnyNode['id']] as WindowNode | undefined)
: undefined
const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as WindowNode | undefined) : undefined,
)
const handleUpdate = useCallback(
(updates: Partial<WindowNode>) => {
@@ -153,7 +151,7 @@ export function WindowPanel() {
[handleUpdate],
)
if (!node || node.type !== 'window' || selectedIds.length !== 1) return null
if (!(node && node.type === 'window' && selectedId)) return null
const numCols = node.columnRatios.length
const numRows = node.rowRatios.length
@@ -1,7 +1,7 @@
import { type AnyNodeId, type BuildingNode, LevelNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Building2, Plus } from 'lucide-react'
import { useState } from 'react'
import { memo, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import {
Tooltip,
@@ -17,7 +17,11 @@ interface BuildingTreeNodeProps {
isLast?: boolean
}
export function BuildingTreeNode({ nodeId, depth, isLast }: BuildingTreeNodeProps) {
export const BuildingTreeNode = memo(function BuildingTreeNode({
nodeId,
depth,
isLast,
}: BuildingTreeNodeProps) {
const [expanded, setExpanded] = useState(true)
const createNode = useScene((state) => state.createNode)
const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
@@ -84,4 +88,4 @@ export function BuildingTreeNode({ nodeId, depth, isLast }: BuildingTreeNodeProp
))}
</TreeNodeWrapper>
)
}
})
@@ -1,7 +1,7 @@
import { type AnyNodeId, type CeilingNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import Image from 'next/image'
import { useCallback, useEffect, useState } from 'react'
import { memo, useCallback, useEffect, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import useEditor from './../../../../../store/use-editor'
import { InlineRenameInput } from './inline-rename-input'
@@ -14,7 +14,11 @@ interface CeilingTreeNodeProps {
isLast?: boolean
}
export function CeilingTreeNode({ nodeId, depth, isLast }: CeilingTreeNodeProps) {
export const CeilingTreeNode = memo(function CeilingTreeNode({
nodeId,
depth,
isLast,
}: CeilingTreeNodeProps) {
const [expanded, setExpanded] = useState(false)
const [isEditing, setIsEditing] = useState(false)
const isVisible = useScene((s) => s.nodes[nodeId as AnyNodeId]?.visible !== false)
@@ -113,7 +117,7 @@ export function CeilingTreeNode({ nodeId, depth, isLast }: CeilingTreeNodeProps)
))}
</TreeNodeWrapper>
)
}
})
/**
* Calculate the area of a polygon using the shoelace formula
@@ -3,7 +3,7 @@
import { type AnyNodeId, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import Image from 'next/image'
import { useCallback, useState } from 'react'
import { memo, useCallback, useState } from 'react'
import useEditor from './../../../../../store/use-editor'
import { InlineRenameInput } from './inline-rename-input'
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
@@ -15,7 +15,11 @@ interface DoorTreeNodeProps {
isLast?: boolean
}
export function DoorTreeNode({ nodeId, depth, isLast }: DoorTreeNodeProps) {
export const DoorTreeNode = memo(function DoorTreeNode({
nodeId,
depth,
isLast,
}: DoorTreeNodeProps) {
const [isEditing, setIsEditing] = useState(false)
const isVisible = useScene((s) => s.nodes[nodeId as AnyNodeId]?.visible !== false)
const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
@@ -72,4 +76,4 @@ export function DoorTreeNode({ nodeId, depth, isLast }: DoorTreeNodeProps) {
onToggle={() => {}}
/>
)
}
})
@@ -1,7 +1,7 @@
import { type AnyNodeId, type FenceNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import Image from 'next/image'
import { useState } from 'react'
import { memo, useState } from 'react'
import useEditor from '../../../../../store/use-editor'
import { InlineRenameInput } from './inline-rename-input'
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
@@ -13,7 +13,11 @@ interface FenceTreeNodeProps {
isLast?: boolean
}
export function FenceTreeNode({ nodeId, depth, isLast }: FenceTreeNodeProps) {
export const FenceTreeNode = memo(function FenceTreeNode({
nodeId,
depth,
isLast,
}: FenceTreeNodeProps) {
const node = useScene((state) => state.nodes[nodeId]) as FenceNode | undefined
const [isEditing, setIsEditing] = useState(false)
const selectedIds = useViewer((state) => state.selection.selectedIds)
@@ -62,4 +66,4 @@ export function FenceTreeNode({ nodeId, depth, isLast }: FenceTreeNodeProps) {
onToggle={() => {}}
/>
)
}
})
@@ -23,7 +23,7 @@ import {
X,
} from 'lucide-react'
import { AnimatePresence, LayoutGroup, motion } from 'motion/react'
import { useEffect, useRef, useState } from 'react'
import { memo, useEffect, useRef, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { ColorDot } from './../../../../../components/ui/primitives/color-dot'
import {
@@ -80,7 +80,7 @@ function useSiteNode(): SiteNode | null {
)
}
function PropertyLineSection() {
const PropertyLineSection = memo(function PropertyLineSection() {
const siteNode = useSiteNode()
const updateNode = useScene((state) => state.updateNode)
const mode = useEditor((state) => state.mode)
@@ -218,13 +218,13 @@ function PropertyLineSection() {
)}
</div>
)
}
})
// ============================================================================
// SITE PHASE VIEW - Property line + building buttons
// ============================================================================
function CameraPopover({
const CameraPopover = memo(function CameraPopover({
nodeId,
hasCamera,
open,
@@ -303,9 +303,9 @@ function CameraPopover({
</PopoverContent>
</Popover>
)
}
})
function ReferenceItem({
const ReferenceItem = memo(function ReferenceItem({
refNode,
isLastRow,
setSelectedReferenceId,
@@ -375,7 +375,7 @@ function ReferenceItem({
</button>
</div>
)
}
})
const MAX_FILE_SIZE = 200 * 1024 * 1024 // 200MB
@@ -387,7 +387,7 @@ interface LevelReferencesProps {
onDeleteAsset?: (projectId: string, url: string) => void
}
function LevelReferences({
const LevelReferences = memo(function LevelReferences({
levelId,
isLastLevel,
projectId,
@@ -554,9 +554,9 @@ function LevelReferences({
)}
</div>
)
}
})
function LevelItem({
const LevelItem = memo(function LevelItem({
level,
selectedLevelId,
setSelection,
@@ -784,9 +784,9 @@ function LevelItem({
</AnimatePresence>
</div>
)
}
})
function LevelsSection({
const LevelsSection = memo(function LevelsSection({
projectId,
onUploadAsset,
onDeleteAsset,
@@ -870,9 +870,9 @@ function LevelsSection({
</div>
</div>
)
}
})
function LayerToggle() {
const LayerToggle = memo(function LayerToggle() {
const structureLayer = useEditor((state) => state.structureLayer)
const setStructureLayer = useEditor((state) => state.setStructureLayer)
const phase = useEditor((state) => state.phase)
@@ -1000,9 +1000,9 @@ function LayerToggle() {
</button>
</div>
)
}
})
function ZoneItem({ zone, isLast }: { zone: ZoneNode; isLast?: boolean }) {
const ZoneItem = memo(function ZoneItem({ zone, isLast }: { zone: ZoneNode; isLast?: boolean }) {
const [isEditing, setIsEditing] = useState(false)
const [cameraPopoverOpen, setCameraPopoverOpen] = useState(false)
const deleteNode = useScene((state) => state.deleteNode)
@@ -1163,9 +1163,9 @@ function ZoneItem({ zone, isLast }: { zone: ZoneNode; isLast?: boolean }) {
</div>
</div>
)
}
})
function MultiSelectionBadge() {
const MultiSelectionBadge = memo(function MultiSelectionBadge() {
const selectedIds = useViewer((state) => state.selection.selectedIds)
const setSelection = useViewer((state) => state.setSelection)
@@ -1185,9 +1185,9 @@ function MultiSelectionBadge() {
</div>
</div>
)
}
})
function ContentSection() {
const ContentSection = memo(function ContentSection() {
const selectedLevelId = useViewer((state) => state.selection.levelId)
const structureLayer = useEditor((state) => state.structureLayer)
const phase = useEditor((state) => state.phase)
@@ -1265,9 +1265,9 @@ function ContentSection() {
</div>
</TreeNodeDragProvider>
)
}
})
function BuildingItem({
const BuildingItem = memo(function BuildingItem({
building,
isBuildingActive,
buildingCameraOpen,
@@ -1308,19 +1308,16 @@ function BuildingItem({
}
return (
<motion.div
<div
className={cn('flex shrink-0 flex-col overflow-hidden', isBuildingActive && 'min-h-0 flex-1')}
layout
transition={{ type: 'spring', bounce: 0, duration: 0.4 }}
>
<motion.div
<div
className={cn(
'group/building flex h-10 shrink-0 cursor-pointer items-center border-border/50 border-b pr-2 transition-all duration-200',
isBuildingActive
? 'bg-accent/50 text-foreground'
: 'text-muted-foreground hover:bg-accent/30 hover:text-foreground',
)}
layout="position"
onClick={handleSelect}
onDoubleClick={handleDoubleClick}
ref={itemRef}
@@ -1404,7 +1401,7 @@ function BuildingItem({
</div>
</PopoverContent>
</Popover>
</motion.div>
</div>
{/* Tools and content for the active building */}
<AnimatePresence initial={false}>
@@ -1433,9 +1430,9 @@ function BuildingItem({
</motion.div>
)}
</AnimatePresence>
</motion.div>
</div>
)
}
})
export interface SitePanelProps {
projectId?: string
@@ -1534,7 +1531,7 @@ export function SitePanel({ projectId, onUploadAsset, onDeleteAsset }: SitePanel
No buildings yet
</motion.div>
) : (
<motion.div className="flex min-h-0 flex-1 flex-col" layout>
<div className="flex min-h-0 flex-1 flex-col">
{buildings.map((building) => {
const isBuildingActive =
(phase === 'structure' || phase === 'furnish') &&
@@ -1553,7 +1550,7 @@ export function SitePanel({ projectId, onUploadAsset, onDeleteAsset }: SitePanel
/>
)
})}
</motion.div>
</div>
)}
</motion.div>
</div>
@@ -1,7 +1,7 @@
import { type AnyNodeId, type ItemNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import Image from 'next/image'
import { useCallback, useEffect, useState } from 'react'
import { memo, useCallback, useEffect, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import useEditor from './../../../../../store/use-editor'
import { InlineRenameInput } from './inline-rename-input'
@@ -24,7 +24,11 @@ interface ItemTreeNodeProps {
isLast?: boolean
}
export function ItemTreeNode({ nodeId, depth, isLast }: ItemTreeNodeProps) {
export const ItemTreeNode = memo(function ItemTreeNode({
nodeId,
depth,
isLast,
}: ItemTreeNodeProps) {
const [isEditing, setIsEditing] = useState(false)
const [expanded, setExpanded] = useState(true)
const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
@@ -121,4 +125,4 @@ export function ItemTreeNode({ nodeId, depth, isLast }: ItemTreeNodeProps) {
))}
</TreeNodeWrapper>
)
}
})
@@ -1,7 +1,7 @@
import { type AnyNodeId, type LevelNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Layers } from 'lucide-react'
import { useCallback, useState } from 'react'
import { memo, useCallback, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { InlineRenameInput } from './inline-rename-input'
import { focusTreeNode, TreeNode, TreeNodeWrapper } from './tree-node'
@@ -13,7 +13,11 @@ interface LevelTreeNodeProps {
isLast?: boolean
}
export function LevelTreeNode({ nodeId, depth, isLast }: LevelTreeNodeProps) {
export const LevelTreeNode = memo(function LevelTreeNode({
nodeId,
depth,
isLast,
}: LevelTreeNodeProps) {
const [expanded, setExpanded] = useState(true)
const [isEditing, setIsEditing] = useState(false)
const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
@@ -67,4 +71,4 @@ export function LevelTreeNode({ nodeId, depth, isLast }: LevelTreeNodeProps) {
))}
</TreeNodeWrapper>
)
}
})
@@ -2,7 +2,7 @@ import { type AnyNodeId, type RoofNode, type RoofSegmentNode, useScene } from '@
import { useViewer } from '@pascal-app/viewer'
import { AnimatePresence } from 'motion/react'
import Image from 'next/image'
import { useCallback, useEffect, useState } from 'react'
import { memo, useCallback, useEffect, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import useEditor from '../../../../../store/use-editor'
import { InlineRenameInput } from './inline-rename-input'
@@ -16,7 +16,11 @@ interface RoofTreeNodeProps {
isLast?: boolean
}
export function RoofTreeNode({ nodeId, depth, isLast }: RoofTreeNodeProps) {
export const RoofTreeNode = memo(function RoofTreeNode({
nodeId,
depth,
isLast,
}: RoofTreeNodeProps) {
const [isEditing, setIsEditing] = useState(false)
const [expanded, setExpanded] = useState(false)
const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
@@ -147,7 +151,7 @@ export function RoofTreeNode({ nodeId, depth, isLast }: RoofTreeNodeProps) {
</TreeNodeWrapper>
</div>
)
}
})
function RoofSegmentTreeNode({
node,
@@ -1,7 +1,7 @@
import { type AnyNodeId, type SlabNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import Image from 'next/image'
import { useCallback, useState } from 'react'
import { memo, useCallback, useState } from 'react'
import useEditor from './../../../../../store/use-editor'
import { InlineRenameInput } from './inline-rename-input'
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
@@ -13,7 +13,11 @@ interface SlabTreeNodeProps {
isLast?: boolean
}
export function SlabTreeNode({ nodeId, depth, isLast }: SlabTreeNodeProps) {
export const SlabTreeNode = memo(function SlabTreeNode({
nodeId,
depth,
isLast,
}: SlabTreeNodeProps) {
const [isEditing, setIsEditing] = useState(false)
const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
const polygon = useScene((s) => (s.nodes[nodeId] as SlabNode | undefined)?.polygon ?? [])
@@ -74,7 +78,7 @@ export function SlabTreeNode({ nodeId, depth, isLast }: SlabTreeNodeProps) {
onToggle={() => {}}
/>
)
}
})
/**
* Calculate the area of a polygon using the shoelace formula
@@ -2,7 +2,7 @@ import { type AnyNodeId, type StairNode, type StairSegmentNode, useScene } from
import { useViewer } from '@pascal-app/viewer'
import { AnimatePresence } from 'motion/react'
import Image from 'next/image'
import { useCallback, useEffect, useState } from 'react'
import { memo, useCallback, useEffect, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import useEditor from '../../../../../store/use-editor'
import { InlineRenameInput } from './inline-rename-input'
@@ -16,7 +16,11 @@ interface StairTreeNodeProps {
isLast?: boolean
}
export function StairTreeNode({ nodeId, depth, isLast }: StairTreeNodeProps) {
export const StairTreeNode = memo(function StairTreeNode({
nodeId,
depth,
isLast,
}: StairTreeNodeProps) {
const [isEditing, setIsEditing] = useState(false)
const [expanded, setExpanded] = useState(false)
const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
@@ -147,7 +151,7 @@ export function StairTreeNode({ nodeId, depth, isLast }: StairTreeNodeProps) {
</TreeNodeWrapper>
</div>
)
}
})
function StairSegmentTreeNode({
node,
@@ -1,7 +1,7 @@
import { type AnyNodeId, emitter, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Camera, Eye, EyeOff, Trash2 } from 'lucide-react'
import { useState } from 'react'
import { memo, useState } from 'react'
import {
Popover,
PopoverContent,
@@ -12,7 +12,7 @@ interface TreeNodeActionsProps {
nodeId: AnyNodeId
}
export function TreeNodeActions({ nodeId }: TreeNodeActionsProps) {
export const TreeNodeActions = memo(function TreeNodeActions({ nodeId }: TreeNodeActionsProps) {
const [open, setOpen] = useState(false)
const updateNode = useScene((state) => state.updateNode)
const updateNodes = useScene((state) => state.updateNodes)
@@ -112,4 +112,4 @@ export function TreeNodeActions({ nodeId }: TreeNodeActionsProps) {
</Popover>
</div>
)
}
})
@@ -1,7 +1,7 @@
import { type AnyNodeId, emitter, useScene } from '@pascal-app/core'
import { ChevronRight } from 'lucide-react'
import { AnimatePresence, motion } from 'motion/react'
import { forwardRef, useEffect, useRef } from 'react'
import { forwardRef, memo, useEffect, useRef } from 'react'
export function handleTreeSelection(
e: React.MouseEvent,
@@ -73,7 +73,7 @@ interface TreeNodeProps {
isLast?: boolean
}
export function TreeNode({ nodeId, depth = 0, isLast }: TreeNodeProps) {
export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: TreeNodeProps) {
const nodeType = useScene((state) => state.nodes[nodeId]?.type)
if (!nodeType) return null
@@ -106,7 +106,7 @@ export function TreeNode({ nodeId, depth = 0, isLast }: TreeNodeProps) {
default:
return null
}
}
})
interface TreeNodeWrapperProps {
nodeId?: string
@@ -1,7 +1,7 @@
import { type AnyNodeId, useScene, type WallNode } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import Image from 'next/image'
import { useCallback, useEffect, useRef, useState } from 'react'
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import useEditor from './../../../../../store/use-editor'
import { InlineRenameInput } from './inline-rename-input'
@@ -14,7 +14,11 @@ interface WallTreeNodeProps {
isLast?: boolean
}
export function WallTreeNode({ nodeId, depth, isLast }: WallTreeNodeProps) {
export const WallTreeNode = memo(function WallTreeNode({
nodeId,
depth,
isLast,
}: WallTreeNodeProps) {
const [expanded, setExpanded] = useState(false)
const [isEditing, setIsEditing] = useState(false)
const isVisible = useScene((s) => s.nodes[nodeId as AnyNodeId]?.visible !== false)
@@ -107,4 +111,4 @@ export function WallTreeNode({ nodeId, depth, isLast }: WallTreeNodeProps) {
))}
</TreeNodeWrapper>
)
}
})
@@ -3,7 +3,7 @@
import { type AnyNodeId, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import Image from 'next/image'
import { useCallback, useState } from 'react'
import { memo, useCallback, useState } from 'react'
import useEditor from './../../../../../store/use-editor'
import { InlineRenameInput } from './inline-rename-input'
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
@@ -15,7 +15,11 @@ interface WindowTreeNodeProps {
isLast?: boolean
}
export function WindowTreeNode({ nodeId, depth, isLast }: WindowTreeNodeProps) {
export const WindowTreeNode = memo(function WindowTreeNode({
nodeId,
depth,
isLast,
}: WindowTreeNodeProps) {
const [isEditing, setIsEditing] = useState(false)
const isVisible = useScene((s) => s.nodes[nodeId as AnyNodeId]?.visible !== false)
const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
@@ -72,4 +76,4 @@ export function WindowTreeNode({ nodeId, depth, isLast }: WindowTreeNodeProps) {
onToggle={() => {}}
/>
)
}
})
@@ -1,6 +1,6 @@
import { type AnyNodeId, useScene, type ZoneNode } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useState } from 'react'
import { memo, useCallback, useState } from 'react'
import { ColorDot } from './../../../../../components/ui/primitives/color-dot'
import { InlineRenameInput } from './inline-rename-input'
import { focusTreeNode, TreeNodeWrapper } from './tree-node'
@@ -12,7 +12,11 @@ interface ZoneTreeNodeProps {
isLast?: boolean
}
export function ZoneTreeNode({ nodeId, depth, isLast }: ZoneTreeNodeProps) {
export const ZoneTreeNode = memo(function ZoneTreeNode({
nodeId,
depth,
isLast,
}: ZoneTreeNodeProps) {
const [isEditing, setIsEditing] = useState(false)
const updateNode = useScene((state) => state.updateNode)
const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
@@ -61,7 +65,7 @@ export function ZoneTreeNode({ nodeId, depth, isLast }: ZoneTreeNodeProps) {
onToggle={() => {}}
/>
)
}
})
/**
* Calculate the area of a polygon using the shoelace formula
@@ -14,6 +14,7 @@ import { useViewer } from '@pascal-app/viewer'
import { ArrowLeft, Camera, ChevronRight, Diamond, Layers, Moon, Sun } from 'lucide-react'
import { motion } from 'motion/react'
import Link from 'next/link'
import { useShallow } from 'zustand/react/shallow'
import { cn } from '../lib/utils'
import { ActionButton } from './ui/action-menu/action-button'
import { TooltipProvider } from './ui/primitives/tooltip'
@@ -87,7 +88,6 @@ export const ViewerOverlay = ({
onBack,
}: ViewerOverlayProps) => {
const selection = useViewer((s) => s.selection)
const nodes = useScene((s) => s.nodes)
const showScans = useViewer((s) => s.showScans)
const showGuides = useViewer((s) => s.showGuides)
const cameraMode = useViewer((s) => s.cameraMode)
@@ -95,24 +95,30 @@ export const ViewerOverlay = ({
const wallMode = useViewer((s) => s.wallMode)
const theme = useViewer((s) => s.theme)
const building = selection.buildingId
? (nodes[selection.buildingId] as BuildingNode | undefined)
: null
const level = selection.levelId ? (nodes[selection.levelId] as LevelNode | undefined) : null
const zone = selection.zoneId ? (nodes[selection.zoneId] as ZoneNode | undefined) : null
// Get the first selected item (if any)
const selectedNode =
selection.selectedIds.length > 0
? (nodes[selection.selectedIds[0] as AnyNodeId] as AnyNode | undefined)
: null
// Get all levels for the selected building
const levels =
building?.children
.map((id) => nodes[id as AnyNodeId] as LevelNode | undefined)
.filter((n): n is LevelNode => n?.type === 'level')
.sort((a, b) => a.level - b.level) ?? []
// Subscribe only to the specific nodes we read so that creating an unrelated
// node elsewhere in the scene doesn't re-render this overlay.
const firstSelectedId = selection.selectedIds[0] ?? null
const building = useScene((s) =>
selection.buildingId ? (s.nodes[selection.buildingId] as BuildingNode | undefined) : null,
)
const level = useScene((s) =>
selection.levelId ? (s.nodes[selection.levelId] as LevelNode | undefined) : null,
)
const zone = useScene((s) =>
selection.zoneId ? (s.nodes[selection.zoneId] as ZoneNode | undefined) : null,
)
const selectedNode = useScene((s) =>
firstSelectedId ? (s.nodes[firstSelectedId as AnyNodeId] as AnyNode | undefined) : null,
)
const levels = useScene(
useShallow((s) => {
if (!building) return []
return building.children
.map((id) => s.nodes[id as AnyNodeId] as LevelNode | undefined)
.filter((n): n is LevelNode => n?.type === 'level')
.sort((a, b) => a.level - b.level)
}),
)
const handleLevelClick = (levelId: LevelNode['id']) => {
// When switching levels, deselect zone and items
@@ -1,12 +1,18 @@
import { type AnyNodeId, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useMemo } from 'react'
import { useShallow } from 'zustand/react/shallow'
import useEditor, { type StructureTool } from '../store/use-editor'
export function useContextualTools() {
const selection = useViewer((s) => s.selection)
const nodes = useScene((s) => s.nodes)
const phase = useEditor((s) => s.phase)
// Only resubscribe when the *types* of selected nodes change, not when any
// node in the scene mutates.
const selectedTypes = useScene(
useShallow((s) =>
selection.selectedIds.map((id) => s.nodes[id as AnyNodeId]?.type).filter(Boolean),
),
)
const structureLayer = useEditor((s) => s.structureLayer)
return useMemo(() => {
@@ -26,35 +32,30 @@ export function useContextualTools() {
'window',
]
if (selection.selectedIds.length === 0) {
if (selectedTypes.length === 0) {
return defaultTools
}
// Get types of selected nodes
const selectedTypes = new Set(
selection.selectedIds.map((id) => nodes[id as AnyNodeId]?.type).filter(Boolean),
)
// If a wall is selected, prioritize wall-hosted elements
if (selectedTypes.has('wall')) {
if (selectedTypes.includes('wall')) {
return ['window', 'door', 'wall', 'fence'] as StructureTool[]
}
// If a slab is selected, prioritize slab editing
if (selectedTypes.has('slab')) {
if (selectedTypes.includes('slab')) {
return ['slab', 'wall'] as StructureTool[]
}
// If a ceiling is selected, prioritize ceiling editing
if (selectedTypes.has('ceiling')) {
if (selectedTypes.includes('ceiling')) {
return ['ceiling'] as StructureTool[]
}
// If a roof is selected, prioritize roof editing
if (selectedTypes.has('roof')) {
if (selectedTypes.includes('roof')) {
return ['roof'] as StructureTool[]
}
return defaultTools
}, [selection.selectedIds, nodes, structureLayer])
}, [selectedTypes, structureLayer])
}
+2 -1
View File
@@ -15,11 +15,13 @@ export {
} from './components/ui/sidebar/panels/settings-panel'
export type { SitePanelProps } from './components/ui/sidebar/panels/site-panel'
export type { SidebarTab } from './components/ui/sidebar/tab-bar'
export { ViewerToolbarLeft, ViewerToolbarRight } from './components/ui/viewer-toolbar'
export type { PresetsAdapter, PresetsTab } from './contexts/presets-context'
export { PresetsProvider } from './contexts/presets-context'
export type { SaveStatus } from './hooks/use-auto-save'
export type { SceneGraph } from './lib/scene'
export { applySceneGraphToEditor } from './lib/scene'
export { triggerSFX } from './lib/sfx-bus'
export { default as useAudio } from './store/use-audio'
export { type CommandAction, useCommandRegistry } from './store/use-command-registry'
export type { FloorplanSelectionTool, SplitOrientation, ViewMode } from './store/use-editor'
@@ -30,4 +32,3 @@ export {
usePaletteViewRegistry,
} from './store/use-palette-view-registry'
export { useUploadStore } from './store/use-upload'
export { ViewerToolbarLeft, ViewerToolbarRight } from './components/ui/viewer-toolbar'
+96 -13
View File
@@ -1,26 +1,90 @@
import { Howl } from 'howler'
import useAudio from '../store/use-audio'
// Per-sound variation config. Playback rate also shifts pitch (one semitone ≈ 1.0595×),
// so a rate range of ~0.881.12 reads as a subtle ±2 semitones, enough to kill the
// machine-gun feeling when the same SFX fires in rapid succession.
type SFXConfig = {
src: string
// Random playback-rate range applied per play (1 = unchanged).
rateRange?: [number, number]
// Random volume multiplier range applied per play (1 = unchanged).
volumeRange?: [number, number]
// Minimum gap between two plays of this SFX. Triggers within this window
// are silently dropped so bursty sequences don't phase-stack into noise.
minIntervalMs?: number
// Random stereo pan per play, max absolute offset (0 = center, 1 = hard
// right). A small value like 0.15 keeps things centered but adds just enough
// spread to stop repeats from stacking on the same point in the field.
panJitter?: number
}
const DEFAULT_MIN_INTERVAL_MS = 30
// SFX sound definitions
export const SFX = {
gridSnap: '/audios/sfx/grid_snap.mp3',
itemDelete: '/audios/sfx/item_delete.mp3',
itemPick: '/audios/sfx/item_pick.mp3',
itemPlace: '/audios/sfx/item_place.mp3',
itemRotate: '/audios/sfx/item_rotate.mp3',
structureBuild: '/audios/sfx/structure_build.mp3',
structureDelete: '/audios/sfx/structure_delete.mp3',
export const SFX: Record<string, SFXConfig> = {
gridSnap: {
src: '/audios/sfx/grid_snap.mp3',
rateRange: [0.94, 1.06],
volumeRange: [0.92, 1.0],
panJitter: 0.15,
},
itemDelete: {
src: '/audios/sfx/item_delete.mp3',
rateRange: [0.9, 1.1],
volumeRange: [0.9, 1.0],
panJitter: 0.15,
},
itemPick: {
src: '/audios/sfx/item_pick.mp3',
rateRange: [0.92, 1.08],
volumeRange: [0.92, 1.0],
panJitter: 0.15,
},
itemPlace: {
src: '/audios/sfx/item_place.mp3',
rateRange: [0.98, 1.06],
volumeRange: [0.9, 1.0],
panJitter: 0.15,
},
itemRotate: {
src: '/audios/sfx/item_rotate.mp3',
rateRange: [0.94, 1.06],
volumeRange: [0.92, 1.0],
panJitter: 0.15,
},
structureBuild: {
src: '/audios/sfx/structure_build.mp3',
rateRange: [0.95, 1.05],
volumeRange: [0.88, 1.0],
panJitter: 0.15,
},
structureDelete: {
src: '/audios/sfx/structure_delete.mp3',
rateRange: [0.9, 1.1],
volumeRange: [0.9, 1.0],
panJitter: 0.15,
},
snapshotCapture: {
// Shutter should sound consistent, no variation.
src: '/audios/sfx/snapshot_capture.mp3',
},
} as const
export type SFXName = keyof typeof SFX
function randomInRange([min, max]: [number, number]): number {
return min + Math.random() * (max - min)
}
// Preload all SFX sounds
const sfxCache = new Map<SFXName, Howl>()
const lastPlayedAt = new Map<SFXName, number>()
// Initialize all sounds
Object.entries(SFX).forEach(([name, path]) => {
Object.entries(SFX).forEach(([name, config]) => {
const sound = new Howl({
src: [path],
src: [config.src],
preload: true,
volume: 0.5, // Will be adjusted by the bus
})
@@ -36,15 +100,34 @@ export function playSFX(name: SFXName) {
console.warn(`SFX not found: ${name}`)
return
}
const config = SFX[name]!
// Drop rapid repeats, two plays of the same SFX within minIntervalMs just
// smear into noise, they don't add useful information.
const now = performance.now()
const minInterval = config.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS
const last = lastPlayedAt.get(name)
if (last !== undefined && now - last < minInterval) return
lastPlayedAt.set(name, now)
const { masterVolume, sfxVolume, muted } = useAudio.getState()
if (muted) return
// Calculate final volume (masterVolume and sfxVolume are 0-100)
const finalVolume = (masterVolume / 100) * (sfxVolume / 100)
sound.volume(finalVolume)
sound.play()
const baseVolume = (masterVolume / 100) * (sfxVolume / 100)
const volumeJitter = config.volumeRange ? randomInRange(config.volumeRange) : 1
const rate = config.rateRange ? randomInRange(config.rateRange) : 1
// Apply per-play variation using the returned sound id so overlapping plays
// don't fight over shared properties on the Howl.
const id = sound.play()
sound.volume(baseVolume * volumeJitter, id)
if (rate !== 1) sound.rate(rate, id)
if (config.panJitter) {
const pan = (Math.random() * 2 - 1) * config.panJitter
sound.stereo(pan, id)
}
}
/**
+2 -2
View File
@@ -26,7 +26,7 @@
"@react-three/drei": "^10",
"@react-three/fiber": "^9",
"react": "^18 || ^19",
"three": "^0.183"
"three": "^0.184"
},
"dependencies": {
"polygon-clipping": "^0.15.7",
@@ -36,7 +36,7 @@
"@pascal/typescript-config": "*",
"@types/node": "^25.5.0",
"@types/react": "^19.2.2",
"@types/three": "^0.183.0",
"@types/three": "^0.184.0",
"typescript": "5.9.3"
},
"keywords": [
@@ -0,0 +1,42 @@
import { useThree } from '@react-three/fiber'
import { useLayoutEffect } from 'react'
type FrameLimiterProps = {
fps?: number
}
const FrameLimiter: React.FC<FrameLimiterProps> = ({ fps = 50 }) => {
const { advance, set, frameloop: initFrameloop } = useThree()
useLayoutEffect(() => {
let elapsed = 0
let then = 0
let i = 0
let raf: number | null = null
const interval = 1000 / fps
function tick(t: DOMHighResTimeStamp) {
raf = requestAnimationFrame(tick)
elapsed = t - then
if (elapsed > interval) {
advance(i)
i += elapsed / 1000 - (elapsed % interval) / 1000
then = t - (elapsed % interval)
}
}
set({ frameloop: 'never' })
raf = requestAnimationFrame(tick)
return () => {
if (raf) {
cancelAnimationFrame(raf)
}
set({ frameloop: initFrameloop })
}
}, [fps, advance, set, initFrameloop])
return null
}
export default FrameLimiter
@@ -23,6 +23,7 @@ import { ScanSystem } from '../../systems/scan/scan-system'
import { WallCutout } from '../../systems/wall/wall-cutout'
import { ZoneSystem } from '../../systems/zone/zone-system'
import { SceneRenderer } from '../renderers/scene-renderer'
import FrameLimiter from './frame-limiter'
import { Lights } from './lights'
import { PerfMonitor } from './perf-monitor'
import PostProcessing from './post-processing'
@@ -69,18 +70,27 @@ extend(THREE as any)
* - Driver crash or GPU reset
* - Browser security policy kills the context
*/
type WebGPUDeviceLossInfo = {
reason?: string
message?: string
}
type WebGPUDeviceLike = {
lost: Promise<WebGPUDeviceLossInfo>
}
function GPUDeviceWatcher() {
const gl = useThree((s) => s.gl)
useEffect(() => {
const backend = (gl as any).backend
const device: GPUDevice | undefined = backend?.device
const device = backend?.device as WebGPUDeviceLike | undefined
if (!device) return
device.lost.then((info) => {
device.lost.then((info: WebGPUDeviceLossInfo) => {
console.error(
`[viewer] WebGPU device lost: reason="${info.reason}", message="${info.message}". ` +
`[viewer] WebGPU device lost: reason="${info.reason ?? 'unknown'}", message="${info.message ?? ''}". ` +
'The page must be reloaded to recover the GPU context.',
)
})
@@ -106,6 +116,7 @@ const Viewer: React.FC<ViewerProps> = ({
camera={{ position: [50, 50, 50], fov: 50 }}
className={`transition-colors duration-700 ${theme === 'dark' ? 'bg-[#1f2433]' : 'bg-[#fafafa]'}`}
dpr={[1, 1.5]}
frameloop="never"
gl={async (props) => {
const renderer = new THREE.WebGPURenderer(props as any)
renderer.toneMapping = THREE.ACESFilmicToneMapping
@@ -127,6 +138,7 @@ const Viewer: React.FC<ViewerProps> = ({
enabled: true,
}}
>
<FrameLimiter fps={50} />
{/* <AnimatedBackground isDark={theme === 'dark'} /> */}
<ViewerCamera />
@@ -140,7 +140,7 @@ const PostProcessingPasses = () => {
// loop fights the direct-render fallback path. Short-circuit here so
// `useFrame` uses the direct `renderer.render(scene, camera)` path
// exclusively and never attempts the TSL pipeline.
const hasWebGPU = typeof navigator !== 'undefined' && typeof navigator.gpu !== 'undefined'
const hasWebGPU = typeof navigator !== 'undefined' && 'gpu' in navigator
if (!hasWebGPU) {
console.warn(
'[viewer] WebGPU unavailable — rendering without post-processing (SSGI, outlines, denoise).',