@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
@@ -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 firstBuilding = siteNode?.children?.map(resolve).find((n: any) => n?.type === 'building')
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 { GROUP_MOVE_DRAG_LABEL, ROTATE_HANDLE_DRAG_LABEL } from './contextual-help'
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
- ).toBeNull()
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', () => {
@@ -1,5 +1,9 @@
1
1
  import type { SnapProfile } from '@pascal-app/core'
2
- import { GROUP_MOVE_DRAG_LABEL, ROTATE_HANDLE_DRAG_LABEL } from './contextual-help'
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 is the ONLY one with an angle lock — it applies solely when
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) return null
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
+ })