@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
@@ -1,11 +1,15 @@
1
1
  import {
2
2
  type AnyNode,
3
3
  type AnyNodeId,
4
+ calculateLevelMiters,
5
+ getWallPlanFootprint,
4
6
  nodeRegistry,
5
7
  resolveBuildingForLevel,
6
8
  sceneRegistry,
9
+ useScene,
10
+ type WallNode,
7
11
  } from '@pascal-app/core'
8
- import { Box3, Matrix4 } from 'three'
12
+ import { Box3, type BufferGeometry, Matrix4, type Object3D, Vector3 } from 'three'
9
13
 
10
14
  // Shared plumbing for the group transform gizmos (rotate + move). Both operate
11
15
  // on the same multi-selection: classify each participant by how its placement
@@ -242,48 +246,6 @@ export function collectParticipants(
242
246
  return { starts, links }
243
247
  }
244
248
 
245
- // Grow a selection to the full connected component of walls/fences: any
246
- // endpoint node transitively reachable through shared junctions from a selected
247
- // endpoint node joins in, so the whole rigid structure transforms as one piece
248
- // (rather than tearing/stretching at the boundary). Non-endpoint selections
249
- // (items, columns) pass through unchanged.
250
- export function expandToComponent(
251
- selectedIds: string[],
252
- sceneNodes: Record<string, AnyNode | undefined>,
253
- levelId: string | null,
254
- ): string[] {
255
- const endpoints: { id: string; start: Vec2; end: Vec2 }[] = []
256
- for (const [id, node] of Object.entries(sceneNodes)) {
257
- if (classifyParticipant(node, levelId, sceneNodes) === 'endpoint') {
258
- const n = node as AnyNode & { start: Vec2; end: Vec2 }
259
- endpoints.push({ id, start: [n.start[0], n.start[1]], end: [n.end[0], n.end[1]] })
260
- }
261
- }
262
- const included = new Set(selectedIds)
263
- if (!endpoints.some((e) => included.has(e.id))) return selectedIds
264
-
265
- let changed = true
266
- while (changed) {
267
- changed = false
268
- for (const e of endpoints) {
269
- if (included.has(e.id)) continue
270
- const touches = endpoints.some(
271
- (o) =>
272
- included.has(o.id) &&
273
- (nearPoint(e.start, o.start) ||
274
- nearPoint(e.start, o.end) ||
275
- nearPoint(e.end, o.start) ||
276
- nearPoint(e.end, o.end)),
277
- )
278
- if (touches) {
279
- included.add(e.id)
280
- changed = true
281
- }
282
- }
283
- }
284
- return Array.from(included)
285
- }
286
-
287
249
  // Per-node field patch, keyed for `useLiveNodeOverrides.setMany` during a live
288
250
  // preview and for the single batched `updateNodes` on commit.
289
251
  export type GroupPatch = readonly [AnyNodeId, Record<string, unknown>]
