@pascal-app/editor 0.9.1 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (262) hide show
  1. package/package.json +12 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
  3. package/src/components/editor/bake-exporter.tsx +47 -0
  4. package/src/components/editor/custom-camera-controls.tsx +63 -25
  5. package/src/components/editor/editor-layout-mobile.tsx +5 -0
  6. package/src/components/editor/editor-layout-v2.tsx +18 -0
  7. package/src/components/editor/export-manager.tsx +71 -63
  8. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +36 -3
  10. package/src/components/editor/first-person/build-collider-world.ts +32 -5
  11. package/src/components/editor/first-person-controls.tsx +118 -7
  12. package/src/components/editor/floating-action-menu.tsx +410 -33
  13. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  14. package/src/components/editor/floorplan-background-selection.test.ts +58 -0
  15. package/src/components/editor/floorplan-background-selection.ts +3 -2
  16. package/src/components/editor/floorplan-panel.tsx +1804 -849
  17. package/src/components/editor/grid.tsx +166 -39
  18. package/src/components/editor/group-actions.ts +457 -0
  19. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  20. package/src/components/editor/group-move-3d.ts +397 -0
  21. package/src/components/editor/group-rotate-handle.tsx +102 -64
  22. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  23. package/src/components/editor/group-transform-shared.test.ts +192 -1
  24. package/src/components/editor/group-transform-shared.ts +240 -7
  25. package/src/components/editor/handles/handle-arrow.tsx +81 -23
  26. package/src/components/editor/handles/use-handle-drag.ts +37 -4
  27. package/src/components/editor/index.tsx +248 -27
  28. package/src/components/editor/measurement-pill.tsx +62 -22
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +366 -173
  31. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  32. package/src/components/editor/quick-measurement-card.tsx +70 -0
  33. package/src/components/editor/quick-measurement-hud.tsx +28 -0
  34. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  35. package/src/components/editor/selection-manager.tsx +812 -466
  36. package/src/components/editor/site-edge-labels.tsx +4 -4
  37. package/src/components/editor/slab-hole-highlights.tsx +13 -5
  38. package/src/components/editor/snapshot-capture-overlay.tsx +255 -115
  39. package/src/components/editor/three-context-bridge.ts +22 -0
  40. package/src/components/editor/thumbnail-generator.tsx +136 -43
  41. package/src/components/editor/use-floorplan-background-placement.ts +113 -45
  42. package/src/components/editor/use-floorplan-scene-data.ts +5 -5
  43. package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
  44. package/src/components/editor/wall-measurement-label.tsx +3 -13
  45. package/src/components/editor/wall-move-side-handles.tsx +36 -21
  46. package/src/components/editor/wall-snap-beacon-layer.tsx +160 -6
  47. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
  48. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +1 -1
  49. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  50. package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
  51. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  52. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
  53. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  54. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +169 -4
  55. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +300 -59
  56. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
  57. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
  58. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
  59. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +4 -1
  60. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
  61. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1704 -342
  62. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  63. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +419 -60
  64. package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
  65. package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
  66. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  67. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  68. package/src/components/systems/zone/zone-system.tsx +42 -13
  69. package/src/components/tools/elevator/elevator-tool.tsx +34 -8
  70. package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
  71. package/src/components/tools/fence/fence-drafting.ts +80 -8
  72. package/src/components/tools/item/move-tool.tsx +17 -13
  73. package/src/components/tools/item/placement-math.test.ts +13 -1
  74. package/src/components/tools/item/placement-math.ts +28 -2
  75. package/src/components/tools/item/placement-strategies.ts +246 -5
  76. package/src/components/tools/item/placement-types.ts +13 -1
  77. package/src/components/tools/item/use-draft-node.ts +31 -1
  78. package/src/components/tools/item/use-placement-coordinator.tsx +761 -121
  79. package/src/components/tools/registry/move-registry-node-tool.tsx +561 -99
  80. package/src/components/tools/roof/roof-tool.tsx +320 -94
  81. package/src/components/tools/select/box-select-state.ts +18 -7
  82. package/src/components/tools/select/box-select-tool.tsx +176 -40
  83. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  84. package/src/components/tools/select/marquee-geometry.ts +155 -0
  85. package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
  86. package/src/components/tools/select/select-candidates.ts +1 -1
  87. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  88. package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
  89. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  90. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  91. package/src/components/tools/shared/placement-box.tsx +183 -1
  92. package/src/components/tools/shared/polygon-editor.tsx +276 -35
  93. package/src/components/tools/site/site-boundary-editor.tsx +88 -36
  94. package/src/components/tools/stair/stair-defaults.ts +1 -0
  95. package/src/components/tools/stair/stair-tool.tsx +82 -11
  96. package/src/components/tools/tool-manager.tsx +86 -25
  97. package/src/components/tools/wall/wall-drafting.test.ts +330 -0
  98. package/src/components/tools/wall/wall-drafting.ts +180 -103
  99. package/src/components/tools/wall/wall-snap-geometry.test.ts +44 -0
  100. package/src/components/tools/wall/wall-snap-geometry.ts +67 -6
  101. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  102. package/src/components/tools/zone/zone-tool.tsx +82 -88
  103. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  104. package/src/components/ui/action-menu/control-modes.tsx +40 -93
  105. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  106. package/src/components/ui/action-menu/index.tsx +2 -4
  107. package/src/components/ui/action-menu/measurement-control.tsx +188 -0
  108. package/src/components/ui/action-menu/structure-tools.tsx +22 -13
  109. package/src/components/ui/action-menu/view-toggles.tsx +42 -90
  110. package/src/components/ui/command-palette/editor-commands.tsx +2 -2
  111. package/src/components/ui/command-palette/index.tsx +4 -3
  112. package/src/components/ui/controls/material-paint-panel.tsx +86 -17
  113. package/src/components/ui/controls/material-picker.tsx +83 -152
  114. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  115. package/src/components/ui/controls/metric-control.tsx +118 -44
  116. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  117. package/src/components/ui/controls/slider-control.tsx +88 -38
  118. package/src/components/ui/floating-level-selector.tsx +1 -1
  119. package/src/components/ui/helpers/building-helper.tsx +10 -23
  120. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  121. package/src/components/ui/helpers/helper-manager.tsx +227 -14
  122. package/src/components/ui/helpers/item-helper.tsx +28 -32
  123. package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
  124. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  125. package/src/components/ui/icon-ref.tsx +47 -0
  126. package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
  127. package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
  128. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  129. package/src/components/ui/panels/node-display.ts +17 -17
  130. package/src/components/ui/panels/panel-manager.tsx +22 -13
  131. package/src/components/ui/panels/panel-wrapper.tsx +24 -3
  132. package/src/components/ui/panels/parametric-inspector.tsx +15 -2
  133. package/src/components/ui/panels/reference-panel.tsx +54 -19
  134. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  135. package/src/components/ui/primitives/sidebar.tsx +1 -0
  136. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  137. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  138. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  139. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +71 -40
  140. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +60 -6
  141. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
  142. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
  143. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
  144. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  145. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  146. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
  147. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
  148. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
  149. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
  150. package/src/components/ui/sidebar/panels/site-panel/index.tsx +40 -17
  151. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  152. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
  153. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
  154. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  155. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
  156. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
  157. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
  158. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  159. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  160. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
  161. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  162. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  163. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  164. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  165. package/src/components/ui/sidebar/tab-bar.tsx +42 -28
  166. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  167. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  168. package/src/components/ui/snap-target-badge.tsx +88 -0
  169. package/src/components/viewer-overlay.tsx +85 -10
  170. package/src/components/viewer-zone-system.tsx +6 -1
  171. package/src/hooks/use-auto-save.test.ts +14 -0
  172. package/src/hooks/use-auto-save.ts +54 -7
  173. package/src/hooks/use-ceiling-events.ts +3 -2
  174. package/src/hooks/use-drag-action.ts +4 -1
  175. package/src/hooks/use-keyboard.ts +385 -40
  176. package/src/index.tsx +219 -6
  177. package/src/lib/active-placement-surface.ts +35 -0
  178. package/src/lib/ceiling-plan-snap.ts +24 -0
  179. package/src/lib/contextual-help.test.ts +112 -0
  180. package/src/lib/contextual-help.ts +144 -0
  181. package/src/lib/continuation.ts +64 -0
  182. package/src/lib/direct-manipulation.test.ts +192 -0
  183. package/src/lib/direct-manipulation.ts +109 -0
  184. package/src/lib/editor-api.ts +23 -35
  185. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  186. package/src/lib/floorplan/apply-alignment.ts +18 -12
  187. package/src/lib/floorplan/floorplan-export.tsx +364 -0
  188. package/src/lib/floorplan/geometry.ts +53 -0
  189. package/src/lib/floorplan/index.ts +3 -0
  190. package/src/lib/floorplan/items.ts +5 -2
  191. package/src/lib/floorplan/plan-coords.ts +21 -0
  192. package/src/lib/fresh-planar-placement.test.ts +240 -3
  193. package/src/lib/fresh-planar-placement.ts +68 -1
  194. package/src/lib/glb-export.test.ts +441 -0
  195. package/src/lib/glb-export.ts +874 -0
  196. package/src/lib/history.ts +9 -0
  197. package/src/lib/interaction/hot-set.test.ts +133 -0
  198. package/src/lib/interaction/hot-set.ts +67 -0
  199. package/src/lib/interaction/overlay-policy.test.ts +51 -0
  200. package/src/lib/interaction/overlay-policy.ts +59 -0
  201. package/src/lib/interaction/scope.ts +211 -0
  202. package/src/lib/level-selection.ts +2 -2
  203. package/src/lib/material-paint.ts +36 -49
  204. package/src/lib/measurement-kind.test.ts +30 -0
  205. package/src/lib/measurement-kind.ts +19 -0
  206. package/src/lib/measurement-label.test.ts +47 -0
  207. package/src/lib/measurement-label.ts +68 -0
  208. package/src/lib/measurement-parser.ts +116 -0
  209. package/src/lib/measurements.test.ts +183 -0
  210. package/src/lib/measurements.ts +199 -0
  211. package/src/lib/paint-scope.test.ts +454 -0
  212. package/src/lib/paint-scope.ts +461 -0
  213. package/src/lib/placement-drag-release.ts +23 -0
  214. package/src/lib/planar-cursor-placement.test.ts +57 -0
  215. package/src/lib/plugin-panels.test.ts +19 -0
  216. package/src/lib/plugin-panels.ts +91 -0
  217. package/src/lib/quick-measurement.test.ts +77 -0
  218. package/src/lib/quick-measurement.ts +109 -0
  219. package/src/lib/roof-duplication.ts +6 -6
  220. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  221. package/src/lib/roof-wall-hit.ts +164 -0
  222. package/src/lib/scene-clipboard.test.ts +196 -0
  223. package/src/lib/scene-clipboard.ts +73 -8
  224. package/src/lib/scene.ts +28 -5
  225. package/src/lib/selection-routing.test.ts +298 -0
  226. package/src/lib/selection-routing.ts +175 -0
  227. package/src/lib/sfx/index.ts +1 -0
  228. package/src/lib/sfx/movement-tick.test.ts +65 -0
  229. package/src/lib/sfx/movement-tick.ts +18 -0
  230. package/src/lib/sfx-bus.ts +50 -13
  231. package/src/lib/sfx-player.test.ts +124 -0
  232. package/src/lib/sfx-player.ts +111 -66
  233. package/src/lib/slab-plan-snap.test.ts +93 -0
  234. package/src/lib/slab-plan-snap.ts +108 -0
  235. package/src/lib/snapping-mode.test.ts +138 -0
  236. package/src/lib/snapping-mode.ts +179 -0
  237. package/src/lib/stair-duplication.ts +4 -4
  238. package/src/lib/surface-plan-snap.test.ts +71 -0
  239. package/src/lib/surface-plan-snap.ts +256 -0
  240. package/src/lib/use-linear-display.ts +40 -0
  241. package/src/lib/world-grid-snap.ts +37 -5
  242. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  243. package/src/store/use-editor.tsx +484 -114
  244. package/src/store/use-facing-pose.ts +44 -0
  245. package/src/store/use-fence-curve-draft.ts +21 -0
  246. package/src/store/use-floorplan-draft-preview.ts +101 -0
  247. package/src/store/use-floorplan-marquee.ts +50 -0
  248. package/src/store/use-interaction-scope.test.ts +186 -0
  249. package/src/store/use-interaction-scope.ts +132 -0
  250. package/src/store/use-measurement-draft.test.ts +530 -0
  251. package/src/store/use-measurement-draft.ts +543 -0
  252. package/src/store/use-opening-guides.ts +41 -0
  253. package/src/store/use-placement-preview.ts +11 -3
  254. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  255. package/src/store/use-quick-measurement-hud.ts +77 -0
  256. package/src/store/use-segment-draft-chain.ts +28 -0
  257. package/src/store/use-stair-build-preview.ts +43 -0
  258. package/src/store/use-wall-snap-indicator.ts +2 -0
  259. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  260. package/src/components/editor/group-move-handle.tsx +0 -316
  261. package/src/components/ui/panels/paint-panel.tsx +0 -163
  262. package/src/lib/level-name.ts +0 -11
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pascal-app/editor",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
4
4
  "description": "Pascal building editor component",
