@pascal-app/editor 1.0.1 → 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 (111) hide show
  1. package/package.json +10 -5
  2. package/src/components/editor/custom-camera-controls.tsx +22 -74
  3. package/src/components/editor/editor-layout-mobile.tsx +1 -0
  4. package/src/components/editor/floorplan-panel.tsx +210 -163
  5. package/src/components/editor/grid.tsx +31 -7
  6. package/src/components/editor/group-actions.ts +5 -9
  7. package/src/components/editor/group-floating-action-menu.tsx +19 -9
  8. package/src/components/editor/group-move-3d.ts +4 -6
  9. package/src/components/editor/group-rotate-handle.tsx +81 -35
  10. package/src/components/editor/group-selection-box-3d.tsx +25 -17
  11. package/src/components/editor/group-transform-shared.test.ts +126 -1
  12. package/src/components/editor/group-transform-shared.ts +79 -73
  13. package/src/components/editor/handles/handle-arrow.tsx +72 -2
  14. package/src/components/editor/handles/use-handle-drag.ts +75 -6
  15. package/src/components/editor/index.tsx +24 -9
  16. package/src/components/editor/node-action-menu.tsx +2 -0
  17. package/src/components/editor/node-arrow-handles.tsx +141 -7
  18. package/src/components/editor/registry-action-contributions.tsx +53 -0
  19. package/src/components/editor/wall-move-side-handles.tsx +71 -21
  20. package/src/components/editor/wall-opening-highlights.tsx +2 -3
  21. package/src/components/editor-2d/floorplan-group-move.tsx +17 -24
  22. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +33 -12
  23. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +154 -49
  24. package/src/components/editor-2d/renderers/floorplan-draft-wall-measurement.tsx +166 -0
  25. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +49 -0
  26. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +67 -21
  27. package/src/components/systems/zone/zone-system.tsx +8 -4
  28. package/src/components/tools/__fixtures__/hosting.ts +10 -0
  29. package/src/components/tools/item/item-surface-placement.test.ts +3 -9
  30. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +3 -9
  31. package/src/components/tools/item/placement-strategies.ts +55 -43
  32. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +3 -3
  33. package/src/components/tools/item/use-draft-node.ts +41 -2
  34. package/src/components/tools/item/use-placement-coordinator.tsx +166 -73
  35. package/src/components/tools/registry/item-surface-move.test.ts +10 -2
  36. package/src/components/tools/registry/item-surface-move.ts +46 -24
  37. package/src/components/tools/registry/item-surface-resolver.test.ts +2 -2
  38. package/src/components/tools/registry/move-registry-node-tool.tsx +23 -9
  39. package/src/components/tools/shared/affordance-dispatch.test.ts +24 -0
  40. package/src/components/tools/shared/affordance-dispatch.ts +13 -0
  41. package/src/components/tools/shared/draft-measurement-label.tsx +38 -0
  42. package/src/components/tools/shared/pointer-support-cap.test.ts +27 -0
  43. package/src/components/tools/shared/pointer-support-cap.ts +17 -5
  44. package/src/components/tools/shared/surface-rejection.test.tsx +5 -4
  45. package/src/components/tools/shared/surface-rejection.tsx +14 -0
  46. package/src/components/tools/tool-manager.tsx +36 -3
  47. package/src/components/ui/controls/material-paint-panel.tsx +6 -67
  48. package/src/components/ui/controls/material-picker.tsx +5 -67
  49. package/src/components/ui/controls/slider-control.tsx +76 -31
  50. package/src/components/ui/helpers/helper-manager.tsx +24 -6
  51. package/src/components/ui/helpers/registered-tool-helper.tsx +2 -32
  52. package/src/components/ui/item-catalog/item-catalog.tsx +5 -30
  53. package/src/components/ui/panels/multi-height-mode.tsx +27 -21
  54. package/src/components/ui/panels/parametric-inspector.tsx +3 -15
  55. package/src/components/ui/primitives/shortcut-token.tsx +4 -0
  56. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
  57. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +585 -369
  58. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  59. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +1 -1
  60. package/src/components/viewer/floorplan-preview-visibility.test.ts +35 -0
  61. package/src/components/viewer/floorplan-preview-visibility.ts +19 -0
  62. package/src/components/viewer/floorplan-preview.tsx +1 -12
  63. package/src/components/viewer-zone-system.tsx +7 -2
  64. package/src/hooks/use-auto-save.integration.test.tsx +197 -0
  65. package/src/hooks/use-auto-save.ts +21 -6
  66. package/src/hooks/use-ceiling-events.test.ts +1 -0
  67. package/src/hooks/use-ceiling-events.ts +2 -0
  68. package/src/hooks/use-grid-events.test.ts +2 -0
  69. package/src/hooks/use-grid-events.ts +16 -11
  70. package/src/hooks/use-keyboard.test.ts +7 -0
  71. package/src/hooks/use-keyboard.ts +42 -29
  72. package/src/index.tsx +63 -3
  73. package/src/lib/catalog-panel-model.ts +39 -0
  74. package/src/lib/catalog-selection.test.ts +27 -0
  75. package/src/lib/catalog-selection.ts +8 -0
  76. package/src/lib/contextual-help.test.ts +27 -1
  77. package/src/lib/contextual-help.ts +16 -9
  78. package/src/lib/floorplan/floorplan-export.test.ts +3 -1
  79. package/src/lib/floorplan/floorplan-extension.ts +16 -0
  80. package/src/lib/fresh-planar-placement.ts +168 -36
  81. package/src/lib/glb-export.test.ts +59 -16
  82. package/src/lib/glb-export.ts +26 -13
  83. package/src/lib/grid-frame.test.ts +19 -0
  84. package/src/lib/grid-frame.ts +18 -0
  85. package/src/lib/interaction/scope.ts +9 -1
  86. package/src/lib/keyboard-pan.test.ts +62 -0
  87. package/src/lib/keyboard-pan.ts +89 -0
  88. package/src/lib/material-catalog-model.ts +72 -0
  89. package/src/lib/material-paint-panel-model.ts +76 -0
  90. package/src/lib/overlay-transform.test.ts +28 -0
  91. package/src/lib/overlay-transform.ts +12 -0
  92. package/src/lib/panel-rows.ts +75 -0
  93. package/src/lib/panel-tool-options.test.ts +34 -0
  94. package/src/lib/panel-tool-options.ts +46 -0
  95. package/src/lib/parametric-node-update.ts +37 -0
  96. package/src/lib/scene.test.ts +99 -0
  97. package/src/lib/scene.ts +21 -3
  98. package/src/lib/snapping-mode.test.ts +13 -2
  99. package/src/lib/snapping-mode.ts +11 -6
  100. package/src/lib/spatial-drag-plane.test.ts +53 -0
  101. package/src/lib/spatial-drag-plane.ts +19 -0
  102. package/src/lib/spatial-pointer-input.test.ts +72 -0
  103. package/src/lib/spatial-pointer-input.ts +62 -0
  104. package/src/lib/spatial-snap.test.ts +47 -0
  105. package/src/lib/terrain-panel-model.ts +117 -0
  106. package/src/lib/world-grid-snap.test.ts +48 -0
  107. package/src/lib/world-grid-snap.ts +9 -5
  108. package/src/store/rotation-snapping.test.ts +53 -0
  109. package/src/store/use-editor.tsx +10 -2
  110. package/src/store/use-floorplan-draft-preview.ts +10 -1
  111. package/src/store/use-interaction-scope.ts +155 -6
