@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
@@ -42,7 +42,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
42
42
  import { type Group, Vector3 } from 'three'
43
43
  import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
44
44
  import { commitFreshPlacementSubtree } from '../../../lib/fresh-planar-placement'
45
- import { stripPlacementMetadataFlags } from '../../../lib/placement-metadata'
45
+ import {
46
+ isFreshPlacementMetadata,
47
+ stripPlacementMetadataFlags,
48
+ } from '../../../lib/placement-metadata'
46
49
  import {
47
50
  offsetPlanPositionByLocalCenter,
48
51
  resolvePrioritizedPlanarCursorPosition,
@@ -64,6 +67,7 @@ import useEditor, {
64
67
  isMagneticSnapActive,
65
68
  } from '../../../store/use-editor'
66
69
  import useFacingPose from '../../../store/use-facing-pose'
70
+ import useInteractionScope from '../../../store/use-interaction-scope'
67
71
  import { swallowNextClick } from '../../editor/node-arrow-handles'
68
72
  import { CursorSphere } from '../shared/cursor-sphere'
69
73
  import { DragBoundingBox } from '../shared/drag-bounding-box'
@@ -218,7 +222,14 @@ const ALIGNMENT_THRESHOLD_M = 0.08
218
222
  */
219
223
  type ClickTriggerEvent = GridEvent | NodeEvent<AnyNode>
220
224
 
221
- export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
225
+ export function MoveRegistryNodeTool({ node: source }: { node: AnyNode }) {
226
+ const node = useMemo(
227
+ () =>
228
+ isFreshPlacementMetadata(source.metadata)
229
+ ? (useScene.getState().nodes[source.id] ?? source)
230
+ : source,
231
+ [source],
232
+ )
222
233
  const itemSurfaceMove = useMemo(() => createRegistryItemSurfaceMove(node), [node])
223
234
  const suppressRaycastsRef = useRef<(() => void) | null>(null)
224
235
  const previewGroupRef = useRef<Group>(null)
@@ -432,6 +443,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
432
443
  }, [])
433
444
 