5
5
  "type": "module",
6
6
  "exports": {
@@ -8,17 +8,17 @@
8
8
  "./catalog": "./src/components/ui/item-catalog/catalog-items.tsx"
9
9
  },
10
10
  "scripts": {
11
- "check-types": "tsc --noEmit"
11
+ "check-types": "tsgo --noEmit"
12
12
  },
13
13
  "peerDependencies": {
14
- "@pascal-app/core": "^0.9.1",
15
- "@pascal-app/viewer": "^0.9.1",
14
+ "@pascal-app/core": "^0.9.2",
15
+ "@pascal-app/viewer": "^0.9.2",
16
16
  "@react-three/drei": "^10",
17
17
  "@react-three/fiber": "^9",
18
18
  "next": ">=15",
19
19
  "react": "^18 || ^19",
20
20
  "react-dom": "^18 || ^19",
21
- "three": "^0.184"
21
+ "three": "^0.185"
22
22
  },
23
23
  "dependencies": {
24
24
  "@dnd-kit/core": "^6.3.1",
@@ -26,6 +26,7 @@
26
26
  "@dnd-kit/utilities": "^3.2.2",
27
27
  "@iconify/react": "^6.0.2",
28
28
  "@number-flow/react": "^0.6.0",
29
+ "@pascal-app/lingo": "^0.2.0",
29
30
  "@radix-ui/react-alert-dialog": "^1.1.15",
30
31
  "@radix-ui/react-context-menu": "^2.2.16",
31
32
  "@radix-ui/react-dialog": "^1.1.15",
@@ -43,18 +44,20 @@
43
44
  "clsx": "^2.1.1",
44
45
  "cmdk": "^1.1.1",
45
46
  "howler": "^2.2.4",
47
+ "jspdf": "^4.2.1",
46
48
  "lucide-react": "^1.7.0",
47
49
  "mitt": "^3.0.1",
48
50
  "motion": "^12.34.3",
49
51
  "nanoid": "^5.1.6",
52
+ "svg2pdf.js": "^2.7.0",
50
53
  "tailwind-merge": "^3.5.0",
54
+ "three-mesh-bvh": "~0.9.8",
51
55
  "zod": "^4.3.6",
52
- "zustand": "^5.0.11",
53
- "three-mesh-bvh": "~0.9.8"
56
+ "zustand": "^5.0.11"
54
57
  },
