@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
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
getCatalogMaterialById,
|
|
5
|
+
getDynamicLibraryMaterials,
|
|
6
|
+
getLibraryMaterialIdFromRef,
|
|
7
|
+
getLibraryMaterialsVersion,
|
|
8
|
+
getMaterialsForCategory,
|
|
9
|
+
MATERIAL_CATEGORIES,
|
|
10
|
+
type MaterialSource,
|
|
11
|
+
subscribeLibraryMaterials,
|
|
12
|
+
toLibraryMaterialRef,
|
|
13
|
+
} from '@pascal-app/core'
|
|
14
|
+
import { useEffect, useState, useSyncExternalStore } from 'react'
|
|
15
|
+
|
|
16
|
+
export type MaterialSourceFilter = MaterialSource
|
|
17
|
+
const SOURCE_FILTERS: { id: MaterialSourceFilter; label: string }[] = [
|
|
18
|
+
{ id: 'pascal', label: 'Pascal' },
|
|
19
|
+
{ id: 'mine', label: 'Mine' },
|
|
20
|
+
{ id: 'workspace', label: 'Workspace' },
|
|
21
|
+
{ id: 'community', label: 'Community' },
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
export function useMaterialCatalogModel(
|
|
25
|
+
selectedMaterialPreset?: string,
|
|
26
|
+
onSelectMaterialPreset?: (ref: string) => void,
|
|
27
|
+
disabled = false,
|
|
28
|
+
) {
|
|
29
|
+
const [selectedCategory, setCategory] = useState<(typeof MATERIAL_CATEGORIES)[number]>(
|
|
30
|
+
MATERIAL_CATEGORIES[0],
|
|
31
|
+
)
|
|
32
|
+
const [sourceFilter, setSourceFilter] = useState<MaterialSourceFilter>('pascal')
|
|
33
|
+
useSyncExternalStore(
|
|
34
|
+
subscribeLibraryMaterials,
|
|
35
|
+
getLibraryMaterialsVersion,
|
|
36
|
+
getLibraryMaterialsVersion,
|
|
37
|
+
)
|
|
38
|
+
const availableCategories = MATERIAL_CATEGORIES.filter(
|
|
39
|
+
(category) => getMaterialsForCategory(category).length > 0,
|
|
40
|
+
)
|
|
41
|
+
const visibleSourceFilters = SOURCE_FILTERS.filter(
|
|
42
|
+
(filter) =>
|
|
43
|
+
filter.id !== 'workspace' ||
|
|
44
|
+
getDynamicLibraryMaterials().some((item) => item.source === 'workspace'),
|
|
45
|
+
)
|
|
46
|
+
const itemsFor = (category: typeof selectedCategory) =>
|
|
47
|
+
getMaterialsForCategory(category).filter((item) => (item.source ?? 'pascal') === sourceFilter)
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
const entry = getCatalogMaterialById(
|
|
50
|
+
getLibraryMaterialIdFromRef(selectedMaterialPreset) ?? undefined,
|
|
51
|
+
)
|
|
52
|
+
if (entry?.category) setCategory(entry.category)
|
|
53
|
+
}, [selectedMaterialPreset])
|
|
54
|
+
const select = (id: string) => {
|
|
55
|
+
if (!disabled) onSelectMaterialPreset?.(toLibraryMaterialRef(id))
|
|
56
|
+
}
|
|
57
|
+
const setSelectedCategory = (category: typeof selectedCategory) => {
|
|
58
|
+
setCategory(category)
|
|
59
|
+
const first = itemsFor(category)[0]
|
|
60
|
+
if (first) select(first.id)
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
selectedCategory,
|
|
64
|
+
setSelectedCategory,
|
|
65
|
+
sourceFilter,
|
|
66
|
+
setSourceFilter,
|
|
67
|
+
visibleSourceFilters,
|
|
68
|
+
availableCategories,
|
|
69
|
+
catalogItems: itemsFor(selectedCategory),
|
|
70
|
+
select,
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type AnyNodeId,
|
|
5
|
+
generateSceneMaterialId,
|
|
6
|
+
toSceneMaterialRef,
|
|
7
|
+
useScene,
|
|
8
|
+
} from '@pascal-app/core'
|
|
9
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
10
|
+
import { useEffect } from 'react'
|
|
11
|
+
import useEditor from '../store/use-editor'
|
|
12
|
+
import { buildResetSurfaceMaterialUpdates, resolvePaintTargetFromSelection } from './material-paint'
|
|
13
|
+
|
|
14
|
+
export function useMaterialPaintPanelModel(enabled = true) {
|
|
15
|
+
const activePaintMaterial = useEditor((state) => state.activePaintMaterial)
|
|
16
|
+
const activePaintTarget = useEditor((state) => state.activePaintTarget)
|
|
17
|
+
const setActivePaintMaterial = useEditor((state) => state.armMaterialPaint)
|
|
18
|
+
const setActivePaintTarget = useEditor((state) => state.setActivePaintTarget)
|
|
19
|
+
const paintEraser = useEditor((state) => state.paintEraser)
|
|
20
|
+
const setPaintEraser = useEditor((state) => state.setPaintEraser)
|
|
21
|
+
const selectedId = useViewer((state) =>
|
|
22
|
+
state.selection.selectedIds.length === 1 ? (state.selection.selectedIds[0] ?? null) : null,
|
|
23
|
+
)
|
|
24
|
+
const paintTarget = useScene((state) =>
|
|
25
|
+
resolvePaintTargetFromSelection({ nodes: state.nodes, selectedId }),
|
|
26
|
+
)
|
|
27
|
+
const materials = useScene((state) => state.materials)
|
|
28
|
+
const canResetSelection = paintTarget !== null
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (enabled && paintTarget) setActivePaintTarget(paintTarget)
|
|
31
|
+
}, [enabled, paintTarget, setActivePaintTarget])
|
|
32
|
+
const resetSelection = () => {
|
|
33
|
+
const scene = useScene.getState()
|
|
34
|
+
const node = selectedId ? scene.nodes[selectedId as AnyNodeId] : undefined
|
|
35
|
+
if (node) scene.updateNodes(buildResetSurfaceMaterialUpdates(scene.nodes, node))
|
|
36
|
+
}
|
|
37
|
+
const createCustomMaterial = () => {
|
|
38
|
+
const scene = useScene.getState()
|
|
39
|
+
const id = generateSceneMaterialId()
|
|
40
|
+
scene.addSceneMaterial({
|
|
41
|
+
id,
|
|
42
|
+
name: `Material ${Object.keys(scene.materials).length + 1}`,
|
|
43
|
+
material: {
|
|
44
|
+
preset: 'custom',
|
|
45
|
+
properties: {
|
|
46
|
+
color: '#ffffff',
|
|
47
|
+
roughness: 0.5,
|
|
48
|
+
metalness: 0,
|
|
49
|
+
opacity: 1,
|
|
50
|
+
transparent: false,
|
|
51
|
+
side: 'front',
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
})
|
|
55
|
+
setActivePaintMaterial({
|
|
56
|
+
materialPreset: toSceneMaterialRef(id),
|
|
57
|
+
sourceTarget: useEditor.getState().activePaintTarget,
|
|
58
|
+
})
|
|
59
|
+
return id
|
|
60
|
+
}
|
|
61
|
+
const selectMaterial = (materialPreset: string) =>
|
|
62
|
+
setActivePaintMaterial({ materialPreset, sourceTarget: useEditor.getState().activePaintTarget })
|
|
63
|
+
return {
|
|
64
|
+
activePaintMaterial,
|
|
65
|
+
activePaintTarget,
|
|
66
|
+
setActivePaintMaterial,
|
|
67
|
+
paintEraser,
|
|
68
|
+
setPaintEraser,
|
|
69
|
+
materials,
|
|
70
|
+
materialCount: Object.keys(materials).length,
|
|
71
|
+
canResetSelection,
|
|
72
|
+
resetSelection,
|
|
73
|
+
createCustomMaterial,
|
|
74
|
+
selectMaterial,
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { test } from 'bun:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { Group } from 'three'
|
|
4
|
+
import { copyOverlayWorldTransform } from './overlay-transform'
|
|
5
|
+
|
|
6
|
+
test('opening overlays retain the source transform through God-mode zoom and rotation', () => {
|
|
7
|
+
const model = new Group()
|
|
8
|
+
const wall = new Group()
|
|
9
|
+
const opening = new Group()
|
|
10
|
+
model.add(wall)
|
|
11
|
+
wall.add(opening)
|
|
12
|
+
wall.rotation.y = 0.8
|
|
13
|
+
opening.position.set(2, 1.5, 0.1)
|
|
14
|
+
const portal = new Group()
|
|
15
|
+
portal.position.set(4, 1, -2)
|
|
16
|
+
portal.rotation.y = -0.4
|
|
17
|
+
const overlay = new Group()
|
|
18
|
+
portal.add(overlay)
|
|
19
|
+
for (const scale of [0.01, 0.2, 1, 4, 0.05]) {
|
|
20
|
+
model.scale.set(scale, scale * 2, scale)
|
|
21
|
+
model.rotation.y += 0.2
|
|
22
|
+
copyOverlayWorldTransform(opening, overlay)
|
|
23
|
+
overlay.updateWorldMatrix(true, false)
|
|
24
|
+
overlay.matrixWorld.elements.forEach((value, i) => {
|
|
25
|
+
assert.ok(Math.abs(value - opening.matrixWorld.elements[i]!) < 1e-8)
|
|
26
|
+
})
|
|
27
|
+
}
|
|
28
|
+
})
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Object3D } from 'three'
|
|
2
|
+
|
|
3
|
+
/** Preserve scale and shear when an overlay is portalled outside its source hierarchy. */
|
|
4
|
+
export function copyOverlayWorldTransform(source: Object3D, overlay: Object3D) {
|
|
5
|
+
source.updateWorldMatrix(true, false)
|
|
6
|
+
overlay.parent?.updateWorldMatrix(true, false)
|
|
7
|
+
overlay.matrixAutoUpdate = false
|
|
8
|
+
if (overlay.parent)
|
|
9
|
+
overlay.matrix.copy(overlay.parent.matrixWorld).invert().multiply(source.matrixWorld)
|
|
10
|
+
else overlay.matrix.copy(source.matrixWorld)
|
|
11
|
+
overlay.matrixWorldNeedsUpdate = true
|
|
12
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { AnyNode, AnyNodeDefinition, AnyNodeId } from '@pascal-app/core'
|
|
2
|
+
|
|
3
|
+
export type PanelIcon = { color?: string; src?: string }
|
|
4
|
+
|
|
5
|
+
export type PanelAction = {
|
|
6
|
+
icon?: PanelIcon
|
|
7
|
+
disabled?: boolean
|
|
8
|
+
id: string
|
|
9
|
+
label: string
|
|
10
|
+
onSelect?: () => void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type PanelRow = { section?: string } & (
|
|
14
|
+
| {
|
|
15
|
+
id: string
|
|
16
|
+
kind: 'action'
|
|
17
|
+
icon?: PanelIcon
|
|
18
|
+
label: string
|
|
19
|
+
onSelect?: () => void
|
|
20
|
+
disabled?: boolean
|
|
21
|
+
}
|
|
22
|
+
| {
|
|
23
|
+
actions: PanelAction[]
|
|
24
|
+
id: string
|
|
25
|
+
kind: 'actions'
|
|
26
|
+
}
|
|
27
|
+
| {
|
|
28
|
+
id: string
|
|
29
|
+
kind: 'choice'
|
|
30
|
+
label: string
|
|
31
|
+
onSelect?: () => void
|
|
32
|
+
value: string
|
|
33
|
+
}
|
|
34
|
+
| {
|
|
35
|
+
getValue: () => string
|
|
36
|
+
id: string
|
|
37
|
+
kind: 'subscribed-choice'
|
|
38
|
+
label: string
|
|
39
|
+
onSelect?: () => void
|
|
40
|
+
subscribe: (listener: () => void) => () => void
|
|
41
|
+
}
|
|
42
|
+
| {
|
|
43
|
+
id: string
|
|
44
|
+
kind: 'cycle'
|
|
45
|
+
label: string
|
|
46
|
+
next: () => void
|
|
47
|
+
previous: () => void
|
|
48
|
+
value: string
|
|
49
|
+
}
|
|
50
|
+
| {
|
|
51
|
+
id: string
|
|
52
|
+
kind: 'stepper'
|
|
53
|
+
label: string
|
|
54
|
+
max: number
|
|
55
|
+
min: number
|
|
56
|
+
onChange: (value: number) => void
|
|
57
|
+
step: number
|
|
58
|
+
unit?: string
|
|
59
|
+
value: number
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
export const PANEL_MODEL_EXTENSION = 'pascal:editor/panel-model'
|
|
64
|
+
|
|
65
|
+
export type NodePanelModel<N extends AnyNode = AnyNode> = {
|
|
66
|
+
rows: (context: {
|
|
67
|
+
node: N
|
|
68
|
+
nodes: Record<AnyNodeId, AnyNode>
|
|
69
|
+
update: (patch: Partial<N>) => void
|
|
70
|
+
}) => PanelRow[]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function getNodePanelModel(definition: AnyNodeDefinition | undefined) {
|
|
74
|
+
return definition?.extensions?.[PANEL_MODEL_EXTENSION] as NodePanelModel | undefined
|
|
75
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test'
|
|
2
|
+
import { createToolHintsStore } from './panel-tool-options'
|
|
3
|
+
|
|
4
|
+
test('tool hints share subscriptions, react to visibility and chip state, and unsubscribe', () => {
|
|
5
|
+
let visible = false
|
|
6
|
+
let value = 'auto'
|
|
7
|
+
let subscriptions = 0
|
|
8
|
+
let cleanups = 0
|
|
9
|
+
const subscribe = (_listener: () => void) => {
|
|
10
|
+
subscriptions++
|
|
11
|
+
return () => {
|
|
12
|
+
cleanups++
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
const store = createToolHintsStore([
|
|
16
|
+
{
|
|
17
|
+
key: 'P',
|
|
18
|
+
label: 'Placement',
|
|
19
|
+
visible: { subscribe, value: () => visible },
|
|
20
|
+
chip: { subscribe, value: () => value, cycle: () => {}, labels: { auto: 'Auto' } },
|
|
21
|
+
},
|
|
22
|
+
])
|
|
23
|
+
const cleanup = store.subscribe(() => {})
|
|
24
|
+
expect(subscriptions).toBe(1)
|
|
25
|
+
const hidden = store.getSnapshot()
|
|
26
|
+
visible = true
|
|
27
|
+
expect(store.getSnapshot()).not.toBe(hidden)
|
|
28
|
+
const shown = store.getSnapshot()
|
|
29
|
+
value = 'roof'
|
|
30
|
+
expect(store.getSnapshot()).not.toBe(shown)
|
|
31
|
+
expect(store.getSnapshot()).toBe(store.getSnapshot())
|
|
32
|
+
cleanup()
|
|
33
|
+
expect(cleanups).toBe(1)
|
|
34
|
+
})
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { nodeRegistry, type ToolHint, useRegistryVersion } from '@pascal-app/core'
|
|
4
|
+
import { useMemo, useSyncExternalStore } from 'react'
|
|
5
|
+
import useEditor from '../store/use-editor'
|
|
6
|
+
|
|
7
|
+
export type PanelToolOption = {
|
|
8
|
+
id: string
|
|
9
|
+
label: string
|
|
10
|
+
value: string
|
|
11
|
+
choices: readonly { value: string; label: string; description?: string }[]
|
|
12
|
+
set: (value: string) => void
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function createToolHintsStore(hints: readonly ToolHint[]) {
|
|
16
|
+
return {
|
|
17
|
+
subscribe(listener: () => void) {
|
|
18
|
+
const subscriptions = hints.flatMap((hint) => [hint.chip?.subscribe, hint.visible?.subscribe])
|
|
19
|
+
const cleanups = [...new Set(subscriptions)].flatMap((subscribe) =>
|
|
20
|
+
subscribe ? [subscribe(listener)] : [],
|
|
21
|
+
)
|
|
22
|
+
return () => {
|
|
23
|
+
for (const cleanup of cleanups) cleanup()
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
getSnapshot: () =>
|
|
27
|
+
JSON.stringify(hints.map((hint) => [hint.visible?.value() ?? true, hint.chip?.value()])),
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function useVisibleToolHints(hints: readonly ToolHint[] = []) {
|
|
32
|
+
const draftVertexCount = useEditor((state) => state.draftVertexCount)
|
|
33
|
+
const store = useMemo(() => createToolHintsStore(hints), [hints])
|
|
34
|
+
useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot)
|
|
35
|
+
return hints.filter(
|
|
36
|
+
(hint) =>
|
|
37
|
+
!(hint.key === 'Shift' && hint.label === 'Cycle snapping mode') &&
|
|
38
|
+
(hint.visible?.value() ?? true) &&
|
|
39
|
+
(hint.minDraftVertices == null || draftVertexCount >= hint.minDraftVertices),
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function usePanelToolHints(kind: string | null | undefined) {
|
|
44
|
+
useRegistryVersion()
|
|
45
|
+
return useVisibleToolHints(kind ? nodeRegistry.get(kind)?.toolHints : undefined)
|
|
46
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AnyNode,
|
|
3
|
+
type AnyNodeId,
|
|
4
|
+
nodeRegistry,
|
|
5
|
+
type ParametricDescriptor,
|
|
6
|
+
useScene,
|
|
7
|
+
} from '@pascal-app/core'
|
|
8
|
+
|
|
9
|
+
export function commitParametricNodeFields(
|
|
10
|
+
nodeId: AnyNodeId,
|
|
11
|
+
requestedPatch: Partial<AnyNode>,
|
|
12
|
+
): void {
|
|
13
|
+
const scene = useScene.getState()
|
|
14
|
+
const node = scene.nodes[nodeId]
|
|
15
|
+
if (!node) return
|
|
16
|
+
|
|
17
|
+
const parametrics = nodeRegistry.get(node.type)?.parametrics as
|
|
18
|
+
| ParametricDescriptor<AnyNode>
|
|
19
|
+
| undefined
|
|
20
|
+
let patch = requestedPatch as Record<string, unknown>
|
|
21
|
+
if (parametrics?.derive) {
|
|
22
|
+
const next = { ...node, ...patch } as AnyNode
|
|
23
|
+
patch = {
|
|
24
|
+
...patch,
|
|
25
|
+
...parametrics.derive(next, patch as Partial<AnyNode>, node),
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const updates: { id: AnyNodeId; data: Partial<AnyNode> }[] = [
|
|
30
|
+
{ id: nodeId, data: patch as Partial<AnyNode> },
|
|
31
|
+
]
|
|
32
|
+
if (parametrics?.reconcile) {
|
|
33
|
+
const next = { ...node, ...patch } as AnyNode
|
|
34
|
+
updates.push(...parametrics.reconcile(node, next))
|
|
35
|
+
}
|
|
36
|
+
scene.updateNodes(updates)
|
|
37
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { nodeRegistry, registerNode, useScene } from '@pascal-app/core'
|
|
3
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
4
|
+
import { z } from 'zod'
|
|
5
|
+
import useEditor from '../store/use-editor'
|
|
6
|
+
import { normalizeSceneGraphNodes, syncEditorSelectionFromCurrentScene } from './scene'
|
|
7
|
+
|
|
8
|
+
const building = {
|
|
9
|
+
children: ['level_scene-root'],
|
|
10
|
+
id: 'building_scene-root',
|
|
11
|
+
object: 'node',
|
|
12
|
+
parentId: null,
|
|
13
|
+
position: [0, 0, 0],
|
|
14
|
+
rotation: [0, 0, 0],
|
|
15
|
+
type: 'building',
|
|
16
|
+
visible: true,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const level = {
|
|
20
|
+
children: ['wall_scene-root'],
|
|
21
|
+
id: 'level_scene-root',
|
|
22
|
+
level: 0,
|
|
23
|
+
object: 'node',
|
|
24
|
+
parentId: building.id,
|
|
25
|
+
type: 'level',
|
|
26
|
+
visible: true,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const wall = {
|
|
30
|
+
children: [],
|
|
31
|
+
end: [4, 0],
|
|
32
|
+
id: 'wall_scene-root',
|
|
33
|
+
object: 'node',
|
|
34
|
+
parentId: level.id,
|
|
35
|
+
start: [0, 0],
|
|
36
|
+
type: 'wall',
|
|
37
|
+
visible: true,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('scene selection synchronization', () => {
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
useViewer.getState().resetSelection()
|
|
43
|
+
useEditor.setState({ mode: 'select', phase: 'site', tool: null })
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('enters the first level when a scene graph is rooted at a building', () => {
|
|
47
|
+
useScene.setState({
|
|
48
|
+
nodes: {
|
|
49
|
+
[building.id]: building,
|
|
50
|
+
[level.id]: level,
|
|
51
|
+
[wall.id]: wall,
|
|
52
|
+
},
|
|
53
|
+
rootNodeIds: [building.id],
|
|
54
|
+
} as never)
|
|
55
|
+
|
|
56
|
+
syncEditorSelectionFromCurrentScene()
|
|
57
|
+
|
|
58
|
+
expect(useViewer.getState().selection).toMatchObject({
|
|
59
|
+
buildingId: building.id,
|
|
60
|
+
levelId: level.id,
|
|
61
|
+
})
|
|
62
|
+
expect(useEditor.getState().phase).toBe('structure')
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
describe('scene graph normalization', () => {
|
|
67
|
+
test('materializes registered schema defaults before the graph reaches renderers', () => {
|
|
68
|
+
const restoreRegistry = nodeRegistry._snapshot()
|
|
69
|
+
try {
|
|
70
|
+
registerNode({
|
|
71
|
+
kind: 'test-scene-normalization',
|
|
72
|
+
schema: z.object({
|
|
73
|
+
id: z.string(),
|
|
74
|
+
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
|
75
|
+
rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
|
76
|
+
type: z.literal('test-scene-normalization'),
|
|
77
|
+
}),
|
|
78
|
+
schemaVersion: 1,
|
|
79
|
+
} as never)
|
|
80
|
+
|
|
81
|
+
expect(
|
|
82
|
+
normalizeSceneGraphNodes({
|
|
83
|
+
test: { id: 'test', type: 'test-scene-normalization' },
|
|
84
|
+
unknown: { id: 'unknown', type: 'unknown-kind', custom: true },
|
|
85
|
+
}),
|
|
86
|
+
).toEqual({
|
|
87
|
+
test: {
|
|
88
|
+
id: 'test',
|
|
89
|
+
position: [0, 0, 0],
|
|
90
|
+
rotation: [0, 0, 0],
|
|
91
|
+
type: 'test-scene-normalization',
|
|
92
|
+
},
|
|
93
|
+
unknown: { id: 'unknown', type: 'unknown-kind', custom: true },
|
|
94
|
+
})
|
|
95
|
+
} finally {
|
|
96
|
+
restoreRegistry()
|
|
97
|
+
}
|
|
98
|
+
})
|
|
99
|
+
})
|
package/src/lib/scene.ts
CHANGED
|
@@ -276,9 +276,13 @@ function getRestoredSelectionForScene(
|
|
|
276
276
|
export function syncEditorSelectionFromCurrentScene() {
|
|
277
277
|
const sceneNodes = useScene.getState().nodes as Record<string, any>
|
|
278
278
|
const sceneRootIds = useScene.getState().rootNodeIds
|
|
279
|
-
const siteNode = sceneRootIds[0] ? sceneNodes[sceneRootIds[0]] : null
|
|
280
279
|
const resolve = (child: any) => (typeof child === 'string' ? sceneNodes[child] : child)
|
|
281
|
-
const
|
|
280
|
+
const rootNodes = sceneRootIds.map((id) => sceneNodes[id]).filter(Boolean)
|
|
281
|
+
const firstBuilding =
|
|
282
|
+
rootNodes.find((node) => node.type === 'building') ??
|
|
283
|
+
rootNodes
|
|
284
|
+
.flatMap((node) => (Array.isArray(node.children) ? node.children.map(resolve) : []))
|
|
285
|
+
.find((node) => node?.type === 'building')
|
|
282
286
|
const firstLevel = firstBuilding?.children?.map(resolve).find((n: any) => n?.type === 'level')
|
|
283
287
|
const restoredEditorUiState = normalizePersistedEditorUiState(useEditor.getState())
|
|
284
288
|
const shouldRestoreEditorUiState = hasCustomPersistedEditorUiState(restoredEditorUiState)
|
|
@@ -396,11 +400,25 @@ function hasUsableSceneGraph(sceneGraph?: SceneGraph | null): sceneGraph is Scen
|
|
|
396
400
|
)
|
|
397
401
|
}
|
|
398
402
|
|
|
403
|
+
export function normalizeSceneGraphNodes(
|
|
404
|
+
nodes: Readonly<Record<string, unknown>>,
|
|
405
|
+
): Record<string, unknown> {
|
|
406
|
+
return Object.fromEntries(
|
|
407
|
+
Object.entries(nodes).map(([id, value]) => {
|
|
408
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return [id, value]
|
|
409
|
+
const type = (value as { type?: unknown }).type
|
|
410
|
+
if (typeof type !== 'string') return [id, value]
|
|
411
|
+
const parsed = nodeRegistry.get(type)?.schema.safeParse(value)
|
|
412
|
+
return [id, parsed?.success ? parsed.data : value]
|
|
413
|
+
}),
|
|
414
|
+
)
|
|
415
|
+
}
|
|
416
|
+
|
|
399
417
|
export function applySceneGraphToEditor(sceneGraph?: SceneGraph | null) {
|
|
400
418
|
const defaultInstalledPlugins = editorHostPanelRegistry.getDefaultInstalledPluginIds()
|
|
401
419
|
if (hasUsableSceneGraph(sceneGraph)) {
|
|
402
420
|
const { nodes, rootNodeIds, collections, materials, installedPlugins } = sceneGraph
|
|
403
|
-
useScene.getState().setScene(nodes as any, rootNodeIds as any, {
|
|
421
|
+
useScene.getState().setScene(normalizeSceneGraphNodes(nodes) as any, rootNodeIds as any, {
|
|
404
422
|
collections: collections as any,
|
|
405
423
|
materials: materials as any,
|
|
406
424
|
installedPlugins: installedPlugins ?? defaultInstalledPlugins,
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { describe, expect, it } from 'bun:test'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
GROUP_MOVE_DRAG_LABEL,
|
|
4
|
+
GROUP_ROTATE_DRAG_LABEL,
|
|
5
|
+
ROTATE_HANDLE_DRAG_LABEL,
|
|
6
|
+
} from './contextual-help'
|
|
3
7
|
import {
|
|
4
8
|
cycleSnappingModeIn,
|
|
5
9
|
DEFAULT_SNAPPING_MODE,
|
|
@@ -49,6 +53,12 @@ describe('resolveSnapFlags', () => {
|
|
|
49
53
|
})
|
|
50
54
|
|
|
51
55
|
describe('per-context snapping', () => {
|
|
56
|
+
it('rotation defaults to angles and cycles independently to free rotation', () => {
|
|
57
|
+
expect(defaultSnappingModeFor('rotation')).toBe('angles')
|
|
58
|
+
expect(snappingModesFor('rotation')).toEqual(['angles', 'off'])
|
|
59
|
+
expect(cycleSnappingModeIn('rotation', 'angles')).toBe('off')
|
|
60
|
+
expect(cycleSnappingModeIn('rotation', 'off')).toBe('angles')
|
|
61
|
+
})
|
|
52
62
|
it('items default to magnetic alignment with no angle lock', () => {
|
|
53
63
|
expect(defaultSnappingModeFor('item')).toBe('lines')
|
|
54
64
|
expect(snappingModesFor('item')).toEqual(['lines', 'grid', 'off'])
|
|
@@ -132,7 +142,8 @@ describe('snapContextOf (profile-driven, node-declared)', () => {
|
|
|
132
142
|
expect(ctx({ kind: 'handle-drag', nodeId: 'unknown_1' })).toBeNull()
|
|
133
143
|
expect(
|
|
134
144
|
ctx({ kind: 'handle-drag', nodeId: 'cabinet-module_1', handle: ROTATE_HANDLE_DRAG_LABEL }),
|
|
135
|
-
).
|
|
145
|
+
).toBe('rotation')
|
|
146
|
+
expect(ctx({ kind: 'handle-drag', handle: GROUP_ROTATE_DRAG_LABEL })).toBe('rotation')
|
|
136
147
|
})
|
|
137
148
|
|
|
138
149
|
it('gives mesh rotation the angle context and other edit operations polygon snapping', () => {
|
package/src/lib/snapping-mode.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import type { SnapProfile } from '@pascal-app/core'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
GROUP_MOVE_DRAG_LABEL,
|
|
4
|
+
GROUP_ROTATE_DRAG_LABEL,
|
|
5
|
+
ROTATE_HANDLE_DRAG_LABEL,
|
|
6
|
+
} from './contextual-help'
|
|
3
7
|
|
|
4
8
|
/**
|
|
5
9
|
* Snapping mode is a single global, user-cyclable control that maps onto the
|
|
@@ -68,14 +72,13 @@ export function nextSnappingMode(mode: SnappingMode): SnappingMode {
|
|
|
68
72
|
// alignment lines). The mode is remembered per context and shown live, so it's
|
|
69
73
|
// never a silent surprise — it just matches what you're doing.
|
|
70
74
|
|
|
71
|
-
export type SnapContext = 'wall' | 'item' | 'polygon'
|
|
75
|
+
export type SnapContext = 'wall' | 'item' | 'polygon' | 'rotation'
|
|
72
76
|
|
|
73
77
|
// The cyclable mode-set for a context (distinct from the node's `SnapProfile`).
|
|
74
78
|
type SnapModeSet = { modes: SnappingMode[]; default: SnappingMode }
|
|
75
79
|
|
|
76
80
|
// `modes[0]` is the cycle's first entry; `default` is what a context starts at.
|
|
77
|
-
// The 'wall' set
|
|
78
|
-
// you're setting a segment's DIRECTION (wall/fence drafting + endpoint drag).
|
|
81
|
+
// The 'wall' set includes angle lock when setting a segment's direction.
|
|
79
82
|
// Translating a whole wall, curving it, or drawing/moving a slab can't change an
|
|
80
83
|
// angle, so those use the no-angle 'polygon' set.
|
|
81
84
|
const SNAP_PROFILES: Record<SnapContext, SnapModeSet> = {
|
|
@@ -88,9 +91,10 @@ const SNAP_PROFILES: Record<SnapContext, SnapModeSet> = {
|
|
|
88
91
|
// whole wall/fence translate, curve reshape, polygon boundary edit. Grid by
|
|
89
92
|
// default, NO angle lock.
|
|
90
93
|
polygon: { modes: ['grid', 'lines', 'off'], default: 'grid' },
|
|
94
|
+
rotation: { modes: ['angles', 'off'], default: 'angles' },
|
|
91
95
|
}
|
|
92
96
|
|
|
93
|
-
export const SNAP_CONTEXTS: SnapContext[] = ['wall', 'item', 'polygon']
|
|
97
|
+
export const SNAP_CONTEXTS: SnapContext[] = ['wall', 'item', 'polygon', 'rotation']
|
|
94
98
|
|
|
95
99
|
export function snappingModesFor(context: SnapContext): SnappingMode[] {
|
|
96
100
|
return SNAP_PROFILES[context].modes
|
|
@@ -171,7 +175,8 @@ export function snapContextOf(args: {
|
|
|
171
175
|
? contextForProfile(profileOfNode?.(scope.nodeId), scope.operator === 'rotate')
|
|
172
176
|
: null
|
|
173
177
|
case 'handle-drag':
|
|
174
|
-
if (scope.handle === ROTATE_HANDLE_DRAG_LABEL
|
|
178
|
+
if (scope.handle === ROTATE_HANDLE_DRAG_LABEL || scope.handle === GROUP_ROTATE_DRAG_LABEL)
|
|
179
|
+
return 'rotation'
|
|
175
180
|
return scope.nodeId ? contextForProfile(profileOfNode?.(scope.nodeId), false) : null
|
|
176
181
|
case 'placing':
|
|
177
182
|
case 'moving':
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test'
|
|
2
|
+
import { Matrix4, Ray, Vector3 } from 'three'
|
|
3
|
+
import {
|
|
4
|
+
createSpatialDragPlane,
|
|
5
|
+
intersectSpatialDragPlane,
|
|
6
|
+
spatialDragLocalY,
|
|
7
|
+
} from './spatial-drag-plane'
|
|
8
|
+
|
|
9
|
+
test('controller translation gives continuous motion at the grabbed height', () => {
|
|
10
|
+
const start = new Vector3(2, 2.4, 1)
|
|
11
|
+
const ray = new Ray(new Vector3(2, 3.4, 2), new Vector3(0, -1, -1).normalize())
|
|
12
|
+
const plane = createSpatialDragPlane(start, ray)
|
|
13
|
+
for (let step = 0; step <= 10; step++) {
|
|
14
|
+
const moved = ray.clone()
|
|
15
|
+
moved.origin.z += step / 10
|
|
16
|
+
const hit = intersectSpatialDragPlane(moved, plane, new Vector3())!
|
|
17
|
+
expect(hit.x).toBeCloseTo(2)
|
|
18
|
+
expect(hit.y).toBeCloseTo(2.4)
|
|
19
|
+
expect(hit.z).toBeCloseTo(1 + step / 10)
|
|
20
|
+
}
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('horizontal rays use a stable facing plane and grazing rays are ignored', () => {
|
|
24
|
+
const ray = new Ray(new Vector3(0, 1, 2), new Vector3(0, 0, -1))
|
|
25
|
+
const plane = createSpatialDragPlane(new Vector3(0, 1, 0), ray)
|
|
26
|
+
ray.origin.x = 0.1
|
|
27
|
+
expect(intersectSpatialDragPlane(ray, plane, new Vector3())?.x).toBeCloseTo(0.1)
|
|
28
|
+
ray.direction.set(1, 0, -0.001).normalize()
|
|
29
|
+
expect(intersectSpatialDragPlane(ray, plane, new Vector3())).toBeNull()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('drag plane follows a rotated, scaled building frame', () => {
|
|
33
|
+
const transform = new Matrix4().makeRotationZ(Math.PI / 4).scale(new Vector3(0.2, 0.2, 0.2))
|
|
34
|
+
const point = new Vector3(0, 2, 0).applyMatrix4(transform)
|
|
35
|
+
const up = new Vector3(0, 1, 0).transformDirection(transform)
|
|
36
|
+
const ray = new Ray(point.clone().add(up), up.clone().negate())
|
|
37
|
+
const plane = createSpatialDragPlane(point, ray, transform)
|
|
38
|
+
expect(intersectSpatialDragPlane(ray, plane, new Vector3())?.distanceTo(point)).toBeLessThan(1e-8)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('height drag uses model metres at human and miniature scales without an initial jump', () => {
|
|
42
|
+
for (const scale of [1, 0.2, 2]) {
|
|
43
|
+
const frame = new Matrix4().makeRotationY(0.7).scale(new Vector3(scale, scale, scale))
|
|
44
|
+
frame.setPosition(2, 4, -3)
|
|
45
|
+
const inverse = frame.clone().invert()
|
|
46
|
+
const start = new Vector3(1, 2.5, 3).applyMatrix4(frame)
|
|
47
|
+
const initialY = spatialDragLocalY(start, inverse)
|
|
48
|
+
for (let step = 0; step <= 10; step++) {
|
|
49
|
+
const moved = start.clone().add(new Vector3(0, (scale * step) / 10, 0))
|
|
50
|
+
expect(2.5 + spatialDragLocalY(moved, inverse) - initialY).toBeCloseTo(2.5 + step / 10)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
})
|