434
445
  useEffect(() => {
446
+ const ownsSubtree = useInteractionScope.getState().adoptSubtree(node.id)
435
447
  useScene.temporal.getState().pause()
436
448
  const movementSfx = createMovementSfxTick()
437
449
  dragAnchorRef.current = null
@@ -1114,11 +1126,13 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
1114
1126
  } as Partial<AnyNode>
1115
1127
 
1116
1128
  if (isNew) {
1117
- const finalId = commitFreshPlacementSubtree(node.id as AnyNodeId, data)
1118
- if (finalId) {
1119
- committed = true
1120
- committedId = finalId
1121
- }
1129
+ const finalId = commitFreshPlacementSubtree(node.id as AnyNodeId, data, (reason) => {
1130
+ setSurfaceRejection(reason)
1131
+ setValid(false)
1132
+ })
1133
+ if (!finalId) return
1134
+ committed = true
1135
+ committedId = finalId
1122
1136
  } else {
1123
1137
  // Fold the connected-ductwork follow-updates into the SAME
1124
1138
  // batch as the moved node so the whole thing is one undo step.
@@ -1339,7 +1353,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
1339
1353
  clearParentFramePreview()
1340
1354
  if (isNew) {
1341
1355
  updateSurfaceNode(node.id, {}, null)
1342
- useScene.getState().deleteNode(node.id as AnyNodeId)
1356
+ if (!ownsSubtree) useScene.getState().deleteNode(node.id)
1343
1357
  } else {
1344
1358
  itemSurfaceMove?.restore()
1345
1359
  applyMeshPose(originalPosition, originalRotationY)
@@ -1376,7 +1390,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
1376
1390
  // unmount / commit paths uniformly.
1377
1391
  useAlignmentGuides.getState().clear()
1378
1392
  const finalisedBy2D = useEditor.getState().movingNodeOrigin === '2d'
1379
- if (!committed && !finalisedBy2D && (!isNew || itemSurfaceMove)) {
1393
+ if (!committed && !finalisedBy2D && (!isNew || (!ownsSubtree && itemSurfaceMove))) {
1380
1394
  useLiveTransforms.getState().clear(node.id)
1381
1395
  clearConnectivityOverrides()
1382
1396
  clearParentFramePreview()
@@ -0,0 +1,24 @@
1
+ import { afterAll, afterEach, expect, spyOn, test } from 'bun:test'
2
+ import { type AnyNodeDefinition, nodeRegistry } from '@pascal-app/core'
3
+ import { getRegistryAffordanceTool, preloadRegistryAffordanceTools } from './affordance-dispatch'
4
+
5
+ const get = spyOn(nodeRegistry, 'get')
6
+ afterEach(() => get.mockReset())
7
+ afterAll(() => get.mockRestore())
8
+
9
+ test('a preloaded drag tool mounts directly without first-gesture suspense', async () => {
10
+ const Tool = () => null
11
+ const loader = async () => ({ default: Tool })
12
+ get.mockReturnValue({ affordanceTools: { move: loader } } as unknown as AnyNodeDefinition)
13
+ await preloadRegistryAffordanceTools('wall')
14
+ expect(getRegistryAffordanceTool('wall', 'move')).toBe(Tool)
15
+ expect(getRegistryAffordanceTool('wall', 'move')).toBe(Tool)
16
+ })
17
+
18
+ test('preloading does not change an already mounted lazy component identity', async () => {
19
+ const loader = async () => ({ default: () => null })
20
+ get.mockReturnValue({ affordanceTools: { move: loader } } as unknown as AnyNodeDefinition)
21
+ const mounted = getRegistryAffordanceTool('wall', 'move')
22
+ await preloadRegistryAffordanceTools('wall')
23
+ expect(getRegistryAffordanceTool('wall', 'move')).toBe(mounted)
24
+ })
@@ -15,6 +15,19 @@ import { type ComponentType, lazy } from 'react'
15
15
  */
16
16
  const lazyToolCache = new WeakMap<() => Promise<unknown>, ComponentType>()
17
17
 
18
+ export async function preloadRegistryAffordanceTools(kind: string): Promise<void> {
19
+ const tools = nodeRegistry.get(kind)?.affordanceTools
20
+ await Promise.allSettled(
21
+ Object.values(tools ?? {}).map(async (loader) => {
22
+ if (!loader || lazyToolCache.has(loader)) return
23
+ const module = (await loader()) as { default: ComponentType<any> }
24
+ // Keep an already-mounted lazy component stable. Otherwise use the resolved
25
+ // component directly so grabbing a preloaded handle never suspends its tool.
26
+ if (!lazyToolCache.has(loader)) lazyToolCache.set(loader, module.default)
27
+ }),
28
+ )
29
+ }
30
+
18
31
  export function getRegistryAffordanceTool(
19
32
  kind: string,
20
33
  affordance: string,
@@ -0,0 +1,38 @@
1
+ 'use client'
2
+
3
+ import { Html } from '@react-three/drei'
4
+
5
+ /** Lift of a draft's readout above the top of what it measures, in metres. */
6
+ export const DRAFT_LABEL_Y_OFFSET = 0.22
7
+
8
+ /** Screen-space readout for a draft in 3D (lengths, angles, split segments). */
9
+ export function DraftMeasurementLabel({
10
+ color,
11
+ label,
12
+ position,
13
+ shadowColor,
14
+ }: {
15
+ color: string
16
+ label: string
17
+ position: [number, number, number]
18
+ shadowColor: string
19
+ }) {
20
+ return (
21
+ <Html
22
+ center
23
+ position={position}
24
+ style={{ pointerEvents: 'none', userSelect: 'none' }}
25
+ zIndexRange={[100, 0]}
26
+ >
27
+ <div
28
+ className="whitespace-nowrap font-bold font-mono text-[15px]"
29
+ style={{
30
+ color,
31
+ textShadow: `-1.5px -1.5px 0 ${shadowColor}, 1.5px -1.5px 0 ${shadowColor}, -1.5px 1.5px 0 ${shadowColor}, 1.5px 1.5px 0 ${shadowColor}, 0 0 4px ${shadowColor}, 0 0 4px ${shadowColor}`,
32
+ }}
33
+ >
34
+ {label}
35
+ </div>
36
+ </Html>
37
+ )
38
+ }
@@ -19,6 +19,7 @@ import {
19
19
  MeshBasicMaterial,
20
20
  OrthographicCamera,
21
21
  PerspectiveCamera,
22
+ Ray,
22
23
  Vector3,
23
24
  } from 'three'
24
25
  import { z } from 'zod'
@@ -161,6 +162,32 @@ describe('resolvePointerSupportSurface node tops', () => {
161
162
  expect(support?.worldPoint).toEqual([0, 2, 0])
162
163
  })
163
164
 
165
+ test('XR floor drawing uses the controller ray even when the camera sees a raised surface', () => {
166
+ addPluginPlatform(2, [4, 2, 2])
167
+ const camera = new PerspectiveCamera()
168
+ camera.position.set(0, 5, 5)
169
+ camera.updateMatrixWorld(true)
170
+ const controllerRay = new Ray(new Vector3(0, 1.5, 0), new Vector3(0, -1, 0))
171
+ const desktop = resolvePointerSupportSurface(camera, [0, 0, 0], {
172
+ includeNodeTopSurfaces: true,
173
+ })
174
+ expect(desktop?.sourceNodeId).toBe(PLATFORM_ID)
175
+ const xr = resolvePointerSupportSurface(camera, [0, 0, 0], {
176
+ includeNodeTopSurfaces: true,
177
+ pointerRay: controllerRay,
178
+ })
179
+ expect(xr?.sourceNodeId).toBeNull()
180
+ expect(xr?.worldPoint).toEqual([0, 0, 0])
181
+ camera.position.set(12, 8, -10)
182
+ camera.updateMatrixWorld(true)
183
+ expect(
184
+ resolvePointerSupportSurface(camera, [0, 0, 0], {
185
+ includeNodeTopSurfaces: true,
186
+ pointerRay: controllerRay,
187
+ })?.worldPoint,
188
+ ).toEqual([0, 0, 0])
189
+ })
190
+
164
191
  test('keeps an unhovered item top above a batched slab across batch transitions', () => {
165
192
  const restoreRegistry = nodeRegistry._snapshot()
166
193
  const item = ItemNode.parse({
@@ -11,7 +11,7 @@ import {
11
11
  useScene,
12
12
  } from '@pascal-app/core'
13
13
  import { setSurfaceRaycastLayers, useViewer } from '@pascal-app/viewer'
14
- import { type Camera, type Object3D, Raycaster, Vector3 } from 'three'
14
+ import { type Camera, type Object3D, type Ray, Raycaster, Vector3 } from 'three'
15
15
  import { resolveTerrainGroundHit } from '../../../lib/ground-surface'
16
16
  import { scopeNodeId } from '../../../lib/interaction/scope'
17
17
  import useInteractionScope from '../../../store/use-interaction-scope'
@@ -79,16 +79,22 @@ export type PointerSupportSurface = {
79
79
  export function resolvePointerSupportSurface(
80
80
  camera: Camera,
81
81
  worldHit: readonly [number, number, number],
82
- options?: { includeNodeTopSurfaces?: boolean },
82
+ options?: { includeNodeTopSurfaces?: boolean; pointerRay?: Ray },
83
83
  ): PointerSupportSurface | null {
84
84
  const levelId = useViewer.getState().selection.levelId
85
85
  if (!levelId) return null
86
86
 
87
87
  // The world ray, kept before the level conversion below: the terrain field is
88
88
  // world-space (site geometry, not level-local), so the march needs this frame.
89
- camera.getWorldPosition(worldRayOrigin)
89
+ if (options?.pointerRay) worldRayOrigin.copy(options.pointerRay.origin)
90
+ else camera.getWorldPosition(worldRayOrigin)
91
+ const isOrthographic =
92
+ !options?.pointerRay &&
93
+ (camera as Camera & { isOrthographicCamera?: boolean }).isOrthographicCamera === true
90
94
  const cameraToHit = hitScratch.set(worldHit[0], worldHit[1], worldHit[2]).sub(worldRayOrigin)
91
- if ((camera as Camera & { isOrthographicCamera?: boolean }).isOrthographicCamera) {
95
+ if (options?.pointerRay) {
96
+ worldRayDirection.copy(options.pointerRay.direction).normalize()
97
+ } else if (isOrthographic) {
92
98
  // For an orthographic camera every screen pixel has the same direction. The
93
99
  // hit point is offset from the camera along the view plane, so using
94
100
  // `camera.position -> hit` tilts the ray toward the screen centre and makes
@@ -102,7 +108,13 @@ export function resolvePointerSupportSurface(
102
108
  }
103
109
 
104
110
  originScratch.copy(worldRayOrigin)
105
- hitScratch.set(worldHit[0], worldHit[1], worldHit[2])
111
+ // Second world point defining the ray fed to the surface solve. In the plain
112
+ // perspective case `worldHit` already lies on the true pointer ray, so using
113
+ // it keeps `t === 1` (and the returned plan point exact) whenever the pointed
114
+ // surface IS the event plane — the pointer-ray and orthographic branches have
115
+ // no such hit, so they step one unit along the resolved direction.
116
+ if (options?.pointerRay || isOrthographic) hitScratch.copy(worldRayOrigin).add(worldRayDirection)
117
+ else hitScratch.set(worldHit[0], worldHit[1], worldHit[2])
106
118
  // Slab polygons/elevations live in the level frame; the level mesh
107
119
  // carries the storey Y offset and any building rotation.
108
120
  const levelMesh = sceneRegistry.nodes.get(levelId as AnyNodeId)
@@ -3,12 +3,12 @@ import {
3
3
  type AnyNode,
4
4
  ItemNode,
5
5
  nodeRegistry,
6
- registerNode,
7
6
  resolveSurfacePlacement,
8
7
  type SceneApi,
9
8
  type SurfaceProvider,
10
9
  type SurfaceRejectReason,
11
10
  } from '@pascal-app/core'
11
+ import { registerHostingTestNode } from '../__fixtures__/hosting'
12
12
  import { createSurfaceRejectionFeedback } from './surface-rejection'
13
13
 
14
14
  let restore: () => void
@@ -27,7 +27,7 @@ for (const reason of [
27
27
  'no-surface',
28
28
  'invalid-hit',
29
29
  ] as const) {
30
- test(`${reason} remains available from onReject without user-facing text`, () => {
30
+ test(`${reason}: eligible-host diagnostics remain available; ineligible hosts defer silently`, () => {
31
31
  const provider: SurfaceProvider = {
32
32
  childFrame: 'host-local',
33
33
  resolveHit: () =>
@@ -56,7 +56,7 @@ for (const reason of [
56
56
  },
57
57
  accepts: () => reason !== 'child-not-accepted',
58
58
  }
59
- registerNode({
59
+ registerHostingTestNode({
60
60
  kind: 'test-host',
61
61
  schemaVersion: 1,
62
62
  schema: ItemNode,
@@ -75,6 +75,7 @@ for (const reason of [
75
75
  type: reason === 'host-not-eligible' ? 'guide' : 'test-host',
76
76
  } as unknown as AnyNode,
77
77
  childKind: 'item',
78
+ childId: 'item_existing',
78
79
  childFootprint: {
79
80
  size: reason === 'footprint-outside-surface' ? [3, 1, 3] : [0.3, 0.3, 0.3],
80
81
  rotationY: 0,
@@ -87,7 +88,7 @@ for (const reason of [
87
88
  onReject: (r) => feedback.reject(r),
88
89
  }),
89
90
  ).toBeNull()
90
- expect(feedback.reason).toBe(reason)
91
+ expect(feedback.reason).toBe(reason === 'host-not-eligible' ? null : reason)
91
92
  })
92
93
  }
93
94
 
@@ -27,3 +27,17 @@ export function createSurfaceRejectionFeedback(
27
27
  },
28
28
  }
29
29
  }
30
+
31
+ // R3F reuses one native event across enter/move dispatch and bubbled ancestor hits.
32
+ export function createSurfaceEventOwnership() {
33
+ const owners = new WeakMap<object, string>()
34
+ return {
35
+ allows(hostId: string, event: object) {
36
+ const owner = owners.get(event)
37
+ return owner === undefined || owner === hostId
38
+ },
39
+ claim(hostId: string, event: object) {
40
+ if (!owners.has(event)) owners.set(event, hostId)
41
+ },
42
+ }
43
+ }
@@ -13,6 +13,7 @@ import {
13
13
  import { useViewer } from '@pascal-app/viewer'
14
14
  import { type ComponentType, lazy, Suspense, useMemo } from 'react'
15
15
  import { useRegisteredToolEnabled } from '../../hooks/use-registered-tool-enabled'
16
+ import type { ReshapeKind } from '../../lib/interaction/scope'
16
17
  import { siteBoundaryHandlesEnabled } from '../../lib/site-boundary'
17
18
  import useEditor, { type Phase, type Tool } from '../../store/use-editor'
18
19
  import useInteractionScope, {
@@ -33,7 +34,10 @@ import { WallSnapBeaconLayer } from '../editor/wall-snap-beacon-layer'
33
34
  import { ElevatorTool } from './elevator/elevator-tool'
34
35
  import { MoveTool } from './item/move-tool'
35
36
  import { RegistryToolProvider } from './registry-tool-context'
36
- import { getRegistryAffordanceTool } from './shared/affordance-dispatch'
37
+ import {
38
+ getRegistryAffordanceTool,
39
+ preloadRegistryAffordanceTools,
40
+ } from './shared/affordance-dispatch'
37
41
  import { FacingPoseIndicator } from './shared/facing-pose-indicator'
38
42
  import { SiteBoundaryEditor } from './site/site-boundary-editor'
39
43
  import { TerrainSculptTool } from './site/terrain-sculpt-tool'
@@ -44,6 +48,15 @@ import { ZoneTool } from './zone/zone-tool'
44
48
  // Cache lazy tool components keyed by their loader so React.lazy isn't
45
49
  // re-invoked across renders.
46
50
  const lazyToolCache = new WeakMap<() => Promise<unknown>, ComponentType>()
51
+ // Reshapes with their own arm below; anything else resolves by name.
52
+ const DEDICATED_RESHAPES = new Set<ReshapeKind>([
53
+ 'curve',
54
+ 'hole',
55
+ 'endpoint',
56
+ 'boundary',
57
+ 'control-point',
58
+ 'tangent',
59
+ ])
47
60
  const registryToolPreloadCache = new WeakMap<AnyNodeDefinition, Promise<void>>()
48
61
 
49
62
  export function preloadRegistryToolModules(tool: string | null): Promise<void> {
@@ -60,8 +73,7 @@ export function preloadRegistryToolModules(tool: string | null): Promise<void> {
60
73
  if (def.system) loaders.push(def.system.module)
61
74
  if (def.parametrics?.customPanel) loaders.push(def.parametrics.customPanel)
62
75
  if (def.parametrics?.trailingSection) loaders.push(def.parametrics.trailingSection)
63
- const moveTool = def.affordanceTools?.move
64
- if (moveTool) loaders.push(moveTool)
76
+ loaders.push(() => preloadRegistryAffordanceTools(tool))
65
77
 
66
78
  const preload = Promise.allSettled(loaders.map((loader) => loader())).then(() => undefined)
67
79
  registryToolPreloadCache.set(def, preload)
@@ -114,6 +126,11 @@ export const ToolManager: React.FC = () => {
114
126
  const tangentReshape = useTangentReshape()
115
127
  const isCurveReshape = useIsCurveReshape()
116
128
  const isToolDrivenReshape = useIsToolDrivenReshape()
129
+ const registryReshape = useInteractionScope((state) =>
130
+ state.scope.kind === 'reshaping' && !DEDICATED_RESHAPES.has(state.scope.reshape)
131
+ ? state.scope.reshape
132
+ : null,
133
+ )
117
134
  const isFloorplanDrivenReshape = useIsFloorplanDrivenReshape()
118
135
  const reshapingNode = useReshapingNode()
119
136
  // The endpoint affordance tool's `target` is kind-specific
@@ -384,6 +401,22 @@ export const ToolManager: React.FC = () => {
384
401
  </Suspense>
385
402
  ) : null
386
403
  })()}
404
+ {/* Reshape kinds without an arm above mount the kind's own affordance tool
405
+ under the reshape's name (`def.affordanceTools[reshape]`, e.g. wall 'split'). */}
406
+ {isToolDrivenReshape &&
407
+ reshapingNode &&
408
+ registryReshape &&
409
+ (() => {
410
+ const RegistryAffordance = getRegistryAffordanceTool(
411
+ reshapingNode.type,
412
+ registryReshape,
413
+ )
414
+ return RegistryAffordance ? (
415
+ <Suspense fallback={null}>
416
+ <RegistryAffordance node={reshapingNode} />
417
+ </Suspense>
418
+ ) : null
419
+ })()}
387
420
  {showMover && movingNode.type !== 'building' && (
388
421
  <MoveTool
389
422
  onNodeMoved={handlePlacedNodeSelected}
@@ -1,20 +1,9 @@
1
1
  'use client'
2
2
 
3
- import {
4
- type AnyNodeId,
5
- generateSceneMaterialId,
6
- type SceneMaterialId,
7
- toSceneMaterialRef,
8
- useScene,
9
- } from '@pascal-app/core'
10
- import { useViewer } from '@pascal-app/viewer'
3
+ import { type SceneMaterialId } from '@pascal-app/core'
11
4
  import { Eraser, Plus, RotateCcw } from 'lucide-react'
12
- import { useEffect, useState } from 'react'
13
- import {
14
- buildResetSurfaceMaterialUpdates,
15
- resolvePaintTargetFromSelection,
16
- } from './../../../lib/material-paint'
17
- import useEditor from './../../../store/use-editor'
5
+ import { useState } from 'react'
6
+ import { useMaterialPaintPanelModel } from '../../../lib/material-paint-panel-model'
18
7
  import { Button } from '../primitives/button'
19
8
  import { Tooltip, TooltipContent, TooltipTrigger } from '../primitives/tooltip'
20
9
  import { MaterialPicker } from './material-picker'
@@ -33,57 +22,9 @@ export type MaterialPaintPanelProps = {
33
22
  }
34
23
 
35
24
  export function MaterialPaintPanel({ onCreateMaterialRequest }: MaterialPaintPanelProps) {
36
- const activePaintMaterial = useEditor((state) => state.activePaintMaterial)
37
- const activePaintTarget = useEditor((state) => state.activePaintTarget)
38
- const armMaterialPaint = useEditor((state) => state.armMaterialPaint)
39
- const setActivePaintTarget = useEditor((state) => state.setActivePaintTarget)
40
- const paintEraser = useEditor((state) => state.paintEraser)
41
- const setPaintEraser = useEditor((state) => state.setPaintEraser)
42
- // Id of a just-created scene material whose inline editor should open on mount.
25
+ const { activePaintMaterial, paintEraser, setPaintEraser, canResetSelection, resetSelection, materialCount, selectMaterial, createCustomMaterial: createMaterial } = useMaterialPaintPanelModel()
43
26
  const [autoEditMaterialId, setAutoEditMaterialId] = useState<SceneMaterialId | null>(null)
44
- const selectedIds = useViewer((state) => state.selection.selectedIds)
45
- const nodes = useScene((state) => state.nodes)
46
- const materialCount = useScene((state) => Object.keys(state.materials).length)
47
- const selectedId = selectedIds.length === 1 ? (selectedIds[0] ?? null) : null
48
- const selectedNode = selectedId ? nodes[selectedId as AnyNodeId] : null
49
- const canResetSelection =
50
- selectedNode != null && resolvePaintTargetFromSelection({ nodes, selectedId }) != null
51
-
52
- useEffect(() => {
53
- const selectedPaintTarget = resolvePaintTargetFromSelection({ nodes, selectedId })
54
- if (selectedPaintTarget) {
55
- setActivePaintTarget(selectedPaintTarget)
56
- }
57
- }, [nodes, selectedId, setActivePaintTarget])
58
-
59
- const resetSelection = () => {
60
- if (!selectedNode) return
61
- useScene.getState().updateNodes(buildResetSurfaceMaterialUpdates(nodes, selectedNode))
62
- }
63
-
64
- // Create a blank custom scene material, select it as the brush (`scene:` ref so
65
- // edits propagate), and open its inline editor. Available from any category.
66
- const createCustomMaterial = () => {
67
- const id = generateSceneMaterialId()
68
- const count = Object.keys(useScene.getState().materials).length
69
- useScene.getState().addSceneMaterial({
70
- id,
71
- name: `Material ${count + 1}`,
72
- material: {
73
- preset: 'custom',
74
- properties: {
75
- color: '#ffffff',
76
- roughness: 0.5,
77
- metalness: 0,
78
- opacity: 1,
79
- transparent: false,
80
- side: 'front',
81
- },
82
- },
83
- })
84
- armMaterialPaint({ materialPreset: toSceneMaterialRef(id), sourceTarget: activePaintTarget })
85
- setAutoEditMaterialId(id)
86
- }
27
+ const createCustomMaterial = () => setAutoEditMaterialId(createMaterial())
87
28
 
88
29
  return (
89
30
  <div className="flex h-full min-h-0 w-full flex-col">
@@ -117,9 +58,7 @@ export function MaterialPaintPanel({ onCreateMaterialRequest }: MaterialPaintPan
117
58
  <div className="min-h-0 flex-1" data-guide-target="paint-material">
118
59
  <MaterialPicker
119
60
  onCreateMaterialRequest={onCreateMaterialRequest}
120
- onSelectMaterialPreset={(materialPreset) => {
121
- armMaterialPaint({ materialPreset, sourceTarget: activePaintTarget })
122
- }}
61
+ onSelectMaterialPreset={selectMaterial}
123
62
  selectedMaterialPreset={activePaintMaterial?.materialPreset}
124
63
  />
125
64
  </div>
@@ -1,23 +1,11 @@
1
1
  'use client'
2
2
 
3
- import {
4
- getCatalogMaterialById,
5
- getDynamicLibraryMaterials,
6
- getLibraryMaterialIdFromRef,
7
- getLibraryMaterialsVersion,
8
- getMaterialsForCategory,
9
- MATERIAL_CATEGORIES,
10
- type MaterialCatalogItem,
11
- type MaterialSource,
12
- type MaterialTarget,
13
- subscribeLibraryMaterials,
14
- toLibraryMaterialRef,
15
- } from '@pascal-app/core'
3
+ import { type MaterialTarget, toLibraryMaterialRef } from '@pascal-app/core'
16
4
  import { Plus } from 'lucide-react'
17
- import { useEffect, useMemo, useState, useSyncExternalStore } from 'react'
5
+ import { useMaterialCatalogModel, type MaterialSourceFilter } from '../../../lib/material-catalog-model'
18
6
  import { triggerSFX } from '../../../lib/sfx-bus'
19
7
 
20
- export type MaterialSourceFilter = MaterialSource
8
+ export type { MaterialSourceFilter } from '../../../lib/material-catalog-model'
21
9
 
22
10
  export type MaterialPickerProps = {
23
11
  selectedMaterialPreset?: string
@@ -28,23 +16,10 @@ export type MaterialPickerProps = {
28
16
  onCreateMaterialRequest?: () => void
29
17
  }
30
18
 
31
- // No 'All': the browse surfaces (Items / Rooms / Build) dropped it and default
32
- // to the Pascal library — the combined list buried the curated set.
33
- const SOURCE_FILTERS: { id: MaterialSourceFilter; label: string }[] = [
34
- { id: 'pascal', label: 'Pascal' },
35
- { id: 'mine', label: 'Mine' },
36
- { id: 'workspace', label: 'Workspace' },
37
- { id: 'community', label: 'Community' },
38
- ]
39
-
40
- function getCategoryLabel(category: (typeof MATERIAL_CATEGORIES)[number]) {
19
+ function getCategoryLabel(category: string) {
41
20
  return category.charAt(0).toUpperCase() + category.slice(1)
42
21
  }
43
22
 
44
- function filterBySource(items: MaterialCatalogItem[], filter: MaterialSourceFilter) {
45
- return items.filter((item) => (item.source ?? 'pascal') === filter)
46
- }
47
-
48
23
  /**
49
24
  * Catalog material picker: a fixed row of category tabs and a source filter row
50
25
  * over a scrollable grid of swatches. Scene-material creation lives in the
@@ -57,40 +32,7 @@ export function MaterialPicker({
57
32
  disabled = false,
58
33
  onCreateMaterialRequest,
59
34
  }: MaterialPickerProps) {
60
- const [selectedCategory, setSelectedCategory] = useState<(typeof MATERIAL_CATEGORIES)[number]>(
61
- MATERIAL_CATEGORIES[0],
62
- )
63
- const [sourceFilter, setSourceFilter] = useState<MaterialSourceFilter>('pascal')
64
- // Version counter so host registrations/unregistrations re-render the picker.
65
- const libraryVersion = useSyncExternalStore(
66
- subscribeLibraryMaterials,
67
- getLibraryMaterialsVersion,
68
- getLibraryMaterialsVersion,
69
- )
70
- const hasWorkspaceMaterials = useMemo(
71
- () => getDynamicLibraryMaterials().some((item) => item.source === 'workspace'),
72
- [libraryVersion],
73
- )
74
- const visibleSourceFilters = SOURCE_FILTERS.filter(
75
- (filter) => filter.id !== 'workspace' || hasWorkspaceMaterials,
76
- )
77
- const availableCategories = MATERIAL_CATEGORIES.filter(
78
- (category) => getMaterialsForCategory(category).length > 0,
79
- )
80
- const catalogItems = filterBySource(getMaterialsForCategory(selectedCategory), sourceFilter)
81
-
82
- // Keep the visible category in sync with the externally-selected catalog
83
- // material (a `scene:` ref matches no catalog entry, so the tab stays put).
84
- useEffect(() => {
85
- const catalogId = getLibraryMaterialIdFromRef(selectedMaterialPreset) ?? undefined
86
- const entry = getCatalogMaterialById(catalogId)
87
- if (entry?.category) setSelectedCategory(entry.category)
88
- }, [selectedMaterialPreset])
89
-
90
- const handleCatalogSelect = (materialId: string) => {
91
- if (disabled) return
92
- onSelectMaterialPreset?.(toLibraryMaterialRef(materialId))
93
- }
35
+ const { selectedCategory, setSelectedCategory, sourceFilter, setSourceFilter, visibleSourceFilters, availableCategories, catalogItems, select: handleCatalogSelect } = useMaterialCatalogModel(selectedMaterialPreset, onSelectMaterialPreset, disabled)
94
36
 
95
37
  return (
96
38
  <div
@@ -108,10 +50,6 @@ export function MaterialPicker({
108
50
  key={category}
109
51
  onClick={() => {
110
52
  setSelectedCategory(category)
111
- // Auto-select the first material in the category so the brush is
112
- // immediately ready (and the swatch shows as selected).
113
- const first = filterBySource(getMaterialsForCategory(category), sourceFilter)[0]
114
- if (first) handleCatalogSelect(first.id)
115
53
  }}
116
54
  type="button"
117
55
  >