55
58
  "devDependencies": {
56
- "@pascal-app/core": "^0.9.1",
57
- "@pascal-app/viewer": "^0.9.1",
59
+ "@pascal-app/core": "^0.9.2",
60
+ "@pascal-app/viewer": "^0.9.2",
58
61
  "@pascal/typescript-config": "*",
59
62
  "@types/bun": "^1.3.0",
60
63
  "@types/howler": "^2.2.12",
@@ -1,7 +1,6 @@
1
1
  'use client'
2
2
 
3
3
  import { type AlignmentGuide, sceneRegistry } from '@pascal-app/core'
4
- import { useAlignmentGuides } from '@pascal-app/editor'
5
4
  import { useViewer } from '@pascal-app/viewer'
6
5
  import { Html } from '@react-three/drei'
7
6
  import { useFrame } from '@react-three/fiber'
@@ -9,6 +8,7 @@ import { memo, useMemo, useRef } from 'react'
9
8
  import { BoxGeometry, CircleGeometry, type Group, Vector3 } from 'three'
10
9
  import { MeshBasicNodeMaterial } from 'three/webgpu'
11
10
  import { EDITOR_LAYER } from '../../lib/constants'
11
+ import useAlignmentGuides from '../../store/use-alignment-guides'
12
12
  import { formatMeasurement } from './measurement-pill'
13
13
 
14
14
  /**
@@ -49,6 +49,14 @@ const guideMaterial = new MeshBasicNodeMaterial({
49
49
  toneMapped: false,
50
50
  transparent: true,
51
51
  })
52
+ const DOT_COLOR = 0x22_c5_5e // green-500 — matches the wall-snap marker
53
+ const dotMaterial = new MeshBasicNodeMaterial({
54
+ color: DOT_COLOR,
55
+ depthTest: false,
56
+ depthWrite: false,
57
+ toneMapped: false,
58
+ transparent: true,
59
+ })
52
60
  const DASH_GEOMETRY = new BoxGeometry(1, 1, 1)
53
61
  const DOT_GEOMETRY = new CircleGeometry(1, 24)
54
62
 
@@ -150,7 +158,7 @@ function Dot({ position }: { position: Vec3 }) {
150
158
  <mesh
151
159
  geometry={DOT_GEOMETRY}
152
160
  layers={EDITOR_LAYER}
153
- material={guideMaterial}
161
+ material={dotMaterial}
154
162
  position={position}
155
163
  renderOrder={1001}
156
164
  rotation={[-Math.PI / 2, 0, 0]}
@@ -0,0 +1,47 @@
1
+ 'use client'
2
+
3
+ import { useScene } from '@pascal-app/core'
4
+ import { useViewer } from '@pascal-app/viewer'
5
+ import { useThree } from '@react-three/fiber'
6
+ import { useEffect, useRef } from 'react'
7
+ import { exportSceneToGlb, nextFrames } from '../../lib/glb-export'
8
+
9
+ export function BakeExporter({
10
+ active,
11
+ onComplete,
12
+ onError,
13
+ }: {
14
+ active: boolean
15
+ onComplete: (buffer: ArrayBuffer) => void
16
+ onError: (message: string) => void
17
+ }) {
18
+ const scene = useThree((s) => s.scene)
19
+ const doneRef = useRef(false)
20
+ useEffect(() => {
21
+ if (!(active && !doneRef.current)) return
22
+ doneRef.current = true
23
+ const run = async () => {
24
+ try {
25
+ // Signal export so instanced kinds (trees/flowers/grass) swap their
26
+ // invisible proxy for real, exportable geometry, then wait for the
27
+ // commit before cloning the scene graph.
28
+ useViewer.getState().setExporting(true)
29
+ await nextFrames()
30
+ const sceneGroup = scene.getObjectByName('scene-renderer')
31
+ if (!sceneGroup) throw new Error('scene-renderer group not found')
32
+ const buffer = await exportSceneToGlb(sceneGroup, useScene.getState().nodes)
33
+ onComplete(buffer)
34
+ } catch (err) {
35
+ // The bake worker relays page console output into the job's error
36
+ // trail; the message alone rarely localises an exporter crash, so
37
+ // surface the full stack here.
38
+ console.error('[bake-exporter]', err instanceof Error ? (err.stack ?? err.message) : err)
39
+ onError(err instanceof Error ? err.message : String(err))
40
+ } finally {
41
+ useViewer.getState().setExporting(false)
42
+ }
43
+ }
44
+ void run()
45
+ }, [active, scene, onComplete, onError])
46
+ return null
47
+ }
@@ -22,6 +22,11 @@ import {
22
22
  } from 'three'
23
23
  import { EDITOR_LAYER } from '../../lib/constants'
24
24
  import useEditor from '../../store/use-editor'
25
+ import {
26
+ useActiveHandleDrag,
27
+ useEndpointReshape,
28
+ useMovingNode,
29
+ } from '../../store/use-interaction-scope'
25
30
 
26
31
  const currentTarget = new Vector3()
27
32
  const tempBox = new Box3()
@@ -364,10 +369,15 @@ export const CustomCameraControls = () => {
364
369
  const lastPublishedNavigationSync = useRef<NavigationCameraPoseSnapshot | null>(null)
365
370
  const pendingFloorplanNavigationPose = useRef<PendingNavigationCameraPoseSnapshot | null>(null)
366
371
  const lastApplied2dNavigationRevision = useRef(0)
372
+ const savedSmoothTimeRef = useRef<number | null>(null)
367
373
  const maxPolarAngle =
368
374
  !isPreviewMode && allowUndergroundCamera ? DEBUG_MAX_POLAR_ANGLE : DEFAULT_MAX_POLAR_ANGLE
369
375
  const clearPendingFloorplanNavigationPose = useCallback(() => {
370
376
  pendingFloorplanNavigationPose.current = null
377
+ if (savedSmoothTimeRef.current !== null && controls.current) {
378
+ controls.current.smoothTime = savedSmoothTimeRef.current
379
+ savedSmoothTimeRef.current = null
380
+ }
371
381
  }, [])
372
382
 
373
383
  const camera = useThree((state) => state.camera)
@@ -381,6 +391,20 @@ export const CustomCameraControls = () => {
381
391
  raycaster.layers.enable(ZONE_LAYER)
382
392
  }, [camera, raycaster])
383
393
 
394
+ useEffect(() => {
395
+ // Dev-only: deterministic camera poses for screenshot/automation tooling.
396
+ // A getter, not a snapshot — drei recreates the impl when the default
397
+ // camera changes, so a captured instance goes stale.
398
+ if (process.env.NODE_ENV !== 'development') return
399
+ const w = window as typeof window & {
400
+ __pascalCameraControls?: (() => CameraControlsImpl | null) | null
401
+ }
402
+ w.__pascalCameraControls = () => controls.current
403
+ return () => {
404
+ w.__pascalCameraControls = null
405
+ }
406
+ }, [])
407
+
384
408
  useEffect(() => {
385
409
  if (isPreviewMode || isFirstPersonMode || isRestoringFirstPersonPose()) return
386
410
  let targetY = 0
@@ -452,12 +476,7 @@ export const CustomCameraControls = () => {
452
476
 
453
477
  return useEditor.subscribe((state) => {
454
478
  const pose = state.navigationSyncPose
455
- if (
456
- !pose ||
457
- pose.source !== '2d' ||
458
- pose.revision === lastApplied2dNavigationRevision.current
459
- )
460
- return
479
+ if (pose?.source !== '2d' || pose.revision === lastApplied2dNavigationRevision.current) return
461
480
 
462
481
  const control = controls.current
463
482
  if (!control) return
@@ -478,6 +497,13 @@ export const CustomCameraControls = () => {
478
497
  Math.abs(viewWidthUpdate.viewWidth - pose.viewWidth) >=
479
498
  NAVIGATION_SYNC_VIEW_WIDTH_EPSILON,
480
499
  }
500
+ // Match 3D settle time to 2D exponential decay (τ=90ms). SmoothDamp's
501
+ // effective time constant is smoothTime/2, so smoothTime=0.18 gives
502
+ // τ≈90ms and visual convergence in ~350-400ms, matching the 2D panel.
503
+ if (savedSmoothTimeRef.current === null) {
504
+ savedSmoothTimeRef.current = control.smoothTime
505
+ }
506
+ control.smoothTime = 0.18
481
507
  control.moveTo(pose.target[0], pose.target[1], pose.target[2], true)
482
508
  control.rotateTo(targetAzimuth, control.polarAngle, true)
483
509
  applyCameraViewWidth(control, viewWidthUpdate)
@@ -499,7 +525,7 @@ export const CustomCameraControls = () => {
499
525
  isCameraAtNavigationPose(pendingFloorplanPose, syncTarget, syncSpherical.theta, viewWidth)
500
526
  ) {
501
527
  lastPublishedNavigationSync.current = pendingFloorplanPose
502
- pendingFloorplanNavigationPose.current = null
528
+ clearPendingFloorplanNavigationPose()
503
529
  if (pendingFloorplanPose.publishOnComplete) {
504
530
  useEditor.getState().publishNavigationSyncPose({
505
531
  source: '3d',
@@ -540,7 +566,7 @@ export const CustomCameraControls = () => {
540
566
  azimuth: syncSpherical.theta,
541
567
  viewWidth,
542
568
  })
543
- }, [camera, isFirstPersonMode, viewportSize])
569
+ }, [camera, clearPendingFloorplanNavigationPose, isFirstPersonMode, viewportSize])
544
570
 
545
571
  useEffect(() => {
546
572
  if (isFirstPersonMode || (!isFloorplanOpen && currentLevelId === null)) return
@@ -573,7 +599,7 @@ export const CustomCameraControls = () => {
573
599
  )
574
600
  const step = (speed * Math.min(delta, 0.05)) / Math.hypot(horizontal, vertical)
575
601
 
576
- pendingFloorplanNavigationPose.current = null
602
+ clearPendingFloorplanNavigationPose()
577
603
  if (horizontal !== 0) control.truck(horizontal * step, 0, true)
578
604
  if (vertical !== 0) control.forward(vertical * step, true)
579
605
  })
@@ -611,18 +637,12 @@ export const CustomCameraControls = () => {
611
637
  const tool = useEditor((s) => s.tool)
612
638
  const mode = useEditor((s) => s.mode)
613
639
  const selectionTool = useEditor((s) => s.floorplanSelectionTool)
614
- const movingNode = useEditor((s) => s.movingNode)
615
- const movingWallEndpoint = useEditor((s) => s.movingWallEndpoint)
616
- const movingFenceEndpoint = useEditor((s) => s.movingFenceEndpoint)
617
- const activeHandleDrag = useEditor((s) => s.activeHandleDrag)
640
+ const movingNode = useMovingNode()
641
+ const endpointReshape = useEndpointReshape()
642
+ const activeHandleDrag = useActiveHandleDrag()
618
643
  const isBoxSelectActive = mode === 'select' && selectionTool === 'marquee'
619
644
  const isInteracting = Boolean(
620
- tool ||
621
- movingNode ||
622
- movingWallEndpoint ||
623
- movingFenceEndpoint ||
624
- activeHandleDrag ||
625
- isBoxSelectActive,
645
+ tool || movingNode || endpointReshape || activeHandleDrag || isBoxSelectActive,
626
646
  )
627
647
  const touches = useMemo(() => {
628
648
  const twoFingerAction =
@@ -731,7 +751,7 @@ export const CustomCameraControls = () => {
731
751
  !isEditableKeyboardTarget(event.target)
732
752
  ) {
733
753
  setKeyboardPanKey(keyboardPanKeys.current, event.code, true)
734
- pendingFloorplanNavigationPose.current = null
754
+ clearPendingFloorplanNavigationPose()
735
755
  event.preventDefault()
736
756
  event.stopPropagation()
737
757
  }
@@ -794,7 +814,7 @@ export const CustomCameraControls = () => {
794
814
 
795
815
  const onPointerDown = (event: PointerEvent) => {
796
816
  if (!(event.target instanceof Node) || !gl.domElement.contains(event.target)) return
797
- pendingFloorplanNavigationPose.current = null
817
+ clearPendingFloorplanNavigationPose()
798
818
  if (event.button !== 1 && !(event.button === 0 && keyState.space)) return
799
819
 
800
820
  panPointerId = event.pointerId
@@ -803,7 +823,7 @@ export const CustomCameraControls = () => {
803
823
  }
804
824
 
805
825
  const onWheel = () => {
806
- pendingFloorplanNavigationPose.current = null
826
+ clearPendingFloorplanNavigationPose()
807
827
  }
808
828
 
809
829
  const onPointerUp = (event: PointerEvent) => {
@@ -845,7 +865,25 @@ export const CustomCameraControls = () => {
845
865
  clearKeyboardPanKeys()
846
866
  clearNavigationCursor()
847
867
  }
848
- }, [cameraMode, gl, isPreviewMode, isFirstPersonMode])
868
+ }, [cameraMode, gl, isPreviewMode, isFirstPersonMode, clearPendingFloorplanNavigationPose])
869
+
870
+ // Cancel any in-progress 2D-origin navigation pose when the user starts
871
+ // dragging (right-click orbit, middle-click pan, touch). `controlstart`
872
+ // fires only for user pointer interactions — not for programmatic
873
+ // moveTo/rotateTo which emit `transitionstart` instead.
874
+ useEffect(() => {
875
+ if (isFirstPersonMode) return
876
+ const control = controls.current
877
+ if (!control) return
878
+
879
+ const onControlStart = () => {
880
+ clearPendingFloorplanNavigationPose()
881
+ }
882
+ control.addEventListener('controlstart', onControlStart)
883
+ return () => {
884
+ control.removeEventListener('controlstart', onControlStart)
885
+ }
886
+ }, [isFirstPersonMode, clearPendingFloorplanNavigationPose])
849
887
 
850
888
  // Preview mode: auto-navigate camera to selected node (viewer behavior)
851
889
  const previewTargetNodeId = isPreviewMode
@@ -1154,12 +1192,12 @@ export const CustomCameraControls = () => {
1154
1192
  }, [])
1155
1193
 
1156
1194
  // Preset capture mode frames a single subtree (often a 0.3–2m preset),
1157
- // so the default 6m minDistance prevents the user from getting close
1195
+ // so the default 2m minDistance prevents the user from getting close
1158
1196
  // enough to compose a good thumbnail. Relax the clamp to 0.5m while
1159
1197
  // capturing presets; reset on exit so general editing keeps the looser
1160
1198
  // navigation guardrails.
1161
1199
  const isPresetCapture = captureMode.mode === 'preset'
1162
- const minDistance = isPresetCapture ? 0.5 : 6
1200
+ const minDistance = isPresetCapture ? 0.5 : 2
1163
1201
 
1164
1202
  if (isFirstPersonMode) {
1165
1203
  return null
@@ -159,6 +159,11 @@ export function EditorLayoutMobile({
159
159
  const expandedThreshold = Math.max(SHEET_HANDLE_PX, defaultPx * 0.5)
160
160
  if (current > expandedThreshold) {
161
161
  sheetRef.current?.snapTo(SHEET_HANDLE_PX)
162
+ // Closing the sheet disarms any build tool back to select
163
+ const { mode, setMode } = useEditor.getState()
164
+ if (mode === 'build') {
165
+ setMode('select')
166
+ }
162
167
  } else {
163
168
  sheetRef.current?.snapTo(defaultPx)
164
169
  }
@@ -52,6 +52,15 @@ function LeftColumn({
52
52
  }
53
53
  }, [activePanel])
54
54
 
55
+ // Closing (collapsing) the sidebar disarms any build tool back to select
56
+ useEffect(() => {
57
+ if (!isCollapsed) return
58
+ const { mode, setMode } = useEditor.getState()
59
+ if (mode === 'build') {
60
+ setMode('select')
61
+ }
62
+ }, [isCollapsed])
63
+
55
64
  const handleResizerDown = useCallback(
56
65
  (e: React.PointerEvent) => {
57
66
  e.preventDefault()
@@ -150,11 +159,13 @@ function RightColumn({
150
159
  toolbarRight,
151
160
  children,
152
161
  overlays,
162
+ stageOverlay,
153
163
  }: {
154
164
  toolbarLeft?: ReactNode
155
165
  toolbarRight?: ReactNode
156
166
  children: ReactNode
157
167
  overlays?: ReactNode
168
+ stageOverlay?: ReactNode
158
169
  }) {
159
170
  return (
160
171
  <div
@@ -174,6 +185,10 @@ function RightColumn({
174
185
  )}
175
186
  {/* Canvas area */}