@@ -1,9 +1,24 @@
1
1
  import { beforeAll, describe, expect, test } from 'bun:test'
2
- import { type AnyNode, type AnyNodeDefinition, nodeRegistry, registerNode } from '@pascal-app/core'
2
+ import {
3
+ type AnyNode,
4
+ type AnyNodeDefinition,
5
+ type AnyNodeId,
6
+ CeilingNode,
7
+ LevelNode,
8
+ nodeRegistry,
9
+ registerNode,
10
+ SlabNode,
11
+ sceneRegistry,
12
+ useScene,
13
+ WallNode,
14
+ } from '@pascal-app/core'
15
+ import { BoxGeometry, Group, Mesh } from 'three'
3
16
  import { z } from 'zod'
4
17
  import {
5
18
  classifyParticipant,
6
19
  collectParticipants,
20
+ computeGroupPlanBox,
21
+ groupPlanBounds,
7
22
  planBoundsCenter,
8
23
  rotateGroupPatches,
9
24
  rotateGroupSnapshots,
@@ -472,3 +487,113 @@ describe('group transform participants', () => {
472
487
  ])
473
488
  })
474
489
  })
490
+
491
+ describe('group plan bounds', () => {
492
+ const withNodes = (nodes: AnyNode[], run: () => void) => {
493
+ const saved = useScene.getState().nodes
494
+ useScene.setState({ nodes: Object.fromEntries(nodes.map((n) => [n.id, n])) as typeof saved })
495
+ try {
496
+ run()
497
+ } finally {
498
+ useScene.setState({ nodes: saved })
499
+ }
500
+ }
501
+
502
+ test('a placed object measures its built mesh in the level frame, or falls back to its anchor', () => {
503
+ const level = new Group()
504
+ level.position.set(10, 0, -4)
505
+ level.rotation.y = Math.PI / 5
506
+ const item = new Mesh(new BoxGeometry(1, 1, 1))
507
+ item.position.set(2, 0.5, 0)
508
+ level.add(item)
509
+ level.updateWorldMatrix(true, true)
510
+ const registry = sceneRegistry.nodes as unknown as Map<string, unknown>
511
+ registry.set('item_plan_bounds_test', item)
512
+ const start = {
513
+ id: 'item_plan_bounds_test' as AnyNodeId,
514
+ kind: 'vec3' as const,
515
+ position: [2, 0, 0] as [number, number, number],
516
+ rotation: [0, 0, 0] as [number, number, number],
517
+ }
518
+ try {
519
+ const frameInv = level.matrixWorld.clone().invert()
520
+ const built = groupPlanBounds([start], frameInv)
521
+ expect(built?.minX).toBeCloseTo(1.5)
522
+ expect(built?.maxX).toBeCloseTo(2.5)
523
+ expect(built?.minZ).toBeCloseTo(-0.5)
524
+ expect(built?.maxZ).toBeCloseTo(0.5)
525
+ // Unbuilt while the 3D scene is paused: the placeholder must not count.
526
+ item.geometry.userData.placeholder = true
527
+ expect(groupPlanBounds([start], frameInv)).toEqual({ minX: 2, minZ: 0, maxX: 2, maxZ: 0 })
528
+ } finally {
529
+ registry.delete('item_plan_bounds_test')
530
+ }
531
+ })
532
+
533
+ test('hugs the selected wall outlines; a connected wall stays outside', () => {
534
+ const level = LevelNode.parse({ children: [] })
535
+ const bottom = WallNode.parse({
536
+ parentId: level.id,
537
+ start: [0, 0],
538
+ end: [2.4, 0],
539
+ thickness: 0.2,
540
+ })
541
+ const side = WallNode.parse({
542
+ parentId: level.id,
543
+ start: [2.4, 0],
544
+ end: [2.4, 5.3],
545
+ thickness: 0.2,
546
+ })
547
+ const neighbour = WallNode.parse({
548
+ parentId: level.id,
549
+ start: [2.4, 5.3],
550
+ end: [8, 5.3],
551
+ thickness: 0.2,
552
+ })
553
+ withNodes([level, bottom, side, neighbour], () => {
554
+ const plan = computeGroupPlanBox([bottom.id, side.id], level.id)
555
+ expect(plan?.minX).toBeCloseTo(0)
556
+ expect(plan?.maxX).toBeCloseTo(2.5)
557
+ expect(plan?.minZ).toBeCloseTo(-0.1)
558
+ expect(plan?.maxZ).toBeLessThan(5.5)
559
+ expect(plan?.maxX).toBeLessThan(8)
560
+ })
561
+ })
562
+
563
+ test('two walls with the room floor and ceiling: the box is the drawn outlines, never the meshes', () => {
564
+ // A user's selection whose box came out 13 m wide in 2D-only view.
565
+ const level = LevelNode.parse({ id: 'level_vvkg048zziz9ktiy', children: [] })
566
+ const room: [number, number][] = [
567
+ [-3.6221682640435353, -9.037533979512114],
568
+ [-6.21504662384804, -8.29671159099654],
569
+ [-5.712152486161543, -6.5365821090938],
570
+ [-5.712152486161543, -5],
571
+ [-8.223564365687542, -5],
572
+ [-8.223564365687542, -6.5365821090938],
573
+ [-10.712152486161543, -6.5365821090938],
574
+ [-11.712152486161543, -8.5365821090938],
575
+ [-4.119274126357038, -10.777404497609373],
576
+ ]
577
+ const wall15 = WallNode.parse({
578
+ parentId: level.id,
579
+ start: [-10.712152486161543, -6.5365821090938],
580
+ end: [-11.712152486161543, -8.5365821090938],
581
+ })
582
+ const wall12 = WallNode.parse({
583
+ parentId: level.id,
584
+ start: [-11.712152486161543, -8.5365821090938],
585
+ end: [-6.712152486161543, -10.0365821090938],
586
+ })
587
+ const slab = SlabNode.parse({ parentId: level.id, polygon: room, elevation: 0.05 })
588
+ const ceiling = CeilingNode.parse({ parentId: level.id, polygon: room, height: 2.49 })
589
+ withNodes([level, wall15, wall12, slab, ceiling], () => {
590
+ const plan = computeGroupPlanBox([wall15.id, ceiling.id, slab.id, wall12.id], level.id)!
591
+ expect(plan.minX).toBeLessThan(-11.71)
592
+ expect(plan.minX).toBeGreaterThan(-11.95)
593
+ expect(plan.maxX).toBeCloseTo(-3.62, 1)
594
+ expect(plan.minZ).toBeCloseTo(-10.78, 1)
595
+ expect(plan.maxZ).toBeCloseTo(-5, 1)
596
+ expect(plan.maxX - plan.minX).toBeLessThan(8.4)
597
+ })
598
+ })
599
+ })
@@ -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}
@@ -17,6 +17,8 @@ import { useEffect, useRef } from 'react'
17
17
  import { type Camera, type Object3D, type Plane, type Ray, Vector2, type Vector3 } from 'three'
