@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.
- package/package.json +10 -5
- package/src/components/editor/custom-camera-controls.tsx +22 -74
- package/src/components/editor/editor-layout-mobile.tsx +1 -0
- package/src/components/editor/floorplan-panel.tsx +210 -163
- package/src/components/editor/grid.tsx +31 -7
- package/src/components/editor/group-actions.ts +5 -9
- package/src/components/editor/group-floating-action-menu.tsx +19 -9
- package/src/components/editor/group-move-3d.ts +4 -6
- package/src/components/editor/group-rotate-handle.tsx +81 -35
- package/src/components/editor/group-selection-box-3d.tsx +25 -17
- package/src/components/editor/group-transform-shared.test.ts +126 -1
- package/src/components/editor/group-transform-shared.ts +79 -73
- package/src/components/editor/handles/handle-arrow.tsx +72 -2
- package/src/components/editor/handles/use-handle-drag.ts +75 -6
- package/src/components/editor/index.tsx +24 -9
- package/src/components/editor/node-action-menu.tsx +2 -0
- package/src/components/editor/node-arrow-handles.tsx +141 -7
- package/src/components/editor/registry-action-contributions.tsx +53 -0
- package/src/components/editor/wall-move-side-handles.tsx +71 -21
- package/src/components/editor/wall-opening-highlights.tsx +2 -3
- package/src/components/editor-2d/floorplan-group-move.tsx +17 -24
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +33 -12
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +154 -49
- package/src/components/editor-2d/renderers/floorplan-draft-wall-measurement.tsx +166 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +49 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +67 -21
- package/src/components/systems/zone/zone-system.tsx +8 -4
- package/src/components/tools/__fixtures__/hosting.ts +10 -0
- package/src/components/tools/item/item-surface-placement.test.ts +3 -9
- package/src/components/tools/item/item-surface-strategy-resolver.test.ts +3 -9
- package/src/components/tools/item/placement-strategies.ts +55 -43
- package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +3 -3
- package/src/components/tools/item/use-draft-node.ts +41 -2
- package/src/components/tools/item/use-placement-coordinator.tsx +166 -73
- package/src/components/tools/registry/item-surface-move.test.ts +10 -2
- package/src/components/tools/registry/item-surface-move.ts +46 -24
- package/src/components/tools/registry/item-surface-resolver.test.ts +2 -2
- package/src/components/tools/registry/move-registry-node-tool.tsx +23 -9
- package/src/components/tools/shared/affordance-dispatch.test.ts +24 -0
- package/src/components/tools/shared/affordance-dispatch.ts +13 -0
- package/src/components/tools/shared/draft-measurement-label.tsx +38 -0
- package/src/components/tools/shared/pointer-support-cap.test.ts +27 -0
- package/src/components/tools/shared/pointer-support-cap.ts +17 -5
- package/src/components/tools/shared/surface-rejection.test.tsx +5 -4
- package/src/components/tools/shared/surface-rejection.tsx +14 -0
- package/src/components/tools/tool-manager.tsx +36 -3
- package/src/components/ui/controls/material-paint-panel.tsx +6 -67
- package/src/components/ui/controls/material-picker.tsx +5 -67
- package/src/components/ui/controls/slider-control.tsx +76 -31
- package/src/components/ui/helpers/helper-manager.tsx +24 -6
- package/src/components/ui/helpers/registered-tool-helper.tsx +2 -32
- package/src/components/ui/item-catalog/item-catalog.tsx +5 -30
- package/src/components/ui/panels/multi-height-mode.tsx +27 -21
- package/src/components/ui/panels/parametric-inspector.tsx +3 -15
- package/src/components/ui/primitives/shortcut-token.tsx +4 -0
- package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +585 -369
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +1 -1
- package/src/components/viewer/floorplan-preview-visibility.test.ts +35 -0
- package/src/components/viewer/floorplan-preview-visibility.ts +19 -0
- package/src/components/viewer/floorplan-preview.tsx +1 -12
- package/src/components/viewer-zone-system.tsx +7 -2
- package/src/hooks/use-auto-save.integration.test.tsx +197 -0
- package/src/hooks/use-auto-save.ts +21 -6
- package/src/hooks/use-ceiling-events.test.ts +1 -0
- package/src/hooks/use-ceiling-events.ts +2 -0
- package/src/hooks/use-grid-events.test.ts +2 -0
- package/src/hooks/use-grid-events.ts +16 -11
- package/src/hooks/use-keyboard.test.ts +7 -0
- package/src/hooks/use-keyboard.ts +42 -29
- package/src/index.tsx +63 -3
- package/src/lib/catalog-panel-model.ts +39 -0
- package/src/lib/catalog-selection.test.ts +27 -0
- package/src/lib/catalog-selection.ts +8 -0
- package/src/lib/contextual-help.test.ts +27 -1
- package/src/lib/contextual-help.ts +16 -9
- package/src/lib/floorplan/floorplan-export.test.ts +3 -1
- package/src/lib/floorplan/floorplan-extension.ts +16 -0
- package/src/lib/fresh-planar-placement.ts +168 -36
- package/src/lib/glb-export.test.ts +59 -16
- package/src/lib/glb-export.ts +26 -13
- package/src/lib/grid-frame.test.ts +19 -0
- package/src/lib/grid-frame.ts +18 -0
- package/src/lib/interaction/scope.ts +9 -1
- package/src/lib/keyboard-pan.test.ts +62 -0
- package/src/lib/keyboard-pan.ts +89 -0
- package/src/lib/material-catalog-model.ts +72 -0
- package/src/lib/material-paint-panel-model.ts +76 -0
- package/src/lib/overlay-transform.test.ts +28 -0
- package/src/lib/overlay-transform.ts +12 -0
- package/src/lib/panel-rows.ts +75 -0
- package/src/lib/panel-tool-options.test.ts +34 -0
- package/src/lib/panel-tool-options.ts +46 -0
- package/src/lib/parametric-node-update.ts +37 -0
- package/src/lib/scene.test.ts +99 -0
- package/src/lib/scene.ts +21 -3
- package/src/lib/snapping-mode.test.ts +13 -2
- package/src/lib/snapping-mode.ts +11 -6
- package/src/lib/spatial-drag-plane.test.ts +53 -0
- package/src/lib/spatial-drag-plane.ts +19 -0
- package/src/lib/spatial-pointer-input.test.ts +72 -0
- package/src/lib/spatial-pointer-input.ts +62 -0
- package/src/lib/spatial-snap.test.ts +47 -0
- package/src/lib/terrain-panel-model.ts +117 -0
- package/src/lib/world-grid-snap.test.ts +48 -0
- package/src/lib/world-grid-snap.ts +9 -5
- package/src/store/rotation-snapping.test.ts +53 -0
- package/src/store/use-editor.tsx +10 -2
- package/src/store/use-floorplan-draft-preview.ts +10 -1
- 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 {
|
|
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
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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}
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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 =
|
|
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
|
|
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={
|
|
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 {
|
|
5
|
+
import { useMaterialCatalogModel, type MaterialSourceFilter } from '../../../lib/material-catalog-model'
|
|
18
6
|
import { triggerSFX } from '../../../lib/sfx-bus'
|
|
19
7
|
|
|
20
|
-
export type MaterialSourceFilter
|
|
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
|
-
|
|
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
|
|
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
|
>
|