@@ -389,9 +351,24 @@ export function rotateGroupSnapshots(
389
351
 
390
352
  export type GroupPlanBounds = { minX: number; minZ: number; maxX: number; maxZ: number }
391
353
 
392
- // Level-frame XZ extents of the participant DATA — the mesh-free sibling of
393
- // `computeGroupBox`, used when meshes aren't mounted yet.
394
- function participantExtents(starts: ParticipantStart[]): GroupPlanBounds | null {
354
+ const planCorner = new Vector3()
355
+ const planMatrix = new Matrix4()
356
+
357
+ /**
358
+ * The selection's footprint in the level frame — the plan's own coordinates,
359
+ * measured from node data wherever the plan draws from data: walls by their
360
+ * mitered outline, polygon hosts (slab, ceiling, zone) by their polygon, fences
361
+ * by their run. Only placed objects (items, columns, stairs…) measure their
362
+ * meshes: bounds go through `frameInv × matrixWorld` (a world box can't be
363
+ * carried into a rotated building's frame), placeholders — unbuilt while the
364
+ * 3D scene is paused in 2D-only view — are skipped, and a node with no built
365
+ * mesh falls back to its anchor.
366
+ */
367
+ export function groupPlanBounds(
368
+ starts: ParticipantStart[],
369
+ frameInv: Matrix4,
370
+ ): GroupPlanBounds | null {
371
+ const nodes = useScene.getState().nodes
395
372
  let minX = Number.POSITIVE_INFINITY
396
373
  let minZ = Number.POSITIVE_INFINITY
397
374
  let maxX = Number.NEGATIVE_INFINITY
@@ -402,43 +379,72 @@ function participantExtents(starts: ParticipantStart[]): GroupPlanBounds | null
402
379
  maxX = Math.max(maxX, x)
403
380
  maxZ = Math.max(maxZ, z)
404
381
  }
382
+ const miters = new Map<string | null, ReturnType<typeof calculateLevelMiters>>()
383
+ const wallOutline = (wall: WallNode) => {
384
+ const levelId = wall.parentId ?? null
385
+ let levelMiters = miters.get(levelId)
386
+ if (!levelMiters) {
387
+ levelMiters = calculateLevelMiters(
388
+ Object.values(nodes).filter(
389
+ (node): node is WallNode => node?.type === 'wall' && (node.parentId ?? null) === levelId,
390
+ ),
391
+ )
392
+ miters.set(levelId, levelMiters)
393
+ }
394
+ return getWallPlanFootprint(wall, levelMiters)
395
+ }
396
+ const measureMeshes = (id: string) => {
397
+ const obj = sceneRegistry.nodes.get(id as AnyNodeId)
398
+ if (!obj) return false
399
+ obj.updateWorldMatrix(true, true)
400
+ let found = false
401
+ obj.traverse((child: Object3D) => {
402
+ const geometry = (child as Object3D & { geometry?: BufferGeometry }).geometry
403
+ if (!child.visible || !geometry || geometry.userData.placeholder) return
404
+ // Always fresh: geometries rebuilt in place keep a stale cached box.
405
+ geometry.computeBoundingBox()
406
+ const bounds = geometry.boundingBox
407
+ if (!bounds || bounds.isEmpty()) return
408
+ planMatrix.multiplyMatrices(frameInv, child.matrixWorld)
409
+ for (let corner = 0; corner < 8; corner++) {
410
+ planCorner
411
+ .set(
412
+ corner & 1 ? bounds.max.x : bounds.min.x,
413
+ corner & 2 ? bounds.max.y : bounds.min.y,
414
+ corner & 4 ? bounds.max.z : bounds.min.z,
415
+ )
416
+ .applyMatrix4(planMatrix)
417
+ reach(planCorner.x, planCorner.z)
418
+ }
419
+ found = true
420
+ })
421
+ return found
422
+ }
405
423
  for (const s of starts) {
424
+ const node = nodes[s.id]
406
425
  if (s.kind === 'endpoint') {
426
+ const outline = node?.type === 'wall' ? wallOutline(node) : []
427
+ if (outline.length > 0) {
428
+ for (const point of outline) reach(point.x, point.y)
429
+ continue
430
+ }
407
431
  reach(s.start[0], s.start[1])
408
432
  reach(s.end[0], s.end[1])
433
+ const path = (node as { path?: unknown } | undefined)?.path
434
+ if (isVec2Array(path)) for (const [x, z] of path) reach(x, z)
409
435
  } else if (s.kind === 'polygon') {
410
- for (const [x, z] of s.polygon) {
411
- reach(x, z)
412
- }
413
- } else {
436
+ for (const [x, z] of s.polygon) reach(x, z)
437
+ } else if (!measureMeshes(s.id)) {
414
438
  reach(s.position[0], s.position[2])
415
439
  }
416
440
  }
417
- if (!Number.isFinite(minX)) return null
418
- return { minX, minZ, maxX, maxZ }
441
+ return Number.isFinite(minX) ? { minX, minZ, maxX, maxZ } : null
419
442
  }
420
443
 
421
- // The one footprint every group transform measures itself against: the
422
- // selection's mounted meshes (world box, converted into the level frame) with
423
- // the participant DATA extents as the fallback when the meshes aren't up yet
424
- // (Duplicate picks up its clones a frame before their renderers mount). Anchor
425
- // points alone sit metres inside a wide selection's real footprint, so a
426
- // gesture that pivots on the data extents orbits a different point than the
427
- // idle keyboard rotate and the rotate gizmos, which both use the mesh box.
428
- export function groupPlanBounds(
429
- box: Box3 | null,
430
- starts: ParticipantStart[],
431
- frameInv: Matrix4,
432
- ): GroupPlanBounds | null {
433
- if (!box) return participantExtents(starts)
434
- const min = box.min.clone().applyMatrix4(frameInv)
435
- const max = box.max.clone().applyMatrix4(frameInv)
436
- return {
437
- minX: Math.min(min.x, max.x),
438
- minZ: Math.min(min.z, max.z),
439
- maxX: Math.max(min.x, max.x),
440
- maxZ: Math.max(min.z, max.z),
441
- }
444
+ /** `groupPlanBounds` for a selection: the dashed boxes and gizmos measure this. */
445
+ export function computeGroupPlanBox(ids: string[], levelId: string | null): GroupPlanBounds | null {
446
+ const { starts } = collectParticipants(ids, useScene.getState().nodes, levelId)
447
+ return groupPlanBounds(starts, levelFrame(levelId).inverse)
442
448
  }
443
449
 
444
450
  export const planBoundsCenter = (b: GroupPlanBounds): Vec2 => [
@@ -1,6 +1,6 @@
1
1
  'use client'
2
2
 
3
- import { type Cursor, emitter } from '@pascal-app/core'
3
+ import { type Cursor, emitter, sceneRegistry } from '@pascal-app/core'
4
4
  import type { ThreeEvent } from '@react-three/fiber'
5
5
  import { type ReactNode, useEffect, useMemo, useRef } from 'react'
6
6
  import {
@@ -17,11 +17,15 @@ import {
17
17
  type Raycaster,
18
18
  Shape,
19
19
  TorusGeometry,
20
+ Vector3,
20
21
  } from 'three'
21
22
  import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
22
23
  import { MeshBasicNodeMaterial } from 'three/webgpu'
23
24
  import { EDITOR_LAYER } from '../../../lib/constants'
24
25
  import { EDITOR_HANDLE_HIT_AREA_USER_DATA_KEY } from '../../../lib/direct-manipulation'
26
+ import { createSpatialDragPlane, intersectSpatialDragPlane } from '../../../lib/spatial-drag-plane'
27
+ import { getSpatialPointerId, spatialPointerInput } from '../../../lib/spatial-pointer-input'
28
+ import { getActiveBuildingId } from '../../../lib/world-grid-snap'
25
29
  import useEditor from '../../../store/use-editor'
26
30
 
27
31
  // While a press-drag move is in flight (`placementDragMode`), the move tool
@@ -42,6 +46,7 @@ export const NO_RAYCAST = () => null
42
46
  export const HIT_AREA_MARGIN = 0.035
43
47
 
44
48
  const HIT_AREA_RENDER_ORDER = 1011
49
+ const HIT_AREA_POINTER_EVENTS_ORDER = 10
45
50
  const HIT_AREA_THICKNESS = 0.08
46
51
  const CHEVRON_MIN_X = -0.2
47
52
  const CHEVRON_MAX_X = 0.22
@@ -492,15 +497,80 @@ export function InvisibleHandleHitArea({
492
497
  onPointerLeave: PointerHandler
493
498
  scale: number
494
499
  }) {
500
+ const handlePointerDown: PointerHandler = (event) => {
501
+ const spatialPointerId = getSpatialPointerId(event.nativeEvent)
502
+ if (spatialPointerId) {
503
+ const pointerTarget = event.object as typeof event.object & {
504
+ releasePointerCapture?: (pointerId: number) => void
505
+ setPointerCapture?: (pointerId: number) => void
506
+ }
507
+ pointerTarget.setPointerCapture?.(event.pointerId)
508
+ const buildingId = getActiveBuildingId()
509
+ const building = buildingId ? sceneRegistry.nodes.get(buildingId) : undefined
510
+ building?.updateWorldMatrix(true, false)
511
+ const inverse = building?.matrixWorld.clone().invert()
512
+ const initialPoint = event.point.clone()
513
+ const plane = createSpatialDragPlane(initialPoint, event.ray, building?.matrixWorld)
514
+ const point = new Vector3()
515
+ let seeded = false
516
+ const emitPoint = (position: Vector3, ray = event.ray) => {
517
+ const local = inverse ? position.clone().applyMatrix4(inverse) : position
518
+ emitter.emit('grid:move', {
519
+ position: [position.x, position.y, position.z],
520
+ localPosition: [local.x, local.y, local.z],
521
+ nativeEvent: {
522
+ ...event.nativeEvent,
523
+ inputSource: spatialPointerId,
524
+ pointerType: 'xr',
525
+ pointerId: event.pointerId,
526
+ target: event.nativeEvent.target,
527
+ ray: ray.clone(),
528
+ } as never,
529
+ })
530
+ }
531
+ // Direct resize sessions replace this capture in onPointerDown below.
532
+ // Move/reshape tools consume grid events in a plane frozen at the grip.
533
+ spatialPointerInput.capture(spatialPointerId, {
534
+ onMove: (ray) => {
535
+ if (!seeded) {
536
+ emitPoint(initialPoint)
537
+ seeded = true
538
+ }
539
+ if (intersectSpatialDragPlane(ray, plane, point)) emitPoint(point, ray)
540
+ },
541
+ onRelease: () => {
542
+ pointerTarget.releasePointerCapture?.(event.pointerId)
543
+ window.dispatchEvent(
544
+ new PointerEvent('pointerup', {
545
+ bubbles: true,
546
+ button: 0,
547
+ pointerId: event.pointerId,
548
+ pointerType: 'xr',
549
+ }),
550
+ )
551
+ },
552
+ onCancel: () => {
553
+ pointerTarget.releasePointerCapture?.(event.pointerId)
554
+ emitter.emit('tool:cancel')
555
+ },
556
+ onReplace: () => {
557
+ pointerTarget.releasePointerCapture?.(event.pointerId)
558
+ },
559
+ })
560
+ }
561
+ onPointerDown(event)
562
+ }
563
+
495
564
  return (
496
565
  <mesh
497
566
  frustumCulled={false}
498
567
  geometry={geometry}
499
568
  layers={EDITOR_LAYER}
500
569
  material={material}
501
- onPointerDown={onPointerDown}
570
+ onPointerDown={handlePointerDown}
502
571
  onPointerEnter={onPointerEnter}
503
572
  onPointerLeave={onPointerLeave}
573
+ {...({ pointerEventsOrder: HIT_AREA_POINTER_EVENTS_ORDER } as Record<string, unknown>)}
504
574
  raycast={hitAreaRaycast}
505
575
  renderOrder={HIT_AREA_RENDER_ORDER}
506
576
  scale={scale}
@@ -0,0 +1,111 @@
1
+ import { expect, test } from 'bun:test'
2
+ import { type AnyNode, createSceneApi, type LinearResizeHandle, useScene } from '@pascal-app/core'
3
+ import { Euler, Vector3 } from 'three'
4
+ import { createArrowHandleGeometry } from './handle-arrow'
5
+ import {
6
+ computeFreezeOffset,
7
+ resolveLinearHandlePosition,
8
+ resolveLinearHandleRotation,
9
+ } from './handle-placement'
10
+
11
+ const scene = createSceneApi(useScene)
12
+ for (const axis of ['x', 'y', 'z'] as const) {
13
+ test(`${axis} clearance preserves sufficient offsets and keeps even hovered arrow tails clear at different scales`, () => {
14
+ const index = axis === 'x' ? 0 : axis === 'y' ? 1 : 2
15
+ const geometry = createArrowHandleGeometry(true)
16
+ geometry.computeBoundingBox()
17
+ const tail = geometry.boundingBox!.min.x * 1.12
18
+ for (const edge of [0.06, 1.2, -0.4]) {
19
+ const position: [number, number, number] = [0, 0, 0]
20
+ position[index] = edge + 0.15
21
+ const descriptor: LinearResizeHandle<null> = {
22
+ kind: 'linear-resize',
23
+ axis,
24
+ anchor: 'center',
25
+ currentValue: () => 1,
26
+ apply: () => null,
27
+ placement: { position: () => position, clearance: { edge: () => edge, distance: 0.4 } },
28
+ }
29
+ for (const scale of [0.1, 0.325, 0.65, 1.3, 3]) {
30
+ const resolved = resolveLinearHandlePosition(descriptor, null, scene, scale)
31
+ expect(resolved[index] + tail * scale - edge).toBeGreaterThan(0.1 * scale)
32
+ if (0.4 * scale <= 0.15) expect(resolved).toEqual(position)
33
+ resolved.forEach((value, i) => {
34
+ if (i !== index) expect(value).toBe(position[i]!)
35
+ })
36
+ }
37
+ delete descriptor.placement.clearance
38
+ expect(resolveLinearHandlePosition(descriptor, null, scene, 3)).toBe(position)
39
+ }
40
+ geometry.dispose()
41
+ })
42
+ }
43
+
44
+ test('array rotations keep sibling handles finite during rotation and resize', () => {
45
+ const initial = { position: [1, 2, 3], rotation: [0, Math.PI / 2, 0] } as unknown as AnyNode
46
+ const rotated = { ...initial, rotation: [0, Math.PI, 0] } as unknown as AnyNode
47
+ expect(computeFreezeOffset(rotated, initial)).toEqual([0, 0, 0])
48
+ const moved = { ...initial, position: [2, 4, 3] } as unknown as AnyNode
49
+ const offset = computeFreezeOffset(moved, initial)
50
+ expect(offset[0]).toBeCloseTo(0)
51
+ expect(offset[1]).toBe(2)
52
+ expect(offset[2]).toBeCloseTo(1)
53
+ expect(
54
+ computeFreezeOffset(moved, { ...initial, rotation: Math.PI / 2 } as unknown as AnyNode),
55
+ ).toEqual(offset)
56
+ })
57
+
58
+ for (const axis of ['x', 'y', 'z'] as const) {
59
+ test(`${axis} negative direction keeps hovered arrow above its lower-edge clearance`, () => {
60
+ const index = axis === 'x' ? 0 : axis === 'y' ? 1 : 2
61
+ const geometry = createArrowHandleGeometry(true)
62
+ geometry.computeBoundingBox()
63
+ const tail = -geometry.boundingBox!.min.x * 1.12
64
+ for (const edge of [-2, 0, 2]) {
65
+ const position: [number, number, number] = [0, 0, 0]
66
+ position[index] = edge - 0.15
67
+ const descriptor: LinearResizeHandle<null> = {
68
+ kind: 'linear-resize',
69
+ axis,
70
+ anchor: 'max',
71
+ direction: -1,
72
+ currentValue: () => 1,
73
+ apply: () => null,
74
+ placement: { position: () => position, clearance: { edge: () => edge, distance: 0.4 } },
75
+ }
76
+ for (const scale of [0.1, 0.325, 0.65, 1.3, 3]) {
77
+ const resolved = resolveLinearHandlePosition(descriptor, null, scene, scale)
78
+ expect(edge - (resolved[index] + tail * scale)).toBeGreaterThan(0.1 * scale)
79
+ if (0.4 * scale <= 0.15) expect(resolved).toEqual(position)
80
+ const pointing = new Vector3(1, 0, 0)
81
+ .applyEuler(new Euler(...resolveLinearHandleRotation(descriptor, resolved)))
82
+ .applyAxisAngle(new Vector3(0, 1, 0), axis === 'z' ? -Math.PI / 2 : 0)
83
+ expect(pointing.getComponent(index)).toBeCloseTo(-1)
84
+ }
85
+ }
86
+ geometry.dispose()
87
+ })
88
+ }
89
+
90
+ test('unspecified direction preserves vertical position heuristic and wall-facing blade', () => {
91
+ const descriptor: LinearResizeHandle<null> = {
92
+ kind: 'linear-resize',
93
+ axis: 'y',
94
+ anchor: 'min',
95
+ currentValue: () => 1,
96
+ apply: () => null,
97
+ placement: { position: () => [0, 0, 0] },
98
+ }
99
+ expect(resolveLinearHandleRotation(descriptor, [0, -1, 0])).toEqual([
100
+ 0,
101
+ Math.PI / 2,
102
+ -Math.PI / 2,
103
+ ])
104
+ expect(resolveLinearHandleRotation(descriptor, [0, 1, 0])).toEqual([0, Math.PI / 2, Math.PI / 2])
105
+ expect(
106
+ resolveLinearHandleRotation({ ...descriptor, axis: 'x', faceNormal: true }, [0, 0, 0]),
107
+ ).toEqual([Math.PI / 2, 0, 0])
108
+ expect(
109
+ resolveLinearHandleRotation({ ...descriptor, axis: 'z', faceNormal: true }, [0, 0, 0]),
110
+ ).toEqual([0, 0, 0])
111
+ })
@@ -0,0 +1,69 @@
1
+ import type { AnyNode, LinearResizeHandle, RadialResizeHandle, SceneApi } from '@pascal-app/core'
2
+
3
+ export function resolveLinearHandlePosition<N>(
4
+ descriptor: LinearResizeHandle<N> | RadialResizeHandle<N>,
5
+ node: N,
6
+ scene: SceneApi,
7
+ baseScale: number,
8
+ ): readonly [number, number, number] {
9
+ const position = descriptor.placement.position(node, scene)
10
+ const clearance = descriptor.kind === 'linear-resize' ? descriptor.placement.clearance : undefined
11
+ if (!clearance) return position
12
+ const index = descriptor.axis === 'x' ? 0 : descriptor.axis === 'y' ? 1 : 2
13
+ const result: [number, number, number] = [...position]
14
+ const direction = descriptor.kind === 'linear-resize' ? (descriptor.direction ?? 1) : 1
15
+ const edge = clearance.edge(node, scene) + direction * clearance.distance * baseScale
16
+ result[index] = direction === -1 ? Math.min(result[index], edge) : Math.max(result[index], edge)
17
+ return result
18
+ }
19
+
20
+ export function resolveLinearHandleRotation<N>(
21
+ descriptor: LinearResizeHandle<N> | RadialResizeHandle<N>,
22
+ position: readonly [number, number, number],
23
+ ): [number, number, number] {
24
+ const direction = descriptor.kind === 'linear-resize' ? descriptor.direction : undefined
25
+ if (descriptor.axis === 'y') {
26
+ const sign = direction ?? (position[1] < 0 ? -1 : 1)
27
+ return [0, Math.PI / 2, (sign * Math.PI) / 2]
28
+ }
29
+ const faceNormal =
30
+ descriptor.kind === 'linear-resize' && descriptor.axis === 'x' && descriptor.faceNormal
31
+ return [faceNormal ? Math.PI / 2 : 0, 0, direction === -1 ? Math.PI : 0]
32
+ }
33
+
34
+ // Offset, in node-local frame, that compensates for `position` drift on
35
+ // the mesh during an asymmetric resize. Width/length L+R recompute
36
+ // `position` so the anchored edge stays world-fixed — the renderer
37
+ // follows that override, the ride object moves, and every arrow under
38
+ // it would drift along with the mesh center. Subtracting this offset
39
+ // from a non-active arrow's local placement undoes that drift so it
40
+ // stays at its pre-drag world position.
41
+ //
42
+ // Rotation drags don't change `position`, so the offset collapses to
43
+ // zero and non-active arrows naturally rotate with the mesh — which is
44
+ // the desired behaviour (the whole rig rotates as a unit).
45
+ export function computeFreezeOffset(
46
+ liveNode: AnyNode,
47
+ preDragNode: AnyNode,
48
+ ): [number, number, number] {
49
+ // Not every node in the union carries a `position` field (sites are the
50
+ // notable holdout — they don't have handles anyway, but TypeScript still
51
+ // requires us to discriminate). Guarded access keeps the freeze logic
52
+ // safe for the few node kinds that lack the field.
53
+ const liveP = (liveNode as { position?: readonly [number, number, number] }).position ?? [0, 0, 0]
54
+ const preP = (preDragNode as { position?: readonly [number, number, number] }).position ?? [
55
+ 0, 0, 0,
56
+ ]
57
+ const deltaWorldX = liveP[0] - preP[0]
58
+ const deltaWorldY = liveP[1] - preP[1]
59
+ const deltaWorldZ = liveP[2] - preP[2]
60
+ const rotation = (preDragNode as { rotation?: number | readonly number[] }).rotation
61
+ const rotY = typeof rotation === 'number' ? rotation : (rotation?.[1] ?? 0)
62
+ // World → node-local for Y-axis rotation by rotY (THREE.Object3D
63
+ // rotation-y convention): inverse is rotation by -rotY around +Y.
64
+ const cosR = Math.cos(rotY)
65
+ const sinR = Math.sin(rotY)
66
+ const deltaLocalX = cosR * deltaWorldX - sinR * deltaWorldZ
67
+ const deltaLocalZ = sinR * deltaWorldX + cosR * deltaWorldZ
68
+ return [deltaLocalX, deltaWorldY, deltaLocalZ]
69
+ }
@@ -1,14 +1,23 @@
1
1
  import {
2
2
  type AnyNode,
3
3
  type AnyNodeId,
4
+ cascadeDirty,
4
5
  type HandleDragModifiers,
5
6
  type LinearResizeHandle,
7
+ type RadialResizeHandle,
6
8
  type SceneApi,
7
9
  useLiveNodeOverrides,
8
10
  useScene,
9
11
  } from '@pascal-app/core'
10
12
  import { replacePreviewOverrideIds } from './preview-overrides'
11
13
 
14
+ export function linearResizeFactor<N>(
15
+ descriptor: LinearResizeHandle<N> | RadialResizeHandle<N>,
16
+ ): number {
17
+ if (descriptor.kind === 'radial-resize') return 1
18
+ return descriptor.anchor === 'center' ? 2 : descriptor.anchor === 'min' ? 1 : -1
19
+ }
20
+
12
21
  export function createLinearResizeDragBinding({
13
22
  descriptor,
14
23
  initialNode,
@@ -52,14 +61,16 @@ export function createLinearResizeDragBinding({
52
61
  previewEntries.map(([id, previewPatch]) => [id, previewPatch as Record<string, unknown>]),
53
62
  )
54
63
  for (const [previewId] of previewEntries) {
55
- useScene.getState().markDirty(previewId)
64
+ for (const id of cascadeDirty(previewId, { scene: sceneApi }))
65
+ useScene.getState().markDirty(id)
56
66
  }
57
67
  return patch
58
68
  },
59
69
  clearPreview(): void {
60
70
  for (const previewId of previewOverrideIds) {
61
71
  useLiveNodeOverrides.getState().clear(previewId)
62
- useScene.getState().markDirty(previewId)
72
+ for (const id of cascadeDirty(previewId, { scene: sceneApi }))
73
+ useScene.getState().markDirty(id)
63
74
  }
64
75
  previewOverrideIds = new Set()
65
76
  },