18
18
  import { isHistoryShortcut } from '../../../lib/history'
19
19
  import { sfxEmitter } from '../../../lib/sfx-bus'
20
+ import { intersectSpatialDragPlane } from '../../../lib/spatial-drag-plane'
21
+ import { getSpatialPointerId, spatialPointerInput } from '../../../lib/spatial-pointer-input'
20
22
  import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state'
21
23
  import { commitHandleDragPatch } from './handle-drag-history'
22
24
 
@@ -58,6 +60,7 @@ type HandleDragSession = {
58
60
  commit?: (patch: Partial<AnyNode>) => void
59
61
  markDirty?: boolean
60
62
  onBegin?: () => void
63
+ onCancel?: () => void
61
64
  onEnd?: () => void
62
65
  overrideId?: AnyNodeId
63
66
  }
@@ -107,6 +110,7 @@ function suppressInputDraggingUntilPointerRelease(pointerId: number) {
107
110
  window.addEventListener('pointerup', restore)
108
111
  window.addEventListener('pointercancel', restore)
109
112
  window.addEventListener('blur', onBlur)
113
+ return () => restore()
110
114
  }
111
115
 
112
116
  export function useHandleDrag(args: UseHandleDragArgs) {
@@ -121,9 +125,17 @@ export function useHandleDrag(args: UseHandleDragArgs) {
121
125
  if (event.button !== 0) return
122
126
  event.stopPropagation()
123
127
  suppressBoxSelectForPointer(event)
128
+ const spatialPointerId = getSpatialPointerId(event.nativeEvent)
124
129
 
125
130
  if (args.kind === 'tap') {
126
- suppressInputDraggingUntilPointerRelease(event.nativeEvent.pointerId)
131
+ const restoreInputDragging = suppressInputDraggingUntilPointerRelease(event.pointerId)
132
+ if (spatialPointerId) {
133
+ spatialPointerInput.capture(spatialPointerId, {
134
+ onMove: () => undefined,
135
+ onRelease: restoreInputDragging,
136
+ onCancel: restoreInputDragging,
137
+ })
138
+ }
127
139
  swallowNextClick()
128
140
  sfxEmitter.emit('sfx:item-pick')
129
141
  document.body.style.cursor = ''
@@ -133,6 +145,7 @@ export function useHandleDrag(args: UseHandleDragArgs) {
133
145
 
134
146
  const { cursor, dragControls, handleIndex, node, rideObject, setIsDragging } = args
135
147
  rideObject.updateMatrixWorld()
148
+ const spatialRay = spatialPointerId ? event.ray.clone() : null
136
149
 
137
150
  const ndc = new Vector2()
138
151
  const setPointerRay = (clientX: number, clientY: number) => {
@@ -144,10 +157,12 @@ export function useHandleDrag(args: UseHandleDragArgs) {
144
157
  raycaster.setFromCamera(ndc, camera)
145
158
  }
146
159
  const getPointerRay: GetPointerRay = (clientX, clientY, target) => {
160
+ if (spatialRay) return target.copy(spatialRay)
147
161
  setPointerRay(clientX, clientY)
148
162
  return target.copy(raycaster.ray)
149
163
  }
150
164
  const intersectPlane: IntersectPlane = (clientX, clientY, plane, target) => {
165
+ if (spatialRay) return intersectSpatialDragPlane(spatialRay, plane, target)
151
166
  setPointerRay(clientX, clientY)
152
167
  return raycaster.ray.intersectPlane(plane, target)
153
168
  }
@@ -180,7 +195,11 @@ export function useHandleDrag(args: UseHandleDragArgs) {
180
195
 
181
196
  let lastPatch: Partial<AnyNode> | null = null
182
197
  let historyPaused = true
198
+ let releaseSpatialCapture: (() => void) | null = null
183
199
  let altKey = event.nativeEvent.altKey
200
+ let shiftKey = event.nativeEvent.shiftKey
201
+ let pendingMoveEvent: PointerEvent | null = null
202
+ let moveFrame = 0
184
203
 
185
204
  const resumeHistory = () => {
186
205
  if (!historyPaused) return
@@ -188,10 +207,10 @@ export function useHandleDrag(args: UseHandleDragArgs) {
188
207
  useScene.temporal.getState().resume()
189
208
  }
190
209
 
191
- const onMove = (moveEvent: PointerEvent) => {
210
+ const processMove = (moveEvent: PointerEvent) => {
192
211
  const patch = session.move({
193
212
  event: moveEvent,
194
- modifiers: { altKey },
213
+ modifiers: { altKey, shiftKey },
195
214
  getPointerRay,
196
215
  intersectPlane,
197
216
  })
@@ -204,12 +223,35 @@ export function useHandleDrag(args: UseHandleDragArgs) {
204
223
  }
205
224
  }
206
225
 
226
+ // Coalesce high-frequency pointer events so live React/store updates happen
227
+ // at most once per animation frame while retaining the newest position.
228
+ const flushMove = () => {
229
+ moveFrame = 0
230
+ const moveEvent = pendingMoveEvent
231
+ pendingMoveEvent = null
232
+ if (moveEvent) processMove(moveEvent)
233
+ }
234
+ const onMove = (moveEvent: PointerEvent) => {
235
+ pendingMoveEvent = moveEvent
236
+ if (moveFrame !== 0) return
237
+ if (typeof window.requestAnimationFrame === 'function') {
238
+ moveFrame = window.requestAnimationFrame(flushMove)
239
+ } else {
240
+ flushMove()
241
+ }
242
+ }
243
+
207
244
  const cleanup = () => {
245
+ if (moveFrame !== 0) window.cancelAnimationFrame?.(moveFrame)
246
+ moveFrame = 0
247
+ pendingMoveEvent = null
208
248
  window.removeEventListener('pointermove', onMove)
209
249
  window.removeEventListener('pointerup', onUp)
210
250
  window.removeEventListener('pointercancel', onCancel)
211
251
  window.removeEventListener('keydown', onKeyDown, true)
212
252
  window.removeEventListener('keyup', onKeyUp, true)
253
+ releaseSpatialCapture?.()
254
+ releaseSpatialCapture = null
213
255
  if (document.body.style.cursor === cursor) {
214
256
  document.body.style.cursor = ''
215
257
  }
@@ -230,6 +272,8 @@ export function useHandleDrag(args: UseHandleDragArgs) {
230
272
  }
231
273
 
232
274
  const onUp = () => {
275
+ if (moveFrame !== 0) window.cancelAnimationFrame?.(moveFrame)
276
+ flushMove()
233
277
  swallowNextClick()
234
278
  sfxEmitter.emit('sfx:item-place')
235
279
  if (lastPatch) {
@@ -245,6 +289,7 @@ export function useHandleDrag(args: UseHandleDragArgs) {
245
289
  }
246
290
 
247
291
  const onCancel = () => {
292
+ session.onCancel?.()
248
293
  clearOverride()
249
294
  cleanup()
250
295
  }
@@ -256,6 +301,10 @@ export function useHandleDrag(args: UseHandleDragArgs) {
256
301
  altKey = true
257
302
  return
258
303
  }
304
+ if (e.key === 'Shift') {
305
+ shiftKey = true
306
+ return
307
+ }
259
308
  if (e.key !== 'Escape' && !isHistoryShortcut(e)) return
260
309
  e.preventDefault()
261
310
  e.stopPropagation()
@@ -264,12 +313,32 @@ export function useHandleDrag(args: UseHandleDragArgs) {
264
313
  }
265
314
  const onKeyUp = (e: KeyboardEvent) => {
266
315
  if (e.key === 'Alt') altKey = false
316
+ if (e.key === 'Shift') shiftKey = false
267
317
  }
268
318
 
269
319
  dragCleanupRef.current = onCancel
270
- window.addEventListener('pointermove', onMove)
271
- window.addEventListener('pointerup', onUp)
272
- window.addEventListener('pointercancel', onCancel)
320
+ if (spatialPointerId && spatialRay) {
321
+ releaseSpatialCapture = spatialPointerInput.capture(spatialPointerId, {
322
+ onMove: (ray) => {
323
+ spatialRay.copy(ray)
324
+ processMove(
325
+ new PointerEvent('pointermove', {
326
+ button: 0,
327
+ buttons: 1,
328
+ altKey,
329
+ pointerId: event.pointerId,
330
+ pointerType: 'xr',
331
+ }),
332
+ )
333
+ },
334
+ onRelease: onUp,
335
+ onCancel,
336
+ })
337
+ } else {
338
+ window.addEventListener('pointermove', onMove)
339
+ window.addEventListener('pointerup', onUp)
340
+ window.addEventListener('pointercancel', onCancel)
341
+ }
273
342
  window.addEventListener('keydown', onKeyDown, true)
274
343
  window.addEventListener('keyup', onKeyUp, true)
275
344
  }