@pascal-app/editor 1.0.0 → 1.0.3

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 (177) hide show
  1. package/package.json +12 -6
  2. package/src/components/editor/bake-exporter.tsx +4 -0
  3. package/src/components/editor/bake-thumbnail.tsx +4 -1
  4. package/src/components/editor/custom-camera-controls.tsx +22 -74
  5. package/src/components/editor/editor-layout-mobile.tsx +1 -0
  6. package/src/components/editor/floating-action-menu.tsx +14 -1
  7. package/src/components/editor/floorplan-panel.tsx +233 -166
  8. package/src/components/editor/grid.tsx +31 -7
  9. package/src/components/editor/group-actions.ts +5 -9
  10. package/src/components/editor/group-floating-action-menu.tsx +19 -9
  11. package/src/components/editor/group-move-3d.ts +4 -6
  12. package/src/components/editor/group-rotate-handle.tsx +81 -35
  13. package/src/components/editor/group-selection-box-3d.tsx +25 -17
  14. package/src/components/editor/group-transform-shared.test.ts +126 -1
  15. package/src/components/editor/group-transform-shared.ts +79 -73
  16. package/src/components/editor/handles/handle-arrow.tsx +72 -2
  17. package/src/components/editor/handles/handle-placement.test.ts +111 -0
  18. package/src/components/editor/handles/handle-placement.ts +69 -0
  19. package/src/components/editor/handles/linear-resize-drag.ts +13 -2
  20. package/src/components/editor/handles/use-handle-drag.ts +80 -8
  21. package/src/components/editor/index.tsx +36 -13
  22. package/src/components/editor/node-action-menu.tsx +2 -0
  23. package/src/components/editor/node-arrow-handles.tsx +162 -69
  24. package/src/components/editor/registry-action-contributions.tsx +53 -0
  25. package/src/components/editor/selection-manager.tsx +49 -0
  26. package/src/components/editor/thumbnail-generator.tsx +11 -2
  27. package/src/components/editor/wall-move-side-handles.tsx +71 -21
  28. package/src/components/editor/wall-opening-highlights.tsx +2 -3
  29. package/src/components/editor-2d/floorplan-group-move.tsx +17 -24
  30. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +33 -12
  31. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
  32. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +154 -49
  33. package/src/components/editor-2d/renderers/floorplan-draft-wall-measurement.tsx +166 -0
  34. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +49 -0
  35. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +113 -24
  36. package/src/components/procedural-items/inspector.tsx +223 -0
  37. package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
  38. package/src/components/systems/zone/zone-system.tsx +8 -4
  39. package/src/components/tools/__fixtures__/hosting.ts +10 -0
  40. package/src/components/tools/item/item-surface-placement.test.ts +169 -0
  41. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +204 -0
  42. package/src/components/tools/item/placement-strategies.ts +212 -200
  43. package/src/components/tools/item/placement-types.ts +4 -0
  44. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
  45. package/src/components/tools/item/use-draft-node.ts +136 -47
  46. package/src/components/tools/item/use-placement-coordinator.tsx +391 -157
  47. package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
  48. package/src/components/tools/registry/item-surface-move.test.ts +509 -0
  49. package/src/components/tools/registry/item-surface-move.ts +463 -0
  50. package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
  51. package/src/components/tools/registry/move-registry-node-tool.tsx +237 -78
  52. package/src/components/tools/shared/affordance-dispatch.test.ts +24 -0
  53. package/src/components/tools/shared/affordance-dispatch.ts +13 -0
  54. package/src/components/tools/shared/draft-measurement-label.tsx +38 -0
  55. package/src/components/tools/shared/pointer-support-cap.test.ts +27 -0
  56. package/src/components/tools/shared/pointer-support-cap.ts +27 -13
  57. package/src/components/tools/shared/shelf-stickiness.ts +54 -0
  58. package/src/components/tools/shared/surface-hit.test.ts +56 -0
  59. package/src/components/tools/shared/surface-hit.ts +23 -0
  60. package/src/components/tools/shared/surface-rejection.test.tsx +107 -0
  61. package/src/components/tools/shared/surface-rejection.tsx +43 -0
  62. package/src/components/tools/tool-manager.tsx +36 -3
  63. package/src/components/tools/zone/zone-tool.tsx +13 -4
  64. package/src/components/ui/command-palette/editor-commands.tsx +54 -1
  65. package/src/components/ui/controls/material-paint-panel.tsx +6 -67
  66. package/src/components/ui/controls/material-picker.tsx +5 -67
  67. package/src/components/ui/controls/metric-control.tsx +29 -13
  68. package/src/components/ui/controls/slider-control.tsx +87 -35
  69. package/src/components/ui/floating-level-selector.tsx +81 -5
  70. package/src/components/ui/helpers/helper-manager.tsx +24 -6
  71. package/src/components/ui/helpers/registered-tool-helper.tsx +2 -32
  72. package/src/components/ui/item-catalog/item-catalog.tsx +5 -30
  73. package/src/components/ui/panels/multi-height-mode.tsx +27 -21
  74. package/src/components/ui/panels/multi-parametric-inspector.tsx +6 -3
  75. package/src/components/ui/panels/parametric-field-control.tsx +1 -1
  76. package/src/components/ui/panels/parametric-inspector.tsx +9 -17
  77. package/src/components/ui/panels/reference-panel.tsx +3 -3
  78. package/src/components/ui/primitives/checkbox.tsx +34 -0
  79. package/src/components/ui/primitives/shortcut-token.tsx +4 -0
  80. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
  81. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +669 -336
  82. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  83. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +31 -4
  84. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +30 -11
  85. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
  86. package/src/components/ui/sidebar/panels/site-panel/index.tsx +106 -4
  87. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
  88. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
  89. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +6 -1
  90. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
  91. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
  92. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
  93. package/src/components/ui/snap-target-badge.tsx +2 -0
  94. package/src/components/viewer/floorplan-preview-visibility.test.ts +35 -0
  95. package/src/components/viewer/floorplan-preview-visibility.ts +19 -0
  96. package/src/components/viewer/floorplan-preview.tsx +1 -12
  97. package/src/components/viewer/viewer-scene-header.tsx +3 -0
  98. package/src/components/viewer/viewer-units-panel.tsx +116 -0
  99. package/src/components/viewer-zone-system.tsx +7 -2
  100. package/src/hooks/use-auto-save.integration.test.tsx +197 -0
  101. package/src/hooks/use-auto-save.ts +21 -6
  102. package/src/hooks/use-ceiling-events.test.ts +25 -2
  103. package/src/hooks/use-ceiling-events.ts +7 -1
  104. package/src/hooks/use-grid-events.test.ts +2 -0
  105. package/src/hooks/use-grid-events.ts +16 -11
  106. package/src/hooks/use-keyboard.test.ts +7 -0
  107. package/src/hooks/use-keyboard.ts +43 -28
  108. package/src/index.tsx +68 -3
  109. package/src/lib/catalog-panel-model.ts +39 -0
  110. package/src/lib/catalog-selection.test.ts +27 -0
  111. package/src/lib/catalog-selection.ts +8 -0
  112. package/src/lib/contextual-help.test.ts +27 -1
  113. package/src/lib/contextual-help.ts +16 -9
  114. package/src/lib/export-texture-utils.test.ts +167 -0
  115. package/src/lib/export-texture-utils.ts +121 -0
  116. package/src/lib/export-user-data.test.ts +48 -0
  117. package/src/lib/export-user-data.ts +69 -0
  118. package/src/lib/floorplan/floorplan-export.test.ts +142 -2
  119. package/src/lib/floorplan/floorplan-export.tsx +9 -3
  120. package/src/lib/floorplan/floorplan-extension.ts +16 -0
  121. package/src/lib/floorplan/floorplan-readonly.ts +4 -0
  122. package/src/lib/fresh-planar-placement.ts +173 -33
  123. package/src/lib/glb-export-serialize.test.ts +280 -0
  124. package/src/lib/glb-export.test.ts +265 -16
  125. package/src/lib/glb-export.ts +197 -37
  126. package/src/lib/grid-frame.test.ts +19 -0
  127. package/src/lib/grid-frame.ts +18 -0
  128. package/src/lib/interaction/scope.ts +9 -1
  129. package/src/lib/keyboard-pan.test.ts +62 -0
  130. package/src/lib/keyboard-pan.ts +89 -0
  131. package/src/lib/level-duplication.test.ts +73 -0
  132. package/src/lib/level-duplication.ts +32 -2
  133. package/src/lib/linear-display.test.ts +59 -0
  134. package/src/lib/linear-display.ts +39 -0
  135. package/src/lib/local-guide-image.test.ts +24 -0
  136. package/src/lib/local-guide-image.ts +34 -1
  137. package/src/lib/material-catalog-model.ts +72 -0
  138. package/src/lib/material-paint-panel-model.ts +76 -0
  139. package/src/lib/node-action-movement.ts +21 -0
  140. package/src/lib/overlay-transform.test.ts +28 -0
  141. package/src/lib/overlay-transform.ts +12 -0
  142. package/src/lib/panel-rows.ts +75 -0
  143. package/src/lib/panel-tool-options.test.ts +34 -0
  144. package/src/lib/panel-tool-options.ts +46 -0
  145. package/src/lib/parametric-node-update.ts +37 -0
  146. package/src/lib/portable-export.test.ts +89 -0
  147. package/src/lib/portable-export.ts +84 -11
  148. package/src/lib/print-shell-compiler-manifold-core.ts +59 -10
  149. package/src/lib/print-shell-compiler-manifold-worker.ts +14 -1
  150. package/src/lib/print-shell-compiler-manifold.worker.ts +1 -1
  151. package/src/lib/print-shell-compiler-protocol.ts +8 -0
  152. package/src/lib/scene-clipboard.ts +1 -1
  153. package/src/lib/scene.test.ts +99 -0
  154. package/src/lib/scene.ts +21 -3
  155. package/src/lib/send-to-app.test.ts +108 -0
  156. package/src/lib/send-to-app.ts +121 -0
  157. package/src/lib/sfx/movement-tick.ts +37 -0
  158. package/src/lib/snapping-mode.test.ts +13 -2
  159. package/src/lib/snapping-mode.ts +11 -6
  160. package/src/lib/spatial-drag-plane.test.ts +53 -0
  161. package/src/lib/spatial-drag-plane.ts +19 -0
  162. package/src/lib/spatial-pointer-input.test.ts +72 -0
  163. package/src/lib/spatial-pointer-input.ts +62 -0
  164. package/src/lib/spatial-snap.test.ts +47 -0
  165. package/src/lib/surface-attachment.ts +77 -0
  166. package/src/lib/terrain-panel-model.ts +117 -0
  167. package/src/lib/units.ts +254 -0
  168. package/src/lib/usdz-export.ts +43 -16
  169. package/src/lib/use-linear-display.ts +8 -32
  170. package/src/lib/world-grid-snap.test.ts +48 -0
  171. package/src/lib/world-grid-snap.ts +9 -5
  172. package/src/store/rotation-snapping.test.ts +53 -0
  173. package/src/store/use-editor.tsx +10 -2
  174. package/src/store/use-floorplan-draft-preview.ts +10 -1
  175. package/src/store/use-handle-group.ts +4 -0
  176. package/src/store/use-interaction-scope.ts +155 -6
  177. package/src/store/use-send-to-app.ts +21 -0