176
187
  <div className="relative flex-1 overflow-hidden">{children}</div>
188
+ {/* Stage overlay — replaces the canvas visually (e.g. studio gallery)
189
+ while keeping it mounted. Sits below the viewer toolbar (z-20) so
190
+ the stage switch stays reachable. */}
191
+ {stageOverlay && <div className="absolute inset-0 z-10">{stageOverlay}</div>}
177
192
  {/* Overlays scoped to the viewer column. `data-viewer-bounds` marks the
178
193
  draggable region the floating inspector clamps itself to. */}
179
194
  {overlays && (
@@ -200,6 +215,7 @@ export interface EditorLayoutV2Props {
200
215
  viewerToolbarRight?: ReactNode
201
216
  viewerContent: ReactNode
202
217
  overlays?: ReactNode
218
+ stageOverlay?: ReactNode
203
219
  }
204
220
 
205
221
  export function EditorLayoutV2({
@@ -211,6 +227,7 @@ export function EditorLayoutV2({
211
227
  viewerToolbarRight,
212
228
  viewerContent,
213
229
  overlays,
230
+ stageOverlay,
214
231
  }: EditorLayoutV2Props) {
215
232
  const isCaptureMode = useEditor((s) => s.isCaptureMode)
216
233
  const isMobile = useIsMobile()
@@ -246,6 +263,7 @@ export function EditorLayoutV2({
246
263
  )}
247
264
  <RightColumn
248
265
  overlays={overlays}
266
+ stageOverlay={stageOverlay}
249
267
  toolbarLeft={isCaptureMode ? undefined : viewerToolbarLeft}
250
268
  toolbarRight={isCaptureMode ? undefined : viewerToolbarRight}
251
269
  >
@@ -1,12 +1,35 @@
1
1
  'use client'
2
2
 
3
+ import { emitter, useScene } from '@pascal-app/core'
3
4
  import { useViewer } from '@pascal-app/viewer'
4
5
  import { useThree } from '@react-three/fiber'
5
6
  import { useEffect } from 'react'
6
- import type { Mesh, Object3D } from 'three'
7
- import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js'
7
+ import * as THREE from 'three'
8
8
  import { OBJExporter } from 'three/examples/jsm/exporters/OBJExporter.js'
9
9
  import { STLExporter } from 'three/examples/jsm/exporters/STLExporter.js'
10
+ import { exportSceneToGlb, nextFrames, prepareSceneForExport } from '../../lib/glb-export'
11
+
12
+ // prepareSceneForExport neutralises container meshes (door/window hitbox roots,
13
+ // material-less renderables) with an attribute-less geometry — GLTFExporter
14
+ // emits those as plain transform nodes, but STL/OBJExporter read
15
+ // `position.count` unconditionally and crash. Swap in a geometry with an empty
16
+ // (count-0) position so they iterate zero vertices instead. Shared: the export
17
+ // scene is a throwaway clone, only its geometry *ref* is swapped.
18
+ const EMPTY_POSITION_GEOMETRY = new THREE.BufferGeometry()
19
+ EMPTY_POSITION_GEOMETRY.setAttribute(
20
+ 'position',
21
+ new THREE.Float32BufferAttribute(new Float32Array(0), 3),
22
+ )
23
+
24
+ function ensurePositionAttributes(root: THREE.Object3D) {
25
+ root.traverse((object) => {
26
+ const renderable = object as THREE.Mesh & { isLine?: boolean; isPoints?: boolean }
27
+ if (!(renderable.isMesh || renderable.isLine || renderable.isPoints)) return
28
+ if (!renderable.geometry?.getAttribute('position')) {
29
+ renderable.geometry = EMPTY_POSITION_GEOMETRY
30
+ }
31
+ })
32
+ }
10
33
 
11
34
  export function ExportManager() {
12
35
  const scene = useThree((state) => state.scene)
@@ -22,42 +45,54 @@ export function ExportManager() {
22
45
  }
23
46
 
24
47
  const date = new Date().toISOString().split('T')[0]
25
- const exportScene = prepareSceneForExport(sceneGroup)
26
-
27
- if (format === 'stl') {
28
- const exporter = new STLExporter()
29
- const result = exporter.parse(exportScene, { binary: true })
30
- const blob = new Blob([result], { type: 'model/stl' })
31
- downloadBlob(blob, `model_${date}.stl`)
32
- return
33
- }
34
48
 
35
- if (format === 'obj') {
36
- const exporter = new OBJExporter()
37
- const result = exporter.parse(exportScene)
38
- const blob = new Blob([result], { type: 'model/obj' })
39
- downloadBlob(blob, `model_${date}.obj`)
40
- return
49
+ // Signal export so instanced kinds (trees/flowers/grass) swap their
50
+ // invisible proxy for real, exportable geometry, then wait for the
51
+ // commit before cloning the scene graph (same dance as BakeExporter —
52
+ // without it every plant exports as its raycast collider, a white box).
53
+ useViewer.getState().setExporting(true)
54
+ try {
55
+ await nextFrames()
56
+
57
+ if (format === 'glb') {
58
+ const buffer = await exportSceneToGlb(sceneGroup, useScene.getState().nodes)
59
+ const blob = new Blob([buffer], { type: 'model/gltf-binary' })
60
+ downloadBlob(blob, `model_${date}.glb`)
61
+ return
62
+ }
63
+
64
+ // Hide editor affordances that live on the scene layer (selection handles,
65
+ // ceiling/site brackets) and let wall-cutout reveal all walls — the same
66
+ // synchronous capture path thumbnails use. We clone the scene inside the
67
+ // window, so the export snapshots the clean building, then restore.
68
+ emitter.emit('thumbnail:before-capture', undefined)
69
+ let prepared: ReturnType<typeof prepareSceneForExport>
70
+ try {
71
+ prepared = prepareSceneForExport(sceneGroup, useScene.getState().nodes)
72
+ } finally {
73
+ emitter.emit('thumbnail:after-capture', undefined)
74
+ }
75
+ const { scene: exportScene } = prepared
76
+ ensurePositionAttributes(exportScene)
77
+
78
+ if (format === 'stl') {
79
+ const exporter = new STLExporter()
80
+ const result = exporter.parse(exportScene, { binary: true })
81
+ const blob = new Blob([result], { type: 'model/stl' })
82
+ downloadBlob(blob, `model_${date}.stl`)
83
+ return
84
+ }
85
+
86
+ if (format === 'obj') {
87
+ const exporter = new OBJExporter()
88
+ const result = exporter.parse(exportScene)
89
+ const blob = new Blob([result], { type: 'model/obj' })
90
+ downloadBlob(blob, `model_${date}.obj`)
91
+ return
92
+ }
93
+ } finally {
94
+ useViewer.getState().setExporting(false)
41
95
  }
42
-
43
- // Default: GLB export (existing behavior)
44
- const exporter = new GLTFExporter()
45
-
46
- return new Promise<void>((resolve, reject) => {
47
- exporter.parse(
48
- exportScene,
49
- (gltf) => {
50
- const blob = new Blob([gltf as ArrayBuffer], { type: 'model/gltf-binary' })
51
- downloadBlob(blob, `model_${date}.glb`)
52
- resolve()
53
- },
54
- (error) => {
55
- console.error('Export error:', error)
56
- reject(error)
57
- },
58
- { binary: true },
59
- )
60
- })
61
96
  }
62
97
 
63
98
  setExportScene(exportFn)
@@ -70,33 +105,6 @@ export function ExportManager() {
70
105
  return null
71
106
  }
72
107
 
73
- function prepareSceneForExport(source: Object3D) {
74
- const clone = source.clone(true)
75
- const meshesToRemove: Mesh[] = []
76
-
77
- clone.traverse((object) => {
78
- if (isMeshWithInvalidGeometry(object)) meshesToRemove.push(object)
79
- })
80
-
81
- for (const mesh of meshesToRemove) {
82
- mesh.removeFromParent()
83
- }
84
-
85
- return clone
86
- }
87
-
88
- function isMeshWithInvalidGeometry(object: Object3D): object is Mesh {
89
- if (!isMesh(object)) return false
90
-
91
- // Three exporters can crash when a Mesh has no readable position attribute.
92
- const position = object.geometry?.getAttribute('position')
93
- return !position || position.count === 0
94
- }
95
-
96
- function isMesh(object: Object3D): object is Mesh {
97
- return (object as Mesh).isMesh === true
98
- }
99
-
100
108
  function downloadBlob(blob: Blob, filename: string) {
101
109
  const url = URL.createObjectURL(blob)
102
110
  const link = document.createElement('a')
@@ -0,0 +1,87 @@
1
+ 'use client'
2
+
3
+ import {
4
+ type AnyNodeId,
5
+ type FenceNode,
6
+ getFenceControlHandle,
7
+ isSplineFence,
8
+ sceneRegistry,
9
+ useLiveNodeOverrides,
10
+ useScene,
11
+ } from '@pascal-app/core'
12
+ import { useViewer } from '@pascal-app/viewer'
13
+ import { createPortal, useFrame } from '@react-three/fiber'
14
+ import { useMemo, useState } from 'react'
15
+ import { BufferGeometry, type Object3D, Vector3 } from 'three'
16
+ import { EDITOR_LAYER } from '../../lib/constants'
17
+
18
+ /**
19
+ * Straight connecting line through each spline-fence control point joining its
20
+ * two tangent handle ends (the classic pen-tool look). The handle *dots* are
21
+ * registry tap-handles (see `fence/definition.ts`); this overlay only draws
22
+ * the line between them, in both views' 3D scene.
23
+ *
24
+ * Must match the handle placement's arm scale so the line ends land exactly on
25
+ * the dots. Portals into the fence's own Object3D so it inherits the same world
26
+ * transform as the geometry (path coords are node-local plan meters), and reads
27
+ * the live override so the line tracks an in-flight tangent / point drag.
28
+ */
29
+
30
+ // Keep in sync with TANGENT_HANDLE_ARM_SCALE in fence/definition.ts.
31
+ const TANGENT_HANDLE_ARM_SCALE = 3
32
+ const LINE_LIFT_Y = 0.02
33
+
34
+ export function FenceTangentLines3D() {
35
+ const selectedIds = useViewer((s) => s.selection.selectedIds)
36
+ const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
37
+
38
+ const rawNode = useScene((s) => (selectedId ? s.nodes[selectedId as AnyNodeId] : null))
39
+ const liveOverride = useLiveNodeOverrides((s) =>
40
+ selectedId ? s.overrides.get(selectedId as AnyNodeId) : undefined,
41
+ )
42
+ const fence = useMemo<FenceNode | null>(() => {
43
+ if (rawNode?.type !== 'fence') return null
44
+ const merged = liveOverride ? ({ ...rawNode, ...liveOverride } as FenceNode) : rawNode
45
+ return isSplineFence(merged) ? merged : null
46
+ }, [rawNode, liveOverride])
47
+
48
+ const [object, setObject] = useState<{ id: AnyNodeId; object: Object3D } | null>(null)
49
+ const selectedObject = selectedId && object?.id === selectedId ? object.object : null
50
+
51
+ useFrame(() => {
52
+ if (!selectedId || selectedObject) return
53
+ const next = sceneRegistry.nodes.get(selectedId)
54
+ if (next) setObject({ id: selectedId as AnyNodeId, object: next })
55
+ })
56
+
57
+ const geometry = useMemo(() => {
58
+ if (!fence?.path) return null
59
+ const positions: Vector3[] = []
60
+ for (let i = 0; i < fence.path.length; i += 1) {
61
+ const point = fence.path[i]!
62
+ const handle = getFenceControlHandle(fence.path, fence.tangents, i)
63
+ const ax = handle.x * TANGENT_HANDLE_ARM_SCALE
64
+ const az = handle.y * TANGENT_HANDLE_ARM_SCALE
65
+ // One disjoint segment per point — `lineSegments` pairs vertices, so the
66
+ // in/out ends connect through the point without joining across points.
67
+ positions.push(new Vector3(point[0] - ax, LINE_LIFT_Y, point[1] - az))
68
+ positions.push(new Vector3(point[0] + ax, LINE_LIFT_Y, point[1] + az))
69
+ }
70
+ return new BufferGeometry().setFromPoints(positions)
71
+ }, [fence])
72
+
73
+ if (!(fence && selectedObject && geometry)) return null
74
+
75
+ return createPortal(
76
+ <lineSegments frustumCulled={false} geometry={geometry} layers={EDITOR_LAYER} renderOrder={3}>
77
+ <lineBasicNodeMaterial
78
+ color="#8381ed"
79
+ depthTest={false}
80
+ depthWrite={false}
81
+ opacity={0.85}
82
+ transparent
83
+ />
84
+ </lineSegments>,
85
+ selectedObject,
86
+ )
87
+ }