@pascal-app/editor 1.0.0 → 1.0.1

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 (97) hide show
  1. package/package.json +7 -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/floating-action-menu.tsx +14 -1
  5. package/src/components/editor/floorplan-panel.tsx +23 -3
  6. package/src/components/editor/handles/handle-placement.test.ts +111 -0
  7. package/src/components/editor/handles/handle-placement.ts +69 -0
  8. package/src/components/editor/handles/linear-resize-drag.ts +13 -2
  9. package/src/components/editor/handles/use-handle-drag.ts +5 -2
  10. package/src/components/editor/index.tsx +12 -4
  11. package/src/components/editor/node-arrow-handles.tsx +21 -62
  12. package/src/components/editor/selection-manager.tsx +49 -0
  13. package/src/components/editor/thumbnail-generator.tsx +11 -2
  14. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
  15. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +46 -3
  16. package/src/components/procedural-items/inspector.tsx +223 -0
  17. package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
  18. package/src/components/tools/item/item-surface-placement.test.ts +175 -0
  19. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +210 -0
  20. package/src/components/tools/item/placement-strategies.ts +169 -169
  21. package/src/components/tools/item/placement-types.ts +4 -0
  22. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
  23. package/src/components/tools/item/use-draft-node.ts +95 -45
  24. package/src/components/tools/item/use-placement-coordinator.tsx +247 -106
  25. package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
  26. package/src/components/tools/registry/item-surface-move.test.ts +501 -0
  27. package/src/components/tools/registry/item-surface-move.ts +441 -0
  28. package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
  29. package/src/components/tools/registry/move-registry-node-tool.tsx +215 -70
  30. package/src/components/tools/shared/pointer-support-cap.ts +11 -9
  31. package/src/components/tools/shared/shelf-stickiness.ts +54 -0
  32. package/src/components/tools/shared/surface-hit.test.ts +56 -0
  33. package/src/components/tools/shared/surface-hit.ts +23 -0
  34. package/src/components/tools/shared/surface-rejection.test.tsx +106 -0
  35. package/src/components/tools/shared/surface-rejection.tsx +29 -0
  36. package/src/components/tools/zone/zone-tool.tsx +13 -4
  37. package/src/components/ui/command-palette/editor-commands.tsx +54 -1
  38. package/src/components/ui/controls/metric-control.tsx +29 -13
  39. package/src/components/ui/controls/slider-control.tsx +25 -18
  40. package/src/components/ui/floating-level-selector.tsx +81 -5
  41. package/src/components/ui/panels/multi-parametric-inspector.tsx +6 -3
  42. package/src/components/ui/panels/parametric-field-control.tsx +1 -1
  43. package/src/components/ui/panels/parametric-inspector.tsx +6 -2
  44. package/src/components/ui/panels/reference-panel.tsx +3 -3
  45. package/src/components/ui/primitives/checkbox.tsx +34 -0
  46. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +120 -3
  47. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +31 -4
  48. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +29 -10
  49. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
  50. package/src/components/ui/sidebar/panels/site-panel/index.tsx +106 -4
  51. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
  52. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
  53. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +6 -1
  54. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
  55. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
  56. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
  57. package/src/components/ui/snap-target-badge.tsx +2 -0
  58. package/src/components/viewer/viewer-scene-header.tsx +3 -0
  59. package/src/components/viewer/viewer-units-panel.tsx +116 -0
  60. package/src/hooks/use-ceiling-events.test.ts +24 -2
  61. package/src/hooks/use-ceiling-events.ts +5 -1
  62. package/src/hooks/use-keyboard.ts +3 -1
  63. package/src/index.tsx +5 -0
  64. package/src/lib/export-texture-utils.test.ts +167 -0
  65. package/src/lib/export-texture-utils.ts +121 -0
  66. package/src/lib/export-user-data.test.ts +48 -0
  67. package/src/lib/export-user-data.ts +69 -0
  68. package/src/lib/floorplan/floorplan-export.test.ts +139 -1
  69. package/src/lib/floorplan/floorplan-export.tsx +9 -3
  70. package/src/lib/floorplan/floorplan-readonly.ts +4 -0
  71. package/src/lib/fresh-planar-placement.ts +13 -5
  72. package/src/lib/glb-export-serialize.test.ts +280 -0
  73. package/src/lib/glb-export.test.ts +206 -0
  74. package/src/lib/glb-export.ts +171 -24
  75. package/src/lib/level-duplication.test.ts +73 -0
  76. package/src/lib/level-duplication.ts +32 -2
  77. package/src/lib/linear-display.test.ts +59 -0
  78. package/src/lib/linear-display.ts +39 -0
  79. package/src/lib/local-guide-image.test.ts +24 -0
  80. package/src/lib/local-guide-image.ts +34 -1
  81. package/src/lib/node-action-movement.ts +21 -0
  82. package/src/lib/portable-export.test.ts +89 -0
  83. package/src/lib/portable-export.ts +84 -11
  84. package/src/lib/print-shell-compiler-manifold-core.ts +59 -10
  85. package/src/lib/print-shell-compiler-manifold-worker.ts +14 -1
  86. package/src/lib/print-shell-compiler-manifold.worker.ts +1 -1
  87. package/src/lib/print-shell-compiler-protocol.ts +8 -0
  88. package/src/lib/scene-clipboard.ts +1 -1
  89. package/src/lib/send-to-app.test.ts +108 -0
  90. package/src/lib/send-to-app.ts +121 -0
  91. package/src/lib/sfx/movement-tick.ts +37 -0
  92. package/src/lib/surface-attachment.ts +77 -0
  93. package/src/lib/units.ts +254 -0
  94. package/src/lib/usdz-export.ts +43 -16
  95. package/src/lib/use-linear-display.ts +8 -32
  96. package/src/store/use-handle-group.ts +4 -0
  97. package/src/store/use-send-to-app.ts +21 -0