@@ -15,6 +15,19 @@ import { type ComponentType, lazy } from 'react'
15
15
  */
16
16
  const lazyToolCache = new WeakMap<() => Promise<unknown>, ComponentType>()
17
17
 
18
+ export async function preloadRegistryAffordanceTools(kind: string): Promise<void> {
19
+ const tools = nodeRegistry.get(kind)?.affordanceTools
20
+ await Promise.allSettled(
21
+ Object.values(tools ?? {}).map(async (loader) => {
22
+ if (!loader || lazyToolCache.has(loader)) return
23
+ const module = (await loader()) as { default: ComponentType<any> }
24
+ // Keep an already-mounted lazy component stable. Otherwise use the resolved
25
+ // component directly so grabbing a preloaded handle never suspends its tool.
26
+ if (!lazyToolCache.has(loader)) lazyToolCache.set(loader, module.default)
27
+ }),
28
+ )
29
+ }
30
+
18
31
  export function getRegistryAffordanceTool(
19
32
  kind: string,
20
33
  affordance: string,
@@ -0,0 +1,38 @@
1
+ 'use client'
2
+
3
+ import { Html } from '@react-three/drei'
4
+
5
+ /** Lift of a draft's readout above the top of what it measures, in metres. */
6
+ export const DRAFT_LABEL_Y_OFFSET = 0.22
7
+
8
+ /** Screen-space readout for a draft in 3D (lengths, angles, split segments). */
9
+ export function DraftMeasurementLabel({
10
+ color,
11
+ label,
12
+ position,
13
+ shadowColor,
14
+ }: {
15
+ color: string
16
+ label: string
17
+ position: [number, number, number]
18
+ shadowColor: string
19
+ }) {
20
+ return (
21
+ <Html
22
+ center
23
+ position={position}
24
+ style={{ pointerEvents: 'none', userSelect: 'none' }}
25
+ zIndexRange={[100, 0]}
26
+ >
27
+ <div
28
+ className="whitespace-nowrap font-bold font-mono text-[15px]"
29
+ style={{
30
+ color,
31
+ textShadow: `-1.5px -1.5px 0 ${shadowColor}, 1.5px -1.5px 0 ${shadowColor}, -1.5px 1.5px 0 ${shadowColor}, 1.5px 1.5px 0 ${shadowColor}, 0 0 4px ${shadowColor}, 0 0 4px ${shadowColor}`,
32
+ }}
33
+ >
34
+ {label}
35
+ </div>
36
+ </Html>
37
+ )
38
+ }
@@ -19,6 +19,7 @@ import {
19
19
  MeshBasicMaterial,
20
20
  OrthographicCamera,
21
21
  PerspectiveCamera,
22
+ Ray,
22
23
  Vector3,
23
24
  } from 'three'
24
25
  import { z } from 'zod'
@@ -161,6 +162,32 @@ describe('resolvePointerSupportSurface node tops', () => {
161
162
  expect(support?.worldPoint).toEqual([0, 2, 0])
162
163
  })
163
164
 
165
+ test('XR floor drawing uses the controller ray even when the camera sees a raised surface', () => {
166
+ addPluginPlatform(2, [4, 2, 2])
167
+ const camera = new PerspectiveCamera()
168
+ camera.position.set(0, 5, 5)
169
+ camera.updateMatrixWorld(true)
170
+ const controllerRay = new Ray(new Vector3(0, 1.5, 0), new Vector3(0, -1, 0))
171
+ const desktop = resolvePointerSupportSurface(camera, [0, 0, 0], {
172
+ includeNodeTopSurfaces: true,
173
+ })
174
+ expect(desktop?.sourceNodeId).toBe(PLATFORM_ID)
175
+ const xr = resolvePointerSupportSurface(camera, [0, 0, 0], {
176
+ includeNodeTopSurfaces: true,
177
+ pointerRay: controllerRay,
178
+ })
179
+ expect(xr?.sourceNodeId).toBeNull()
180
+ expect(xr?.worldPoint).toEqual([0, 0, 0])
181
+ camera.position.set(12, 8, -10)
182
+ camera.updateMatrixWorld(true)
183
+ expect(
184
+ resolvePointerSupportSurface(camera, [0, 0, 0], {
185
+ includeNodeTopSurfaces: true,
186
+ pointerRay: controllerRay,
187
+ })?.worldPoint,
188
+ ).toEqual([0, 0, 0])
189
+ })
190
+
164
191
  test('keeps an unhovered item top above a batched slab across batch transitions', () => {
165
192
  const restoreRegistry = nodeRegistry._snapshot()
166
193
  const item = ItemNode.parse({
@@ -1,4 +1,5 @@
1
1
  import {
2
+ type AnyNode,
2
3
  type AnyNodeId,
3
4
  canHostOnTop,
4
5
  GROUND_SUPPORT_ID,
@@ -10,10 +11,11 @@ import {
10
11
  useScene,
11
12
  } from '@pascal-app/core'
12
13
  import { setSurfaceRaycastLayers, useViewer } from '@pascal-app/viewer'
13
- import { type Camera, Matrix3, type Object3D, Raycaster, Vector3 } from 'three'
14
+ import { type Camera, type Object3D, type Ray, Raycaster, Vector3 } from 'three'
14
15
  import { resolveTerrainGroundHit } from '../../../lib/ground-surface'
15
16
  import { scopeNodeId } from '../../../lib/interaction/scope'
16
17
  import useInteractionScope from '../../../store/use-interaction-scope'
18
+ import { surfaceWorldNormalY } from './surface-hit'
17
19
 
18
20
  const originScratch = new Vector3()
19
21
  const hitScratch = new Vector3()
@@ -23,8 +25,6 @@ const worldRayOrigin = new Vector3()
23
25
  const worldRayDirection = new Vector3()
24
26
  const nodeTopRaycaster = new Raycaster()
25
27
  setSurfaceRaycastLayers(nodeTopRaycaster.layers)
26
- const nodeTopNormal = new Vector3()
27
- const nodeTopNormalMatrix = new Matrix3()
28
28
 
29
29
  export type PointerSupportSurface = {
30
30
  /** Level-local elevation of the pointed surface — the election cap. */
@@ -79,16 +79,22 @@ export type PointerSupportSurface = {
79
79
  export function resolvePointerSupportSurface(
80
80
  camera: Camera,
81
81
  worldHit: readonly [number, number, number],
82
- options?: { includeNodeTopSurfaces?: boolean },
82
+ options?: { includeNodeTopSurfaces?: boolean; pointerRay?: Ray },
83
83
  ): PointerSupportSurface | null {
84
84
  const levelId = useViewer.getState().selection.levelId
85
85
  if (!levelId) return null
86
86
 
87
87
  // The world ray, kept before the level conversion below: the terrain field is
88
88
  // world-space (site geometry, not level-local), so the march needs this frame.
89
- camera.getWorldPosition(worldRayOrigin)
89
+ if (options?.pointerRay) worldRayOrigin.copy(options.pointerRay.origin)
90
+ else camera.getWorldPosition(worldRayOrigin)
91
+ const isOrthographic =
92
+ !options?.pointerRay &&
93
+ (camera as Camera & { isOrthographicCamera?: boolean }).isOrthographicCamera === true
90
94
  const cameraToHit = hitScratch.set(worldHit[0], worldHit[1], worldHit[2]).sub(worldRayOrigin)
91
- if ((camera as Camera & { isOrthographicCamera?: boolean }).isOrthographicCamera) {
95
+ if (options?.pointerRay) {
96
+ worldRayDirection.copy(options.pointerRay.direction).normalize()
97
+ } else if (isOrthographic) {
92
98
  // For an orthographic camera every screen pixel has the same direction. The
93
99
  // hit point is offset from the camera along the view plane, so using
94
100
  // `camera.position -> hit` tilts the ray toward the screen centre and makes
@@ -102,7 +108,13 @@ export function resolvePointerSupportSurface(
102
108
  }
103
109
 
104
110
  originScratch.copy(worldRayOrigin)
105
- hitScratch.set(worldHit[0], worldHit[1], worldHit[2])
111
+ // Second world point defining the ray fed to the surface solve. In the plain
112
+ // perspective case `worldHit` already lies on the true pointer ray, so using
113
+ // it keeps `t === 1` (and the returned plan point exact) whenever the pointed
114
+ // surface IS the event plane — the pointer-ray and orthographic branches have
115
+ // no such hit, so they step one unit along the resolved direction.
116
+ if (options?.pointerRay || isOrthographic) hitScratch.copy(worldRayOrigin).add(worldRayDirection)
117
+ else hitScratch.set(worldHit[0], worldHit[1], worldHit[2])
106
118
  // Slab polygons/elevations live in the level frame; the level mesh
107
119
  // carries the storey Y offset and any building rotation.
108
120
  const levelMesh = sceneRegistry.nodes.get(levelId as AnyNodeId)
@@ -194,7 +206,7 @@ export function resolvePointerSupportSurface(
194
206
  // convention — the election owns the invariant.
195
207
  const interactingNodeId = scopeNodeId(useInteractionScope.getState().scope)
196
208
  const isEligibleCandidate = (nodeId: AnyNodeId) => {
197
- let current = nodes[nodeId]
209
+ let current: AnyNode | undefined = nodes[nodeId]
198
210
  const visited = new Set<AnyNodeId>()
199
211
  while (current && !visited.has(current.id)) {
200
212
  if (current.id === interactingNodeId) return false
@@ -247,11 +259,13 @@ export function resolvePointerSupportSurface(
247
259
  ownerObject = ownerObject.parent
248
260
  }
249
261
  if (belongsToNestedNode) continue
250
- nodeTopNormal
251
- .copy(intersection.face.normal)
252
- .applyNormalMatrix(nodeTopNormalMatrix.getNormalMatrix(intersection.object.matrixWorld))
253
- .normalize()
254
- if (nodeTopNormal.y < 0.75) continue
262
+ if (
263
+ surfaceWorldNormalY(
264
+ intersection.face.normal.toArray(),
265
+ intersection.object.matrixWorld,
266
+ ) < 0.75
267
+ )
268
+ continue
255
269
  nearest = {
256
270
  distance: intersection.distance,
257
271
  nodeId,
@@ -0,0 +1,54 @@
1
+ import {
2
+ type AnyNodeId,
3
+ createSceneApi,
4
+ getSurfaceProvider,
5
+ sceneRegistry,
6
+ surfaceRegionContainsPoint,
7
+ useScene,
8
+ } from '@pascal-app/core'
9
+ import { Box3, type Camera, Matrix4, Ray, Vector3 } from 'three'
10
+
11
+ export function createShelfStickiness() {
12
+ const ray = new Ray()
13
+ const box = new Box3()
14
+ const matrix = new Matrix4()
15
+ const cameraPosition = new Vector3()
16
+
17
+ // A ray through a board gap can hit the floor behind the shelf while still targeting its volume.
18
+ return (
19
+ shelfId: string | null | undefined,
20
+ camera: Camera,
21
+ worldPoint: readonly [number, number, number],
22
+ ) => {
23
+ if (!shelfId) return false
24
+ const mesh = sceneRegistry.nodes.get(shelfId)
25
+ const shelf = useScene.getState().nodes[shelfId as AnyNodeId]
26
+ if (!mesh || !shelf || (shelf.type !== 'shelf' && shelf.type !== 'cabinet')) return false
27
+ camera.getWorldPosition(cameraPosition)
28
+ ray.origin.copy(cameraPosition)
29
+ ray.direction
30
+ .set(...worldPoint)
31
+ .sub(cameraPosition)
32
+ .normalize()
33
+ ray.applyMatrix4(matrix.copy(mesh.matrixWorld).invert())
34
+ if (shelf.type === 'cabinet') {
35
+ const surfaces =
36
+ getSurfaceProvider(shelf).surfaces?.(shelf, { scene: createSceneApi(useScene) }) ?? []
37
+ return surfaces.some((surface) => {
38
+ if (Math.abs(ray.direction.y) < 1e-8) return false
39
+ const distance = (surface.position[1] - ray.origin.y) / ray.direction.y
40
+ if (distance < 0) return false
41
+ const point = ray.at(distance, new Vector3())
42
+ return surfaceRegionContainsPoint(surface.region, [
43
+ point.x - surface.position[0],
44
+ point.z - surface.position[2],
45
+ ])
46
+ })
47
+ }
48
+ if (!shelf.width || !shelf.depth || !shelf.height) return false
49
+ const margin = 0.08
50
+ box.min.set(-shelf.width / 2 - margin, -margin, -shelf.depth / 2 - margin)
51
+ box.max.set(shelf.width / 2 + margin, shelf.height + margin, shelf.depth / 2 + margin)
52
+ return ray.intersectsBox(box)
53
+ }
54
+ }
@@ -0,0 +1,56 @@
1
+ import { afterEach, expect, test } from 'bun:test'
2
+ import { type ItemEvent, ItemNode, sceneRegistry } from '@pascal-app/core'
3
+ import { Group, Matrix3, Vector3 } from 'three'
4
+ import { itemEventToSurfaceHit } from './surface-hit'
5
+
6
+ const host = ItemNode.parse({
7
+ id: 'item_hit-adapter',
8
+ asset: { id: 'host', name: 'Host', category: 'furniture', thumbnail: '', src: '/host.glb' },
9
+ })
10
+
11
+ afterEach(() => sceneRegistry.nodes.delete(host.id))
12
+
13
+ test('matches the legacy host-local point and hit-mesh normal exactly under rotation and scale', () => {
14
+ const mesh = new Group()
15
+ mesh.position.set(4.3, 2.1, -3.7)
16
+ mesh.rotation.set(0.2, 0.7, -0.1)
17
+ mesh.scale.set(2, 0.7, 1.3)
18
+ const object = new Group()
19
+ object.name = 'surface-mesh'
20
+ object.position.set(0.4, 0.8, -0.2)
21
+ object.rotation.set(-0.1, 0.3, 0.2)
22
+ object.scale.set(0.8, 1.4, 0.6)
23
+ mesh.add(object)
24
+ mesh.updateMatrixWorld(true)
25
+ sceneRegistry.nodes.set(host.id, mesh)
26
+ const event = {
27
+ node: host,
28
+ object,
29
+ position: object.localToWorld(new Vector3(0.37, 0.81, -0.39)).toArray(),
30
+ localPosition: [0.37, 0.81, -0.39],
31
+ normal: [0.2, 1, -0.3],
32
+ nativeEvent: {} as ItemEvent['nativeEvent'],
33
+ stopPropagation() {},
34
+ } as ItemEvent
35
+ const before = structuredClone({ position: event.position, normal: event.normal })
36
+ const expectedPoint = mesh.worldToLocal(new Vector3(...event.position)).toArray()
37
+ const expectedNormal = new Vector3(...event.normal!)
38
+ .applyNormalMatrix(new Matrix3().getNormalMatrix(object.matrixWorld))
39
+ .normalize().y
40
+ const hit = itemEventToSurfaceHit(host, event)!
41
+ expect(hit.point).toEqual(expectedPoint)
42
+ expect(hit.point).not.toEqual(event.localPosition)
43
+ expect(hit.normalWorldY).toBe(expectedNormal)
44
+ expect(hit.meshName).toBe(object.name)
45
+ expect({ position: event.position, normal: event.normal }).toEqual(before)
46
+
47
+ const roundTrip = mesh.localToWorld(new Vector3(...expectedPoint)).toArray()
48
+ expect(itemEventToSurfaceHit(host, { ...event, position: roundTrip })!.point).toEqual(
49
+ mesh.worldToLocal(new Vector3(...roundTrip)).toArray(),
50
+ )
51
+ expect(itemEventToSurfaceHit(host, { ...event, normal: undefined })!.normalWorldY).toBeNaN()
52
+ })
53
+
54
+ test('a missing registered host mesh yields no hit', () => {
55
+ expect(itemEventToSurfaceHit(host, {} as ItemEvent)).toBeNull()
56
+ })
@@ -0,0 +1,23 @@
1
+ import { type AnyNode, type NodeEvent, type SurfaceHit, sceneRegistry } from '@pascal-app/core'
2
+ import { Matrix3, type Matrix4, Vector3 } from 'three'
3
+
4
+ export function surfaceWorldNormalY(
5
+ normal: NodeEvent<AnyNode>['normal'],
6
+ matrixWorld: Matrix4,
7
+ ): number {
8
+ return normal
9
+ ? new Vector3(...normal)
10
+ .applyNormalMatrix(new Matrix3().getNormalMatrix(matrixWorld))
11
+ .normalize().y
12
+ : Number.NaN
13
+ }
14
+
15
+ export function itemEventToSurfaceHit(host: AnyNode, event: NodeEvent<AnyNode>): SurfaceHit | null {
16
+ const mesh = sceneRegistry.nodes.get(host.id)
17
+ if (!mesh) return null
18
+ return {
19
+ point: mesh.worldToLocal(new Vector3(...event.position)).toArray(),
20
+ normalWorldY: surfaceWorldNormalY(event.normal, event.object.matrixWorld),
21
+ meshName: event.object.name,
22
+ }
23
+ }
@@ -0,0 +1,107 @@
1
+ import { afterEach, beforeEach, expect, test } from 'bun:test'
2
+ import {
3
+ type AnyNode,
4
+ ItemNode,
5
+ nodeRegistry,
6
+ resolveSurfacePlacement,
7
+ type SceneApi,
8
+ type SurfaceProvider,
9
+ type SurfaceRejectReason,
10
+ } from '@pascal-app/core'
11
+ import { registerHostingTestNode } from '../__fixtures__/hosting'
12
+ import { createSurfaceRejectionFeedback } from './surface-rejection'
13
+
14
+ let restore: () => void
15
+ beforeEach(() => {
16
+ restore = nodeRegistry._snapshot()
17
+ nodeRegistry._reset()
18
+ })
19
+ afterEach(() => restore())
20
+
21
+ for (const reason of [
22
+ 'footprint-outside-surface',
23
+ 'footprint-exceeds-host',
24
+ 'surface-cutout',
25
+ 'child-not-accepted',
26
+ 'host-not-eligible',
27
+ 'no-surface',
28
+ 'invalid-hit',
29
+ ] as const) {
30
+ test(`${reason}: eligible-host diagnostics remain available; ineligible hosts defer silently`, () => {
31
+ const provider: SurfaceProvider = {
32
+ childFrame: 'host-local',
33
+ resolveHit: () =>
34
+ reason === 'no-surface'
35
+ ? null
36
+ : {
37
+ id: 'top',
38
+ position: [0, 1, 0],
39
+ normal: [0, 1, 0],
40
+ region: {
41
+ kind: 'rect',
42
+ size: [1, 1],
43
+ ...(reason === 'surface-cutout'
44
+ ? {
45
+ holes: [
46
+ [
47
+ [-0.2, -0.2],
48
+ [0.2, -0.2],
49
+ [0.2, 0.2],
50
+ [-0.2, 0.2],
51
+ ],
52
+ ],
53
+ }
54
+ : {}),
55
+ },
56
+ },
57
+ accepts: () => reason !== 'child-not-accepted',
58
+ }
59
+ registerHostingTestNode({
60
+ kind: 'test-host',
61
+ schemaVersion: 1,
62
+ schema: ItemNode,
63
+ category: 'furnish',
64
+ defaults: () => ({}),
65
+ capabilities:
66
+ reason === 'footprint-exceeds-host'
67
+ ? { dragBounds: () => ({ size: [0.1, 1, 0.1] }) }
68
+ : { surfaces: { hosting: provider } },
69
+ })
70
+ const feedback = createSurfaceRejectionFeedback()
71
+ expect(
72
+ resolveSurfacePlacement({
73
+ host: {
74
+ id: 'test_host',
75
+ type: reason === 'host-not-eligible' ? 'guide' : 'test-host',
76
+ } as unknown as AnyNode,
77
+ childKind: 'item',
78
+ childId: 'item_existing',
79
+ childFootprint: {
80
+ size: reason === 'footprint-outside-surface' ? [3, 1, 3] : [0.3, 0.3, 0.3],
81
+ rotationY: 0,
82
+ },
83
+ hit: {
84
+ point: [reason.startsWith('footprint-') ? 2 : 0, reason === 'invalid-hit' ? NaN : 1, 0],
85
+ normalWorldY: 1,
86
+ },
87
+ scene: { get: () => undefined, nodes: () => ({}) } as unknown as SceneApi,
88
+ onReject: (r) => feedback.reject(r),
89
+ }),
90
+ ).toBeNull()
91
+ expect(feedback.reason).toBe(reason === 'host-not-eligible' ? null : reason)
92
+ })
93
+ }
94
+
95
+ test('the paired grid cannot erase a refusal; a new floor move and cleanup clear it', () => {
96
+ const changes: (SurfaceRejectReason | null)[] = []
97
+ const feedback = createSurfaceRejectionFeedback((r) => changes.push(r))
98
+ const event = {}
99
+ feedback.reject('footprint-exceeds-host', event)
100
+ feedback.grid(event)
101
+ expect(feedback.reason).toBe('footprint-exceeds-host')
102
+ feedback.grid({})
103
+ expect(feedback.reason).toBeNull()
104
+ feedback.reject('surface-cutout', event)
105
+ feedback.clear()
106
+ expect(changes).toEqual(['footprint-exceeds-host', null, 'surface-cutout', null])
107
+ })
@@ -0,0 +1,43 @@
1
+ import type { SurfaceRejectReason } from '@pascal-app/core'
2
+
3
+ export function createSurfaceRejectionFeedback(
4
+ onChange?: (reason: SurfaceRejectReason | null) => void,
5
+ ) {
6
+ let reason: SurfaceRejectReason | null = null
7
+ let rejectedEvent: object | undefined
8
+ const set = (next: SurfaceRejectReason | null) => {
9
+ reason = next
10
+ onChange?.(next)
11
+ }
12
+ return {
13
+ get reason() {
14
+ return reason
15
+ },
16
+ reject(next: SurfaceRejectReason, event?: object) {
17
+ if (event) rejectedEvent = event
18
+ set(next)
19
+ },
20
+ clear() {
21
+ rejectedEvent = undefined
22
+ set(null)
23
+ },
24
+ grid(event: object) {
25
+ // A host refusal and the floor ray beneath it belong to the same pointer move.
26
+ if (event !== rejectedEvent) this.clear()
27
+ },
28
+ }
29
+ }
30
+
31
+ // R3F reuses one native event across enter/move dispatch and bubbled ancestor hits.
32
+ export function createSurfaceEventOwnership() {
33
+ const owners = new WeakMap<object, string>()
34
+ return {
35
+ allows(hostId: string, event: object) {
36
+ const owner = owners.get(event)
37
+ return owner === undefined || owner === hostId
38
+ },
39
+ claim(hostId: string, event: object) {
40
+ if (!owners.has(event)) owners.set(event, hostId)
41
+ },
42
+ }
43
+ }
@@ -13,6 +13,7 @@ import {
13
13
  import { useViewer } from '@pascal-app/viewer'
14
14
  import { type ComponentType, lazy, Suspense, useMemo } from 'react'
15
15
  import { useRegisteredToolEnabled } from '../../hooks/use-registered-tool-enabled'
16
+ import type { ReshapeKind } from '../../lib/interaction/scope'
16
17
  import { siteBoundaryHandlesEnabled } from '../../lib/site-boundary'
17
18
  import useEditor, { type Phase, type Tool } from '../../store/use-editor'
18
19
  import useInteractionScope, {
@@ -33,7 +34,10 @@ import { WallSnapBeaconLayer } from '../editor/wall-snap-beacon-layer'
33
34
  import { ElevatorTool } from './elevator/elevator-tool'
34
35
  import { MoveTool } from './item/move-tool'
35
36
  import { RegistryToolProvider } from './registry-tool-context'
36
- import { getRegistryAffordanceTool } from './shared/affordance-dispatch'
37
+ import {
38
+ getRegistryAffordanceTool,
39
+ preloadRegistryAffordanceTools,
40
+ } from './shared/affordance-dispatch'
37
41
  import { FacingPoseIndicator } from './shared/facing-pose-indicator'
38
42
  import { SiteBoundaryEditor } from './site/site-boundary-editor'
39
43
  import { TerrainSculptTool } from './site/terrain-sculpt-tool'
@@ -44,6 +48,15 @@ import { ZoneTool } from './zone/zone-tool'
44
48
  // Cache lazy tool components keyed by their loader so React.lazy isn't
45
49
  // re-invoked across renders.
46
50
  const lazyToolCache = new WeakMap<() => Promise<unknown>, ComponentType>()
51
+ // Reshapes with their own arm below; anything else resolves by name.
52
+ const DEDICATED_RESHAPES = new Set<ReshapeKind>([
53
+ 'curve',
54
+ 'hole',
55
+ 'endpoint',
56
+ 'boundary',
57
+ 'control-point',
58
+ 'tangent',
59
+ ])
47
60
  const registryToolPreloadCache = new WeakMap<AnyNodeDefinition, Promise<void>>()
48
61
 
49
62
  export function preloadRegistryToolModules(tool: string | null): Promise<void> {
@@ -60,8 +73,7 @@ export function preloadRegistryToolModules(tool: string | null): Promise<void> {
60
73
  if (def.system) loaders.push(def.system.module)
61
74
  if (def.parametrics?.customPanel) loaders.push(def.parametrics.customPanel)
62
75
  if (def.parametrics?.trailingSection) loaders.push(def.parametrics.trailingSection)
63
- const moveTool = def.affordanceTools?.move
64
- if (moveTool) loaders.push(moveTool)
76
+ loaders.push(() => preloadRegistryAffordanceTools(tool))
65
77
 
66
78
  const preload = Promise.allSettled(loaders.map((loader) => loader())).then(() => undefined)
67
79
  registryToolPreloadCache.set(def, preload)
@@ -114,6 +126,11 @@ export const ToolManager: React.FC = () => {
114
126
  const tangentReshape = useTangentReshape()
115
127
  const isCurveReshape = useIsCurveReshape()
116
128
  const isToolDrivenReshape = useIsToolDrivenReshape()
129
+ const registryReshape = useInteractionScope((state) =>
130
+ state.scope.kind === 'reshaping' && !DEDICATED_RESHAPES.has(state.scope.reshape)
131
+ ? state.scope.reshape
132
+ : null,
133
+ )
117
134
  const isFloorplanDrivenReshape = useIsFloorplanDrivenReshape()
118
135
  const reshapingNode = useReshapingNode()
119
136
  // The endpoint affordance tool's `target` is kind-specific
@@ -384,6 +401,22 @@ export const ToolManager: React.FC = () => {
384
401
  </Suspense>
385
402
  ) : null
386
403
  })()}
404
+ {/* Reshape kinds without an arm above mount the kind's own affordance tool
405
+ under the reshape's name (`def.affordanceTools[reshape]`, e.g. wall 'split'). */}
406
+ {isToolDrivenReshape &&
407
+ reshapingNode &&
408
+ registryReshape &&
409
+ (() => {
410
+ const RegistryAffordance = getRegistryAffordanceTool(
411
+ reshapingNode.type,
412
+ registryReshape,
413
+ )
414
+ return RegistryAffordance ? (
415
+ <Suspense fallback={null}>
416
+ <RegistryAffordance node={reshapingNode} />
417
+ </Suspense>
418
+ ) : null
419
+ })()}
387
420
  {showMover && movingNode.type !== 'building' && (
388
421
  <MoveTool
389
422
  onNodeMoved={handlePlacedNodeSelected}
@@ -3,6 +3,7 @@ import {
3
3
  emitter,
4
4
  type GridEvent,
5
5
  type LevelNode,
6
+ runAsSingleSceneHistoryStep,
6
7
  snapPointAlongAngleRay,
7
8
  useScene,
8
9
  ZoneNode,
@@ -16,6 +17,7 @@ import {
16
17
  clearSurfacePlanSnapFeedback,
17
18
  resolveSurfacePlanPointSnap,
18
19
  } from './../../../lib/surface-plan-snap'
20
+ import { focusedUnitNode } from './../../../lib/units'
19
21
  import { snapWorldXZForActiveBuilding } from './../../../lib/world-grid-snap'
20
22
  import useEditor, { isAngleSnapActive, isGridSnapActive } from './../../../store/use-editor'
21
23
  import { useFloorplanDraftPreview } from './../../../store/use-floorplan-draft-preview'
@@ -27,7 +29,7 @@ const Y_OFFSET = 0.02
27
29
  * Creates a zone with the given polygon points
28
30
  */
29
31
  const commitZoneDrawing = (levelId: LevelNode['id'], points: Array<[number, number]>) => {
30
- const { createNode, nodes } = useScene.getState()
32
+ const { createNode, updateNode, nodes } = useScene.getState()
31
33
 
32
34
  // Count existing zones for naming and color cycling
33
35
  const zoneCount = Object.values(nodes).filter((n) => n.type === 'zone').length
@@ -42,10 +44,17 @@ const commitZoneDrawing = (levelId: LevelNode['id'], points: Array<[number, numb
42
44
  color,
43
45
  })
44
46
 
45
- createNode(zone, levelId)
47
+ const focusedUnit = focusedUnitNode()
46
48
 
47
- // Select the newly created zone
48
- useViewer.getState().setSelection({ zoneId: zone.id })
49
+ // Joining the focused unit rides in the zone's own undo step.
50
+ runAsSingleSceneHistoryStep(useScene, () => {
51
+ createNode(zone, levelId)
52
+ if (focusedUnit) updateNode(focusedUnit.id, { members: [...focusedUnit.members, zone.id] })
53
+ })
54
+
55
+ // Selecting the zone would end unit focus; while painting a unit the new
56
+ // zone just joins it and drawing continues.
57
+ if (!focusedUnit) useViewer.getState().setSelection({ zoneId: zone.id })
49
58
 
50
59
  // Play structure build sound
51
60
  sfxEmitter.emit('sfx:structure-build')