package/package.json CHANGED
@@ -1,20 +1,21 @@
1
1
  {
2
2
  "name": "@pascal-app/editor",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Pascal building editor component",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "exports": {
8
8
  ".": "./src/index.tsx",
9
- "./catalog": "./src/components/ui/item-catalog/catalog-items.tsx"
9
+ "./catalog": "./src/components/ui/item-catalog/catalog-items.tsx",
10
+ "./procedural-items": "./src/components/procedural-items/inspector.tsx"
10
11
  },
11
12
  "scripts": {
12
13
  "check-types": "tsgo --noEmit",
13
14
  "test": "bun test src"
14
15
  },
15
16
  "peerDependencies": {
16
- "@pascal-app/core": "^1.0.0",
17
- "@pascal-app/viewer": "^1.0.0",
17
+ "@pascal-app/core": "^1.0.1",
18
+ "@pascal-app/viewer": "^1.0.1",
18
19
  "@react-three/drei": "^10",
19
20
  "@react-three/fiber": "^9",
20
21
  "next": ">=15",
@@ -61,8 +62,8 @@
61
62
  "zustand": "^5.0.11"
62
63
  },
63
64
  "devDependencies": {
64
- "@pascal-app/core": "^1.0.0",
65
- "@pascal-app/viewer": "^1.0.0",
65
+ "@pascal-app/core": "^1.0.1",
66
+ "@pascal-app/viewer": "^1.0.1",
66
67
  "@pascal/typescript-config": "*",
67
68
  "@react-three/test-renderer": "^9.1.0",
68
69
  "@types/blob-stream": "^0.1.33",
@@ -32,6 +32,10 @@ export function BakeExporter({
32
32
  const buffer = await exportSceneToGlb(sceneGroup, useScene.getState().nodes, {
33
33
  textures: 'reference',
34
34
  purpose: 'viewer',
35
+ // The bake worker owns the deadline (BAKE_CAPTURE_TIMEOUT_MS); under
36
+ // SwiftShader a moderate scene can legitimately outlast the
37
+ // interactive default.
38
+ timeoutMs: Number.POSITIVE_INFINITY,
35
39
  })
36
40
  onComplete(buffer)
37
41
  } catch (err) {
@@ -6,6 +6,7 @@ import {
6
6
  GRID_LAYER,
7
7
  heroCameraPose,
8
8
  temporarilyHideNodeTypes,
9
+ useSceneAtmosphere,
9
10
  useViewer,
10
11
  } from '@pascal-app/viewer'
11
12
  import { useThree } from '@react-three/fiber'
@@ -25,6 +26,7 @@ export function BakeThumbnail({
25
26
  }) {
26
27
  const renderer = useThree((state) => state.gl)
27
28
  const scene = useThree((state) => state.scene)
29
+ const atmosphere = useSceneAtmosphere()
28
30
  const doneRef = useRef(false)
29
31
 
30
32
  useEffect(() => {
@@ -61,6 +63,7 @@ export function BakeThumbnail({
61
63
  renderer: renderer as unknown as WebGPURenderer,
62
64
  scene,
63
65
  camera,
66
+ atmosphere,
64
67
  })
65
68
  if (!pipeline) {
66
69
  onError('thumbnail pipeline failed to build')
@@ -89,7 +92,7 @@ export function BakeThumbnail({
89
92
  }
90
93
 
91
94
  void run()
92
- }, [active, onComplete, onError, renderer, scene])
95
+ }, [active, atmosphere, onComplete, onError, renderer, scene])
93
96
 
94
97
  return null
95
98
  }
@@ -51,6 +51,7 @@ import {
51
51
  } from '../../lib/fresh-planar-placement'
52
52
  import { resolveFloatingActionMenuVisibility } from '../../lib/interaction/overlay-policy'
53
53
  import { curveReshapeScope, holeEditScope } from '../../lib/interaction/scope'
54
+ import { duplicateWithoutMove, registryMoveDisabled } from '../../lib/node-action-movement'
54
55
  import { playBlockedQuickActionFeedback } from '../../lib/quick-action-feedback'
55
56
  import { collectQuickActionNodeScope } from '../../lib/quick-action-nodes'
56
57
  import { duplicateRoofSubtree } from '../../lib/roof-duplication'
@@ -501,6 +502,16 @@ export function FloatingActionMenu() {
501
502
  if (!node?.parentId) return
502
503
  sfxEmitter.emit('sfx:item-pick')
503
504
 
505
+ if (registryMoveDisabled(node)) {
506
+ try {
507
+ const id = duplicateWithoutMove(node)
508
+ if (id) setSelection({ selectedIds: [id] })
509
+ } catch (error) {
510
+ console.error('Failed to duplicate node', error)
511
+ }
512
+ return
513
+ }
514
+
504
515
  if (node.type === 'roof') {
505
516
  try {
506
517
  duplicateRoofSubtree(node.id as AnyNodeId, { mode: 'move' })
@@ -805,7 +816,9 @@ export function FloatingActionMenu() {
805
816
  // `capabilities.movable`, a `floorplanMoveTarget`, or a
806
817
  // 3D `affordanceTools.move` mover gets the Move button.
807
818
  // Adding a new movable kind never touches this file.
808
- node && isRegistryMovable(node.type) ? handleMove : undefined
819
+ node && isRegistryMovable(node.type) && !registryMoveDisabled(node)
820
+ ? handleMove
821
+ : undefined
809
822
  }
810
823
  onDelete={handleDelete}
811
824
  onDuplicate={
@@ -34,6 +34,7 @@ import {
34
34
  type RoofSegmentNode,
35
35
  resolveSlabPlacementElevation,
36
36
  resolveTerrainWallConstructionOptions,
37
+ runAsSingleSceneHistoryStep,
37
38
  type SiteNode,
38
39
  type SlabNode,
39
40
  SlabNode as SlabNodeSchema,
@@ -98,6 +99,7 @@ import { formatLinearMeasurement, linearUnitToMeters } from '../../lib/measureme
98
99
  import { sfxEmitter } from '../../lib/sfx-bus'
99
100
  import { SITE_BOUNDARY_DRAG_LABEL, siteBoundaryHandlesEnabled } from '../../lib/site-boundary'
100
101
  import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap'
102
+ import { cancelPendingZonePaint, focusedUnitNode, paintZoneMembership } from '../../lib/units'
101
103
  import { cn } from '../../lib/utils'
102
104
  import { snapBuildingLocalToWorldGrid } from '../../lib/world-grid-snap'
103
105
  import { subscribeNavigationSyncPose } from '../../store/navigation-sync-pose-store'
@@ -7952,7 +7954,7 @@ export function FloorplanPanel({
7952
7954
  return null
7953
7955
  }
7954
7956
 
7955
- const { createNode, nodes } = useScene.getState()
7957
+ const { createNode, updateNode, nodes } = useScene.getState()
7956
7958
  const zoneCount = Object.values(nodes).filter((node) => node.type === 'zone').length
7957
7959
  const zone = ZoneNodeSchema.parse({
7958
7960
  color: PALETTE_COLORS[zoneCount % PALETTE_COLORS.length],
@@ -7960,9 +7962,15 @@ export function FloorplanPanel({
7960
7962
  polygon: points.map(([x, z]) => [x, z] as [number, number]),
7961
7963
  })
7962
7964
 
7963
- createNode(zone, levelId)
7965
+ // Joining the focused unit rides in the zone's own undo step; selecting
7966
+ // the zone would end focus, so a painted unit keeps it instead.
7967
+ const focusedUnit = focusedUnitNode()
7968
+ runAsSingleSceneHistoryStep(useScene, () => {
7969
+ createNode(zone, levelId)
7970
+ if (focusedUnit) updateNode(focusedUnit.id, { members: [...focusedUnit.members, zone.id] })
7971
+ })
7964
7972
  sfxEmitter.emit('sfx:structure-build')
7965
- setSelection({ zoneId: zone.id })
7973
+ if (!focusedUnit) setSelection({ zoneId: zone.id })
7966
7974
  return zone.id
7967
7975
  },
7968
7976
  [levelId, setSelection],
@@ -10008,6 +10016,18 @@ export function FloorplanPanel({
10008
10016
  setSelectedReferenceId(null)
10009
10017
 
10010
10018
  if (backgroundSelection.kind === 'select-zone') {
10019
+ // Unit focus: a click paints membership, a double-click selects the
10020
+ // zone and keeps focus (the SVG gets the second click as detail 2).
10021
+ const focusedUnitId = useViewer.getState().focusedUnitId
10022
+ if (focusedUnitId) {
10023
+ if (event.detail >= 2) {
10024
+ cancelPendingZonePaint(backgroundSelection.zoneId)
10025
+ setSelection({ zoneId: backgroundSelection.zoneId })
10026
+ } else {
10027
+ paintZoneMembership(focusedUnitId, backgroundSelection.zoneId)
10028
+ }
10029
+ return
10030
+ }
10011
10031
  setSelection({ zoneId: backgroundSelection.zoneId })
10012
10032
  return
10013
10033
  }
@@ -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
  },
@@ -4,6 +4,7 @@ import {
4
4
  type AnyNode,
5
5
  type AnyNodeId,
6
6
  type Cursor,
7
+ cascadeDirty,
7
8
  createSceneApi,
8
9
  type HandleDragModifiers,
9
10
  runAsSingleSceneHistoryStep,
@@ -198,7 +199,8 @@ export function useHandleDrag(args: UseHandleDragArgs) {
198
199
  lastPatch = patch
199
200
  useLiveNodeOverrides.getState().set(overrideId, patch as Record<string, unknown>)
200
201
  if (markDirty) {
201
- useScene.getState().markDirty(overrideId)
202
+ for (const id of cascadeDirty(overrideId, { scene: createSceneApi(useScene) }))
203
+ useScene.getState().markDirty(id)
202
204
  }
203
205
  }
204
206
 
@@ -222,7 +224,8 @@ export function useHandleDrag(args: UseHandleDragArgs) {
222
224
  const clearOverride = () => {
223
225
  useLiveNodeOverrides.getState().clear(overrideId)
224
226
  if (markDirty) {
225
- useScene.getState().markDirty(overrideId)
227
+ for (const id of cascadeDirty(overrideId, { scene: createSceneApi(useScene) }))
228
+ useScene.getState().markDirty(id)
226
229
  }
227
230
  }
228
231
 
@@ -49,6 +49,7 @@ import {
49
49
  writePersistedSelection,
50
50
  } from '../../lib/scene'
51
51
  import { disposeSFXBus, initSFXBus } from '../../lib/sfx-bus'
52
+ import { useUnitFocusRules } from '../../lib/units'
52
53
  import { type CameraHintAction, useCameraHintFocus } from '../../store/use-camera-hint-focus'
53
54
  import useEditor from '../../store/use-editor'
54
55
  import useFloorplanMode from '../../store/use-floorplan-mode'
@@ -1041,6 +1042,10 @@ const ViewerCanvas = memo(function ViewerCanvas({
1041
1042
  const setFloorplanPaneRatio = useEditor((s) => s.setFloorplanPaneRatio)
1042
1043
  const isPreviewMode = useEditor((s) => s.isPreviewMode)
1043
1044
  const isCaptureMode = useEditor((s) => s.isCaptureMode)
1045
+ useUnitFocusRules()
1046
+ const presetIsolation = useEditor((s) =>
1047
+ s.captureMode.mode === 'preset' ? s.captureMode.isolated : null,
1048
+ )
1044
1049
 
1045
1050
  const [isCameraControlsHintVisible, setIsCameraControlsHintVisible] = useState<boolean | null>(
1046
1051
  null,
@@ -1151,6 +1156,10 @@ const ViewerCanvas = memo(function ViewerCanvas({
1151
1156
  defaultRender={EDITOR_DEFAULT_RENDER}
1152
1157
  disablePostFx={disablePostFx}
1153
1158
  hoverStyles={EDITOR_HOVER_STYLES}
1159
+ isolate={presetIsolation}
1160
+ // Preset captures isolate one subtree and keep the exterior transparent.
1161
+ // Other modes retain the viewer's configured background policy.
1162
+ transparent={presetIsolation === null ? undefined : true}
1154
1163
  onSceneReadyChange={onSceneReadyChange}
1155
1164
  renderContext="editor"
1156
1165
  renderPaused={!show3d && !showLoader}
@@ -1347,10 +1356,9 @@ function EditorContent({
1347
1356
 
1348
1357
  // Load on mount, project switches, and explicit retry attempts.
1349
1358
  useEffect(() => {
1350
- void sceneLoadAttempt
1351
1359
  let cancelled = false
1352
1360
 
1353
- async function load() {
1361
+ async function load(attempt: number) {
1354
1362
  isLoadingSceneRef.current = true
1355
1363
  setSceneLoadError(null)
1356
1364
  setHasLoadedInitialScene(false)
@@ -1364,7 +1372,7 @@ function EditorContent({
1364
1372
  let failed = false
1365
1373
  try {
1366
1374
  const sceneGraph = onLoad ? await onLoad() : loadSceneFromLocalStorage()
1367
- if (!cancelled) {
1375
+ if (!cancelled && attempt === sceneLoadAttempt) {
1368
1376
  applySceneGraphToEditor(sceneGraph)
1369
1377
  setIsViewerSceneReady(false)
1370
1378
  setSceneReadyKey((key) => key + 1)
@@ -1390,7 +1398,7 @@ function EditorContent({
1390
1398
  }
1391
1399
  }
1392
1400
 
1393
- load()
1401
+ load(sceneLoadAttempt)
1394
1402
 
1395
1403
  return () => {
1396
1404
  cancelled = true
@@ -47,6 +47,7 @@ import { createEditorApi } from '../../lib/editor-api'
47
47
  import { sfxEmitter } from '../../lib/sfx-bus'
48
48
  import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback'
49
49
  import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../store/use-editor'
50
+ import { useHandleGroup } from '../../store/use-handle-group'
50
51
  import useInteractionScope, {
51
52
  useEndpointReshape,
52
53
  useIsCurveReshape,
@@ -62,7 +63,12 @@ import {
62
63
  HandleArrow,
63
64
  NO_RAYCAST,
64
65
  } from './handles/handle-arrow'
65
- import { createLinearResizeDragBinding } from './handles/linear-resize-drag'
66
+ import {
67
+ computeFreezeOffset,
68
+ resolveLinearHandlePosition,
69
+ resolveLinearHandleRotation,
70
+ } from './handles/handle-placement'
71
+ import { createLinearResizeDragBinding, linearResizeFactor } from './handles/linear-resize-drag'
66
72
  import { resolveResizeSnapValue } from './handles/resize-snap'
67
73
  import { type HandleDragControls, useHandleDrag } from './handles/use-handle-drag'
68
74
 
@@ -227,6 +233,9 @@ export function NodeArrowHandles() {
227
233
  () => (rawNode && liveOverride ? ({ ...rawNode, ...liveOverride } as AnyNode) : rawNode),
228
234
  [rawNode, liveOverride],
229
235
  )
236
+ const activeGroup = useHandleGroup((s) =>
237
+ s.active && s.active.nodeId === node?.id ? s.active.group : null,
238
+ )
230
239
  const def = node ? nodeRegistry.get(node.type) : null
231
240
  const descriptorSceneApi = useMemo(() => createSceneApi(useScene), [])
232
241
  const descriptors = useMemo(() => {
@@ -236,6 +245,8 @@ export function NodeArrowHandles() {
236
245
  ? def.handles(node as never, descriptorSceneApi)
237
246
  : (def.handles as HandleDescriptor[])
238
247
  return all.filter((descriptor) => {
248
+ if (activeGroup)
249
+ return descriptor.kind === 'linear-resize' && descriptor.latchGroup === activeGroup
239
250
  if (descriptor.kind === 'translate') return false
240
251
  const visible =
241
252
  'visible' in descriptor
@@ -244,7 +255,7 @@ export function NodeArrowHandles() {
244
255
  if ('shape' in descriptor && descriptor.shape === 'move-cross') return visible === true
245
256
  return visible !== false
246
257
  })
247
- }, [node, def, descriptorSceneApi])
258
+ }, [node, def, descriptorSceneApi, activeGroup])
248
259
 
249
260
  const shouldRender =
250
261
  Boolean(node && descriptors?.length) &&
@@ -291,6 +302,9 @@ function NodeArrowHandlesForNode({
291
302
  node: AnyNode
292
303
  descriptors: HandleDescriptor[]
293
304
  }) {
305
+ const controlledGroup = useHandleGroup((s) =>
306
+ s.active?.nodeId === node.id ? s.active.group : null,
307
+ )
294
308
  const parentId = node.parentId ?? null
295
309
 
296
310
  const portalMode: HandlePortal = descriptors.some((d) => d.portal === 'grandparent')
@@ -476,7 +490,8 @@ function NodeArrowHandlesForNode({
476
490
  }
477
491
  // Arrows tagged with a latch group stay hidden until that group is open.
478
492
  const latchGroup = descriptor.kind === 'linear-resize' ? descriptor.latchGroup : undefined
479
- if (latchGroup && !openLatchGroups.has(latchGroup)) return null
493
+ if (latchGroup && latchGroup !== controlledGroup && !openLatchGroups.has(latchGroup))
494
+ return null
480
495
  return (
481
496
  <ArrowHandle
482
497
  activeIndex={activeIndex}
@@ -502,39 +517,6 @@ function NodeArrowHandlesForNode({
502
517
  )
503
518
  }
504
519
 
505
- // Offset, in node-local frame, that compensates for `position` drift on
506
- // the mesh during an asymmetric resize. Width/length L+R recompute
507
- // `position` so the anchored edge stays world-fixed — the renderer
508
- // follows that override, the ride object moves, and every arrow under
509
- // it would drift along with the mesh center. Subtracting this offset
510
- // from a non-active arrow's local placement undoes that drift so it
511
- // stays at its pre-drag world position.
512
- //
513
- // Rotation drags don't change `position`, so the offset collapses to
514
- // zero and non-active arrows naturally rotate with the mesh — which is
515
- // the desired behaviour (the whole rig rotates as a unit).
516
- function computeFreezeOffset(liveNode: AnyNode, preDragNode: AnyNode): [number, number, number] {
517
- // Not every node in the union carries a `position` field (sites are the
518
- // notable holdout — they don't have handles anyway, but TypeScript still
519
- // requires us to discriminate). Guarded access keeps the freeze logic
520
- // safe for the few node kinds that lack the field.
521
- const liveP = (liveNode as { position?: readonly [number, number, number] }).position ?? [0, 0, 0]
522
- const preP = (preDragNode as { position?: readonly [number, number, number] }).position ?? [
523
- 0, 0, 0,
524
- ]
525
- const deltaWorldX = liveP[0] - preP[0]
526
- const deltaWorldY = liveP[1] - preP[1]
527
- const deltaWorldZ = liveP[2] - preP[2]
528
- const rotY = (preDragNode as { rotation?: number }).rotation ?? 0
529
- // World → node-local for Y-axis rotation by rotY (THREE.Object3D
530
- // rotation-y convention): inverse is rotation by -rotY around +Y.
531
- const cosR = Math.cos(rotY)
532
- const sinR = Math.sin(rotY)
533
- const deltaLocalX = cosR * deltaWorldX - sinR * deltaWorldZ
534
- const deltaLocalZ = sinR * deltaWorldX + cosR * deltaWorldZ
535
- return [deltaLocalX, deltaWorldY, deltaLocalZ]
536
- }
537
-
538
520
  function ArrowHandle({
539
521
  descriptor,
540
522
  liveNode,
@@ -666,7 +648,7 @@ function LinearArrow({
666
648
  // for the edge being resized); cleared when the drag ends.
667
649
  const onDrag = descriptor.kind === 'linear-resize' ? descriptor.onDrag : undefined
668
650
  const placementSceneApi = useMemo(() => createSceneApi(useScene), [])
669
- const basePosition = descriptor.placement.position(node, placementSceneApi)
651
+ const basePosition = resolveLinearHandlePosition(descriptor, node, placementSceneApi, baseScale)
670
652
  // `freezeOffset` (in node-local frame) cancels the mesh's `position`
671
653
  // drift while another arrow is being dragged — `basePosition` is
672
654
  // computed against the pre-drag snapshot, then we subtract the offset
@@ -730,14 +712,7 @@ function LinearArrow({
730
712
  const initialValue = descriptor.currentValue(initialNode)
731
713
  const minBound = resolveBound(descriptor.min, Number.NEGATIVE_INFINITY, initialNode, sceneApi)
732
714
  const maxBound = resolveBound(descriptor.max, Number.POSITIVE_INFINITY, initialNode, sceneApi)
733
- const factor =
734
- descriptor.kind === 'radial-resize'
735
- ? 1
736
- : descriptor.anchor === 'center'
737
- ? 2
738
- : descriptor.anchor === 'min'
739
- ? 1
740
- : -1
715
+ const factor = linearResizeFactor(descriptor)
741
716
 
742
717
  // Last value an emitted resize tick fired at — a new tick fires only
743
718
  // when the (snapped + clamped) value actually changes, so the cue
@@ -806,23 +781,7 @@ function LinearArrow({
806
781
  },
807
782
  })
808
783
 
809
- // For axis === 'y' (vertical handles), tilt the chevron up via local
810
- // X+Z rotation chain matching DoorHeightArrowHandle. When the handle
811
- // sits below the node (placement Y < 0, e.g. window bottom arrow),
812
- // flip the Z rotation so the chevron points outward (downward).
813
- //
814
- // For axis === 'x' with `faceNormal` (wall-mounted opening width arrows),
815
- // roll the blade 90° about its own pointing (X) axis so it stands up from
816
- // the horizontal XZ plane into the node's facing plane (XY = the wall
817
- // face) — otherwise the blade is seen edge-on from the front.
818
- const faceNormalX =
819
- descriptor.kind === 'linear-resize' && descriptor.axis === 'x' && descriptor.faceNormal === true
820
- const innerRotation: [number, number, number] =
821
- descriptor.axis === 'y'
822
- ? [0, Math.PI / 2, position[1] < 0 ? -Math.PI / 2 : Math.PI / 2]
823
- : faceNormalX
824
- ? [Math.PI / 2, 0, 0]
825
- : [0, 0, 0]
784
+ const innerRotation = resolveLinearHandleRotation(descriptor, position)
826
785
 
827
786
  // Optional guide decoration — linear handles use it for curved-stair
828
787
  // width / inner-radius rings; radial handles use it for the column's