@pascal-app/editor 1.0.0 → 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 (177) hide show
  1. package/package.json +12 -6
  2. package/src/components/editor/bake-exporter.tsx +4 -0
  3. package/src/components/editor/bake-thumbnail.tsx +4 -1
  4. package/src/components/editor/custom-camera-controls.tsx +22 -74
  5. package/src/components/editor/editor-layout-mobile.tsx +1 -0
  6. package/src/components/editor/floating-action-menu.tsx +14 -1
  7. package/src/components/editor/floorplan-panel.tsx +233 -166
  8. package/src/components/editor/grid.tsx +31 -7
  9. package/src/components/editor/group-actions.ts +5 -9
  10. package/src/components/editor/group-floating-action-menu.tsx +19 -9
  11. package/src/components/editor/group-move-3d.ts +4 -6
  12. package/src/components/editor/group-rotate-handle.tsx +81 -35
  13. package/src/components/editor/group-selection-box-3d.tsx +25 -17
  14. package/src/components/editor/group-transform-shared.test.ts +126 -1
  15. package/src/components/editor/group-transform-shared.ts +79 -73
  16. package/src/components/editor/handles/handle-arrow.tsx +72 -2
  17. package/src/components/editor/handles/handle-placement.test.ts +111 -0
  18. package/src/components/editor/handles/handle-placement.ts +69 -0
  19. package/src/components/editor/handles/linear-resize-drag.ts +13 -2
  20. package/src/components/editor/handles/use-handle-drag.ts +80 -8
  21. package/src/components/editor/index.tsx +36 -13
  22. package/src/components/editor/node-action-menu.tsx +2 -0
  23. package/src/components/editor/node-arrow-handles.tsx +162 -69
  24. package/src/components/editor/registry-action-contributions.tsx +53 -0
  25. package/src/components/editor/selection-manager.tsx +49 -0
  26. package/src/components/editor/thumbnail-generator.tsx +11 -2
  27. package/src/components/editor/wall-move-side-handles.tsx +71 -21
  28. package/src/components/editor/wall-opening-highlights.tsx +2 -3
  29. package/src/components/editor-2d/floorplan-group-move.tsx +17 -24
  30. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +33 -12
  31. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
  32. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +154 -49
  33. package/src/components/editor-2d/renderers/floorplan-draft-wall-measurement.tsx +166 -0
  34. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +49 -0
  35. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +113 -24
  36. package/src/components/procedural-items/inspector.tsx +223 -0
  37. package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
  38. package/src/components/systems/zone/zone-system.tsx +8 -4
  39. package/src/components/tools/__fixtures__/hosting.ts +10 -0
  40. package/src/components/tools/item/item-surface-placement.test.ts +169 -0
  41. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +204 -0
  42. package/src/components/tools/item/placement-strategies.ts +212 -200
  43. package/src/components/tools/item/placement-types.ts +4 -0
  44. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
  45. package/src/components/tools/item/use-draft-node.ts +136 -47
  46. package/src/components/tools/item/use-placement-coordinator.tsx +391 -157
  47. package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
  48. package/src/components/tools/registry/item-surface-move.test.ts +509 -0
  49. package/src/components/tools/registry/item-surface-move.ts +463 -0
  50. package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
  51. package/src/components/tools/registry/move-registry-node-tool.tsx +237 -78
  52. package/src/components/tools/shared/affordance-dispatch.test.ts +24 -0
  53. package/src/components/tools/shared/affordance-dispatch.ts +13 -0
  54. package/src/components/tools/shared/draft-measurement-label.tsx +38 -0
  55. package/src/components/tools/shared/pointer-support-cap.test.ts +27 -0
  56. package/src/components/tools/shared/pointer-support-cap.ts +27 -13
  57. package/src/components/tools/shared/shelf-stickiness.ts +54 -0
  58. package/src/components/tools/shared/surface-hit.test.ts +56 -0
  59. package/src/components/tools/shared/surface-hit.ts +23 -0
  60. package/src/components/tools/shared/surface-rejection.test.tsx +107 -0
  61. package/src/components/tools/shared/surface-rejection.tsx +43 -0
  62. package/src/components/tools/tool-manager.tsx +36 -3
  63. package/src/components/tools/zone/zone-tool.tsx +13 -4
  64. package/src/components/ui/command-palette/editor-commands.tsx +54 -1
  65. package/src/components/ui/controls/material-paint-panel.tsx +6 -67
  66. package/src/components/ui/controls/material-picker.tsx +5 -67
  67. package/src/components/ui/controls/metric-control.tsx +29 -13
  68. package/src/components/ui/controls/slider-control.tsx +87 -35
  69. package/src/components/ui/floating-level-selector.tsx +81 -5
  70. package/src/components/ui/helpers/helper-manager.tsx +24 -6
  71. package/src/components/ui/helpers/registered-tool-helper.tsx +2 -32
  72. package/src/components/ui/item-catalog/item-catalog.tsx +5 -30
  73. package/src/components/ui/panels/multi-height-mode.tsx +27 -21
  74. package/src/components/ui/panels/multi-parametric-inspector.tsx +6 -3
  75. package/src/components/ui/panels/parametric-field-control.tsx +1 -1
  76. package/src/components/ui/panels/parametric-inspector.tsx +9 -17
  77. package/src/components/ui/panels/reference-panel.tsx +3 -3
  78. package/src/components/ui/primitives/checkbox.tsx +34 -0
  79. package/src/components/ui/primitives/shortcut-token.tsx +4 -0
  80. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
  81. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +669 -336
  82. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  83. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +31 -4
  84. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +30 -11
  85. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
  86. package/src/components/ui/sidebar/panels/site-panel/index.tsx +106 -4
  87. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
  88. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
  89. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +6 -1
  90. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
  91. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
  92. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
  93. package/src/components/ui/snap-target-badge.tsx +2 -0
  94. package/src/components/viewer/floorplan-preview-visibility.test.ts +35 -0
  95. package/src/components/viewer/floorplan-preview-visibility.ts +19 -0
  96. package/src/components/viewer/floorplan-preview.tsx +1 -12
  97. package/src/components/viewer/viewer-scene-header.tsx +3 -0
  98. package/src/components/viewer/viewer-units-panel.tsx +116 -0
  99. package/src/components/viewer-zone-system.tsx +7 -2
  100. package/src/hooks/use-auto-save.integration.test.tsx +197 -0
  101. package/src/hooks/use-auto-save.ts +21 -6
  102. package/src/hooks/use-ceiling-events.test.ts +25 -2
  103. package/src/hooks/use-ceiling-events.ts +7 -1
  104. package/src/hooks/use-grid-events.test.ts +2 -0
  105. package/src/hooks/use-grid-events.ts +16 -11
  106. package/src/hooks/use-keyboard.test.ts +7 -0
  107. package/src/hooks/use-keyboard.ts +43 -28
  108. package/src/index.tsx +68 -3
  109. package/src/lib/catalog-panel-model.ts +39 -0
  110. package/src/lib/catalog-selection.test.ts +27 -0
  111. package/src/lib/catalog-selection.ts +8 -0
  112. package/src/lib/contextual-help.test.ts +27 -1
  113. package/src/lib/contextual-help.ts +16 -9
  114. package/src/lib/export-texture-utils.test.ts +167 -0
  115. package/src/lib/export-texture-utils.ts +121 -0
  116. package/src/lib/export-user-data.test.ts +48 -0
  117. package/src/lib/export-user-data.ts +69 -0
  118. package/src/lib/floorplan/floorplan-export.test.ts +142 -2
  119. package/src/lib/floorplan/floorplan-export.tsx +9 -3
  120. package/src/lib/floorplan/floorplan-extension.ts +16 -0
  121. package/src/lib/floorplan/floorplan-readonly.ts +4 -0
  122. package/src/lib/fresh-planar-placement.ts +173 -33
  123. package/src/lib/glb-export-serialize.test.ts +280 -0
  124. package/src/lib/glb-export.test.ts +265 -16
  125. package/src/lib/glb-export.ts +197 -37
  126. package/src/lib/grid-frame.test.ts +19 -0
  127. package/src/lib/grid-frame.ts +18 -0
  128. package/src/lib/interaction/scope.ts +9 -1
  129. package/src/lib/keyboard-pan.test.ts +62 -0
  130. package/src/lib/keyboard-pan.ts +89 -0
  131. package/src/lib/level-duplication.test.ts +73 -0
  132. package/src/lib/level-duplication.ts +32 -2
  133. package/src/lib/linear-display.test.ts +59 -0
  134. package/src/lib/linear-display.ts +39 -0
  135. package/src/lib/local-guide-image.test.ts +24 -0
  136. package/src/lib/local-guide-image.ts +34 -1
  137. package/src/lib/material-catalog-model.ts +72 -0
  138. package/src/lib/material-paint-panel-model.ts +76 -0
  139. package/src/lib/node-action-movement.ts +21 -0
  140. package/src/lib/overlay-transform.test.ts +28 -0
  141. package/src/lib/overlay-transform.ts +12 -0
  142. package/src/lib/panel-rows.ts +75 -0
  143. package/src/lib/panel-tool-options.test.ts +34 -0
  144. package/src/lib/panel-tool-options.ts +46 -0
  145. package/src/lib/parametric-node-update.ts +37 -0
  146. package/src/lib/portable-export.test.ts +89 -0
  147. package/src/lib/portable-export.ts +84 -11
  148. package/src/lib/print-shell-compiler-manifold-core.ts +59 -10
  149. package/src/lib/print-shell-compiler-manifold-worker.ts +14 -1
  150. package/src/lib/print-shell-compiler-manifold.worker.ts +1 -1
  151. package/src/lib/print-shell-compiler-protocol.ts +8 -0
  152. package/src/lib/scene-clipboard.ts +1 -1
  153. package/src/lib/scene.test.ts +99 -0
  154. package/src/lib/scene.ts +21 -3
  155. package/src/lib/send-to-app.test.ts +108 -0
  156. package/src/lib/send-to-app.ts +121 -0
  157. package/src/lib/sfx/movement-tick.ts +37 -0
  158. package/src/lib/snapping-mode.test.ts +13 -2
  159. package/src/lib/snapping-mode.ts +11 -6
  160. package/src/lib/spatial-drag-plane.test.ts +53 -0
  161. package/src/lib/spatial-drag-plane.ts +19 -0
  162. package/src/lib/spatial-pointer-input.test.ts +72 -0
  163. package/src/lib/spatial-pointer-input.ts +62 -0
  164. package/src/lib/spatial-snap.test.ts +47 -0
  165. package/src/lib/surface-attachment.ts +77 -0
  166. package/src/lib/terrain-panel-model.ts +117 -0
  167. package/src/lib/units.ts +254 -0
  168. package/src/lib/usdz-export.ts +43 -16
  169. package/src/lib/use-linear-display.ts +8 -32
  170. package/src/lib/world-grid-snap.test.ts +48 -0
  171. package/src/lib/world-grid-snap.ts +9 -5
  172. package/src/store/rotation-snapping.test.ts +53 -0
  173. package/src/store/use-editor.tsx +10 -2
  174. package/src/store/use-floorplan-draft-preview.ts +10 -1
  175. package/src/store/use-handle-group.ts +4 -0
  176. package/src/store/use-interaction-scope.ts +155 -6
  177. package/src/store/use-send-to-app.ts +21 -0
@@ -0,0 +1,77 @@
1
+ import { type AnyNode, type AnyNodeId, useScene } from '@pascal-app/core'
2
+ import { evaluateRecipe, isProceduralItem } from '@pascal-app/core/procedural-items'
3
+ import { Euler, Quaternion, Vector3 } from 'three'
4
+
5
+ type Pose = { position: [number, number, number]; rotation: [number, number, number] }
6
+
7
+ export function surfaceAttachmentId(node: Pick<AnyNode, 'id' | 'parentId'>): string | null {
8
+ const parent = node.parentId ? useScene.getState().nodes[node.parentId as AnyNodeId] : undefined
9
+ return isProceduralItem(parent) ? (parent.attachments[node.id] ?? null) : null
10
+ }
11
+
12
+ export function surfaceAttachmentUpdates(
13
+ id: AnyNodeId,
14
+ parentId: string | null | undefined,
15
+ surfaceId: string | null,
16
+ ) {
17
+ const nodes = useScene.getState().nodes
18
+ const updates: { id: AnyNodeId; data: Partial<AnyNode> }[] = []
19
+ for (const hostId of new Set([nodes[id]?.parentId, parentId])) {
20
+ const host = hostId ? nodes[hostId as AnyNodeId] : undefined
21
+ if (!isProceduralItem(host)) continue
22
+ const nextId = host.id === parentId ? surfaceId : null
23
+ if ((host.attachments[id] ?? null) === nextId) continue
24
+ const attachments = { ...host.attachments }
25
+ if (nextId === null) delete attachments[id]
26
+ else attachments[id] = nextId
27
+ updates.push({ id: host.id, data: { attachments } })
28
+ }
29
+ return updates
30
+ }
31
+
32
+ export function updateSurfaceNode(
33
+ id: AnyNodeId,
34
+ data: Partial<AnyNode>,
35
+ surfaceId?: string | null,
36
+ ) {
37
+ const node = useScene.getState().nodes[id]
38
+ const parentId = data.parentId === undefined ? node?.parentId : data.parentId
39
+ const selected =
40
+ surfaceId === undefined && node && node.parentId === parentId
41
+ ? surfaceAttachmentId(node)
42
+ : (surfaceId ?? null)
43
+ useScene
44
+ .getState()
45
+ .updateNodes([{ id, data }, ...surfaceAttachmentUpdates(id, parentId, selected)])
46
+ }
47
+
48
+ export function surfaceFramePose(
49
+ parentId: string | null | undefined,
50
+ surfaceId: string | null,
51
+ pose: Pose,
52
+ toStorage: boolean,
53
+ ): Pose {
54
+ const host = parentId ? useScene.getState().nodes[parentId as AnyNodeId] : undefined
55
+ if (!isProceduralItem(host) || surfaceId === null)
56
+ return { position: pose.position, rotation: pose.rotation }
57
+ const surface = evaluateRecipe(host.recipe, host.parameters).surfaces.find(
58
+ (s) => s.id === surfaceId,
59
+ )
60
+ if (!surface) throw new Error('Missing attachment surface')
61
+ const q = new Quaternion().setFromEuler(new Euler(...surface.rotation))
62
+ const position = new Vector3(...pose.position)
63
+ if (toStorage) position.sub(new Vector3(...surface.position)).applyQuaternion(q.clone().invert())
64
+ else position.applyQuaternion(q).add(new Vector3(...surface.position))
65
+ if (!surface.rotation[0] && !surface.rotation[2] && !pose.rotation[0] && !pose.rotation[2]) {
66
+ return {
67
+ position: position.toArray(),
68
+ rotation: [0, pose.rotation[1] + (toStorage ? -1 : 1) * surface.rotation[1], 0],
69
+ }
70
+ }
71
+ const rotation = new Euler().setFromQuaternion(
72
+ new Quaternion()
73
+ .setFromEuler(new Euler(...pose.rotation))
74
+ .premultiply(toStorage ? q.invert() : q),
75
+ )
76
+ return { position: position.toArray(), rotation: [rotation.x, rotation.y, rotation.z] }
77
+ }
@@ -0,0 +1,117 @@
1
+ 'use client'
2
+
3
+ import { type SiteNode, type TerrainVerb, useScene } from '@pascal-app/core'
4
+ import useEditor from '../store/use-editor'
5
+ import type { PanelRow } from './panel-rows'
6
+ import { brushRadiusRange, flattenSite, resetSiteTerrain } from './terrain-sculpt'
7
+
8
+ const TERRAIN_VERBS: TerrainVerb[] = ['raise', 'lower', 'flatten', 'smooth']
9
+
10
+ export function useTerrainPanelRows(): PanelRow[] {
11
+ const verb = useEditor((state) => state.terrainVerb)
12
+ const setVerb = useEditor((state) => state.setTerrainVerb)
13
+ const brush = useEditor((state) => state.terrainBrush)
14
+ const setBrush = useEditor((state) => state.setTerrainBrush)
15
+ const flattenTarget = useEditor((state) => state.terrainFlattenTarget)
16
+ const setFlattenTarget = useEditor((state) => state.setTerrainFlattenTarget)
17
+ const sampling = useEditor((state) => state.terrainSampling)
18
+ const setSampling = useEditor((state) => state.setTerrainSampling)
19
+ const site = useScene((state) => {
20
+ const root = state.rootNodeIds[0]
21
+ const node = root ? state.nodes[root] : undefined
22
+ return node?.type === 'site' ? (node as SiteNode) : null
23
+ })
24
+ const [minRadius, maxRadius] = brushRadiusRange(site)
25
+ const verbIndex = TERRAIN_VERBS.indexOf(verb)
26
+ const cycleVerb = (direction: -1 | 1) => {
27
+ const next =
28
+ TERRAIN_VERBS[(verbIndex + direction + TERRAIN_VERBS.length) % TERRAIN_VERBS.length]
29
+ if (next) setVerb(next)
30
+ }
31
+
32
+ const rows: PanelRow[] = [
33
+ {
34
+ id: 'terrain-verb',
35
+ kind: 'cycle',
36
+ label: 'Brush',
37
+ next: () => cycleVerb(1),
38
+ previous: () => cycleVerb(-1),
39
+ value: verb.charAt(0).toUpperCase() + verb.slice(1),
40
+ },
41
+ {
42
+ id: 'terrain-radius',
43
+ kind: 'stepper',
44
+ label: 'Size',
45
+ max: maxRadius,
46
+ min: minRadius,
47
+ onChange: (radius) => setBrush({ radius }),
48
+ step: 0.5,
49
+ unit: 'm',
50
+ value: Math.max(minRadius, Math.min(maxRadius, brush.radius)),
51
+ },
52
+ {
53
+ id: 'terrain-strength',
54
+ kind: 'stepper',
55
+ label: 'Strength',
56
+ max: 1,
57
+ min: 0.05,
58
+ onChange: (strength) => setBrush({ strength }),
59
+ step: 0.05,
60
+ value: brush.strength,
61
+ },
62
+ {
63
+ id: 'terrain-softness',
64
+ kind: 'stepper',
65
+ label: 'Softness',
66
+ max: 1,
67
+ min: 0,
68
+ onChange: (falloff) => setBrush({ falloff }),
69
+ step: 0.05,
70
+ value: brush.falloff,
71
+ },
72
+ {
73
+ id: 'terrain-shape',
74
+ kind: 'choice',
75
+ label: 'Shape',
76
+ onSelect: () => setBrush({ shape: brush.shape === 'round' ? 'square' : 'round' }),
77
+ value: brush.shape === 'round' ? 'Round' : 'Square',
78
+ },
79
+ ...(verb === 'flatten'
80
+ ? ([
81
+ {
82
+ id: 'terrain-target',
83
+ kind: 'stepper',
84
+ label: 'Target',
85
+ max: 50,
86
+ min: -50,
87
+ onChange: setFlattenTarget,
88
+ step: 0.1,
89
+ unit: 'm',
90
+ value: flattenTarget ?? 0,
91
+ },
92
+ {
93
+ id: 'terrain-sampling',
94
+ kind: 'choice',
95
+ label: 'Pick height',
96
+ onSelect: () => setSampling(!sampling),
97
+ value: sampling ? 'Armed' : 'Off',
98
+ },
99
+ ] satisfies PanelRow[])
100
+ : []),
101
+ {
102
+ id: 'terrain-level-lot',
103
+ kind: 'choice',
104
+ label: 'Whole lot',
105
+ onSelect: site ? () => flattenSite(site, flattenTarget ?? 0) : undefined,
106
+ value: 'Level',
107
+ },
108
+ {
109
+ id: 'terrain-clear',
110
+ kind: 'choice',
111
+ label: 'Terrain',
112
+ onSelect: site?.terrain ? () => resetSiteTerrain(site) : undefined,
113
+ value: site?.terrain ? 'Clear' : 'Empty',
114
+ },
115
+ ]
116
+ return rows
117
+ }
@@ -0,0 +1,254 @@
1
+ import {
2
+ type AnyNode,
3
+ type AnyNodeId,
4
+ type BuildingNode,
5
+ type LevelNode,
6
+ pointInPolygon2D,
7
+ resolveAutoZonePolygon,
8
+ snapWorldXZToBuildingLocal,
9
+ UnitNode,
10
+ useScene,
11
+ type ZoneNode,
12
+ } from '@pascal-app/core'
13
+ import { useViewer } from '@pascal-app/viewer'
14
+ import { useEffect } from 'react'
15
+ import useEditor, { type StructureLayer } from '../store/use-editor'
16
+ import { getActiveBuildingPose } from './world-grid-snap'
17
+
18
+ type Nodes = Readonly<Record<AnyNodeId, AnyNode>>
19
+
20
+ // Unit focus is a session gesture: the layer the user was on when focus began
21
+ // comes back when focus ends, unless they switched layers by hand meanwhile.
22
+ let restoreLayer: StructureLayer | null = null
23
+ let entering = false
24
+ let leaving = false
25
+
26
+ export function focusedUnitNode(): UnitNode | null {
27
+ const id = useViewer.getState().focusedUnitId
28
+ const node = id ? useScene.getState().nodes[id] : undefined
29
+ return node?.type === 'unit' ? node : null
30
+ }
31
+
32
+ /** Levels carrying member zones, lowest ordinal first. */
33
+ export function unitMemberLevels(unit: UnitNode, nodes: Nodes): LevelNode[] {
34
+ const levels = new Map<LevelNode['id'], LevelNode>()
35
+ for (const zoneId of unit.members) {
36
+ const zone = nodes[zoneId]
37
+ const level =
38
+ zone?.type === 'zone' && zone.parentId ? nodes[zone.parentId as AnyNodeId] : undefined
39
+ if (level?.type === 'level') levels.set(level.id, level)
40
+ }
41
+ return [...levels.values()].sort((a, b) => a.level - b.level)
42
+ }
43
+
44
+ export function enterUnitFocus(unitId: UnitNode['id']): void {
45
+ if (entering) return
46
+ entering = true
47
+ try {
48
+ const nodes = useScene.getState().nodes
49
+ const unit = nodes[unitId]
50
+ if (unit?.type !== 'unit') return
51
+ const editor = useEditor.getState()
52
+ if (restoreLayer === null) restoreLayer = editor.structureLayer
53
+ if (editor.phase !== 'structure') editor.setPhase('structure')
54
+ if (useEditor.getState().structureLayer !== 'zones') {
55
+ useEditor.getState().setStructureLayer('zones')
56
+ }
57
+ const viewer = useViewer.getState()
58
+ if (viewer.focusedUnitId !== unitId) viewer.setFocusedUnit(unitId)
59
+
60
+ const { selection, setSelection } = useViewer.getState()
61
+ const levels = unitMemberLevels(unit, nodes)
62
+ const levelId = levels.some((level) => level.id === selection.levelId)
63
+ ? selection.levelId
64
+ : (levels[0]?.id ?? selection.levelId)
65
+ const buildingId = (unit.parentId as BuildingNode['id'] | null) ?? selection.buildingId
66
+ const unitSelected = selection.selectedIds.length === 1 && selection.selectedIds[0] === unitId
67
+ if (selection.buildingId !== buildingId || selection.levelId !== levelId || !unitSelected) {
68
+ setSelection({ buildingId, levelId, selectedIds: [unitId] })
69
+ }
70
+ } finally {
71
+ entering = false
72
+ }
73
+ }
74
+
75
+ function finishLeave(unitId: UnitNode['id'], keepLayer: boolean) {
76
+ const layer = restoreLayer
77
+ restoreLayer = null
78
+ const editor = useEditor.getState()
79
+ if (
80
+ !keepLayer &&
81
+ layer &&
82
+ layer !== 'zones' &&
83
+ editor.phase === 'structure' &&
84
+ editor.structureLayer === 'zones'
85
+ ) {
86
+ editor.setStructureLayer(layer)
87
+ }
88
+ const { selection, setSelection } = useViewer.getState()
89
+ if (selection.selectedIds.length === 1 && selection.selectedIds[0] === unitId) {
90
+ setSelection({ selectedIds: [] })
91
+ }
92
+ }
93
+
94
+ /** Ends unit focus. Returns false when no unit was focused. */
95
+ export function leaveUnitFocus(options?: { keepLayer?: boolean }): boolean {
96
+ const viewer = useViewer.getState()
97
+ const unitId = viewer.focusedUnitId
98
+ if (!unitId) return false
99
+ leaving = true
100
+ try {
101
+ viewer.setFocusedUnit(null)
102
+ } finally {
103
+ leaving = false
104
+ }
105
+ finishLeave(unitId, options?.keepLayer ?? false)
106
+ return true
107
+ }
108
+
109
+ /**
110
+ * Puts a zone in exactly one unit (or none): it leaves every other unit in the
111
+ * same store update, so the move is one undo step.
112
+ */
113
+ export function assignZoneToUnit(zoneId: ZoneNode['id'], unitId: UnitNode['id'] | null): void {
114
+ const { nodes, updateNodes } = useScene.getState()
115
+ const updates: Array<{ id: AnyNodeId; data: Partial<AnyNode> }> = []
116
+ for (const node of Object.values(nodes)) {
117
+ if (node.type !== 'unit' || node.id === unitId || !node.members.includes(zoneId)) continue
118
+ updates.push({ id: node.id, data: { members: node.members.filter((id) => id !== zoneId) } })
119
+ }
120
+ const target = unitId ? nodes[unitId] : undefined
121
+ if (target?.type === 'unit' && !target.members.includes(zoneId)) {
122
+ updates.push({ id: target.id, data: { members: [...target.members, zoneId] } })
123
+ }
124
+ if (updates.length > 0) updateNodes(updates)
125
+ }
126
+
127
+ export function toggleZoneMembership(unitId: UnitNode['id'], zoneId: ZoneNode['id']): void {
128
+ const unit = useScene.getState().nodes[unitId]
129
+ if (unit?.type !== 'unit') return
130
+ assignZoneToUnit(zoneId, unit.members.includes(zoneId) ? null : unitId)
131
+ }
132
+
133
+ /**
134
+ * The zone under a point on the current level, in building-local XZ. A 3D
135
+ * click lands on whatever surface is closest (a floor slab sits above the
136
+ * zone fill), so painting resolves the zone from the hit point instead of the
137
+ * hit mesh. Nested zones resolve to the smallest one.
138
+ */
139
+ export function zoneAtLevelPoint(x: number, z: number): ZoneNode | null {
140
+ const levelId = useViewer.getState().selection.levelId
141
+ if (!levelId) return null
142
+ const nodes = useScene.getState().nodes
143
+ const resolve = (id: AnyNodeId) => nodes[id]
144
+ let best: ZoneNode | null = null
145
+ let bestArea = Number.POSITIVE_INFINITY
146
+ for (const node of Object.values(nodes)) {
147
+ if (node.type !== 'zone' || node.parentId !== levelId) continue
148
+ const polygon = resolveAutoZonePolygon(node, resolve)
149
+ if (!pointInPolygon2D([x, z], polygon)) continue
150
+ let area = 0
151
+ for (let i = 0; i < polygon.length; i++) {
152
+ const a = polygon[i]!
153
+ const b = polygon[(i + 1) % polygon.length]!
154
+ area += a[0] * b[1] - b[0] * a[1]
155
+ }
156
+ area = Math.abs(area) / 2
157
+ if (area < bestArea) {
158
+ best = node
159
+ bestArea = area
160
+ }
161
+ }
162
+ return best
163
+ }
164
+
165
+ export function zoneAtWorldPoint(worldX: number, worldZ: number): ZoneNode | null {
166
+ const pose = getActiveBuildingPose()
167
+ const [x, z] = pose
168
+ ? snapWorldXZToBuildingLocal(worldX, worldZ, pose.position, pose.rotationY, 0).local
169
+ : [worldX, worldZ]
170
+ return zoneAtLevelPoint(x, z)
171
+ }
172
+
173
+ export const ZONE_PAINT_DELAY_MS = 250
174
+ const pendingPaints = new Map<ZoneNode['id'], ReturnType<typeof setTimeout>>()
175
+
176
+ /**
177
+ * The canvas paint gesture. The toggle waits out the double-click window so
178
+ * the second click of a double-click cancels it instead of toggling twice;
179
+ * the double-click itself then selects the zone.
180
+ */
181
+ export function paintZoneMembership(unitId: UnitNode['id'], zoneId: ZoneNode['id']): void {
182
+ if (cancelPendingZonePaint(zoneId)) return
183
+ pendingPaints.set(
184
+ zoneId,
185
+ setTimeout(() => {
186
+ pendingPaints.delete(zoneId)
187
+ toggleZoneMembership(unitId, zoneId)
188
+ }, ZONE_PAINT_DELAY_MS),
189
+ )
190
+ }
191
+
192
+ export function cancelPendingZonePaint(zoneId: ZoneNode['id']): boolean {
193
+ const pending = pendingPaints.get(zoneId)
194
+ if (!pending) return false
195
+ clearTimeout(pending)
196
+ pendingPaints.delete(zoneId)
197
+ return true
198
+ }
199
+
200
+ export function createUnitInBuilding(buildingId: BuildingNode['id']): UnitNode['id'] | null {
201
+ const scene = useScene.getState()
202
+ const building = scene.nodes[buildingId]
203
+ if (building?.type !== 'building') return null
204
+ const unitCount = building.children.filter((id) => scene.nodes[id]?.type === 'unit').length
205
+ const unit = UnitNode.parse({ name: `Unit ${unitCount + 1}` })
206
+ scene.createNode(unit, buildingId)
207
+ enterUnitFocus(unit.id)
208
+ return unit.id
209
+ }
210
+
211
+ /**
212
+ * Keeps unit focus coherent with the rest of the editor: a focus set from
213
+ * anywhere (the unit inspector calls the viewer store directly) gets the full
214
+ * enter treatment, and focus ends when the user switches layer or phase by
215
+ * hand, selects anything other than the focused unit, or the unit is deleted.
216
+ */
217
+ export function useUnitFocusRules(): void {
218
+ useEffect(() => {
219
+ const unsubscribeViewer = useViewer.subscribe((state, prev) => {
220
+ if (state.focusedUnitId !== prev.focusedUnitId) {
221
+ if (state.focusedUnitId) enterUnitFocus(state.focusedUnitId)
222
+ else if (prev.focusedUnitId && !leaving) finishLeave(prev.focusedUnitId, false)
223
+ return
224
+ }
225
+ const unitId = state.focusedUnitId
226
+ if (!unitId || state.selection === prev.selection) return
227
+ // Selecting a zone keeps focus: renaming or recolouring a zone row is
228
+ // part of arranging the unit, and zones live on the layer focus uses.
229
+ const { selectedIds } = state.selection
230
+ const selectsOther =
231
+ selectedIds.length > 0 && !(selectedIds.length === 1 && selectedIds[0] === unitId)
232
+ if (selectsOther) leaveUnitFocus()
233
+ })
234
+ const unsubscribeEditor = useEditor.subscribe((state, prev) => {
235
+ if (state.structureLayer === prev.structureLayer && state.phase === prev.phase) return
236
+ if (!useViewer.getState().focusedUnitId) return
237
+ if (state.phase !== 'structure' || state.structureLayer !== 'zones') {
238
+ leaveUnitFocus({ keepLayer: true })
239
+ }
240
+ })
241
+ const unsubscribeScene = useScene.subscribe((state, prev) => {
242
+ if (state.nodes === prev.nodes) return
243
+ const unitId = useViewer.getState().focusedUnitId
244
+ if (unitId && state.nodes[unitId]?.type !== 'unit') leaveUnitFocus()
245
+ })
246
+ return () => {
247
+ unsubscribeViewer()
248
+ unsubscribeEditor()
249
+ unsubscribeScene()
250
+ for (const pending of pendingPaints.values()) clearTimeout(pending)
251
+ pendingPaints.clear()
252
+ }
253
+ }, [])
254
+ }
@@ -1,13 +1,19 @@
1
1
  import type { AnyNode } from '@pascal-app/core'
2
2
  import type { Object3D } from 'three'
3
3
  import { USDZExporter } from 'three/examples/jsm/exporters/USDZExporter.js'
4
- import * as WebGPUTextureUtils from 'three/examples/jsm/utils/WebGPUTextureUtils.js'
5
- import { type GlbExportOptions, preparePortableSceneFromViewer } from './glb-export'
4
+ import { createExportTextureUtils } from './export-texture-utils'
5
+ import {
6
+ DEFAULT_MODEL_EXPORT_TIMEOUT_MS,
7
+ type GlbExport,
8
+ type GlbExportOptions,
9
+ preparePortableSceneFromViewer,
10
+ withExportDeadline,
11
+ } from './glb-export'
6
12
  import { createUsdzScene, disposeExportResources } from './portable-export'
7
13
 
8
14
  export type UsdzExportOptions = Pick<
9
15
  GlbExportOptions,
10
- 'excludedNodeTypes' | 'includedPresentationIds' | 'onlyVisible' | 'onWarning'
16
+ 'excludedNodeTypes' | 'includedPresentationIds' | 'onlyVisible' | 'onWarning' | 'timeoutMs'
11
17
  >
12
18
 
13
19
  /** Export a native, self-contained USDZ with no glTF conversion fallback. */
@@ -16,23 +22,44 @@ export async function exportSceneToUsdz(
16
22
  nodes: Record<string, AnyNode>,
17
23
  options: UsdzExportOptions = {},
18
24
  ): Promise<Uint8Array<ArrayBuffer>> {
19
- const prepared = await preparePortableSceneFromViewer(sceneGroup, nodes, {
25
+ const textureUtils = createExportTextureUtils()
26
+ const preparation = preparePortableSceneFromViewer(sceneGroup, nodes, {
20
27
  ...options,
21
28
  textures: 'embed',
29
+ animations: 'none',
30
+ textureUtils,
22
31
  })
23
- for (const warning of prepared.warnings) options.onWarning?.(warning)
24
-
25
- let scene: Object3D | null = null
32
+ // Assigned inside the raced closure, so plain `let`s narrow to `never` in
33
+ // the `finally`.
34
+ const run: { prepared: GlbExport | null; scene: Object3D | null; abandoned: boolean } = {
35
+ prepared: null,
36
+ scene: null,
37
+ abandoned: false,
38
+ }
26
39
  try {
27
- scene = createUsdzScene(prepared.scene)
28
- const exporter = new USDZExporter()
29
- exporter.textureUtils = WebGPUTextureUtils
30
- return await exporter.parseAsync(scene, {
31
- onlyVisible: options.onlyVisible ?? true,
32
- quickLookCompatible: true,
33
- })
40
+ return await withExportDeadline(
41
+ (async () => {
42
+ run.prepared = await preparation
43
+ if (run.abandoned) throw new Error('USDZ export abandoned')
44
+ for (const warning of run.prepared.warnings) options.onWarning?.(warning)
45
+ run.scene = createUsdzScene(run.prepared.scene)
46
+ const exporter = new USDZExporter()
47
+ exporter.textureUtils = textureUtils as unknown as USDZExporter['textureUtils']
48
+ return exporter.parseAsync(run.scene, {
49
+ onlyVisible: options.onlyVisible ?? true,
50
+ quickLookCompatible: true,
51
+ })
52
+ })(),
53
+ options.timeoutMs ?? DEFAULT_MODEL_EXPORT_TIMEOUT_MS,
54
+ 'USDZ',
55
+ () => {
56
+ run.abandoned = true
57
+ },
58
+ )
34
59
  } finally {
35
- if (scene) disposeExportResources(scene, { textures: false })
36
- prepared.dispose()
60
+ if (run.scene) disposeExportResources(run.scene, { textures: false })
61
+ if (run.prepared) run.prepared.dispose()
62
+ else preparation.then((late) => late.dispose()).catch(() => {})
63
+ await textureUtils.dispose()
37
64
  }
38
65
  }
@@ -1,40 +1,16 @@
1
1
  'use client'
2
2
 
3
3
  import { useViewer } from '@pascal-app/viewer'
4
- import { useCallback } from 'react'
5
- import { getLinearUnitLabel, linearUnitToMeters, metersToLinearUnit } from './measurements'
4
+ import { useMemo } from 'react'
5
+ import { getLinearDisplay } from './linear-display'
6
6
 
7
- /**
8
- * Shared display/storage conversion for numeric property controls so that
9
- * every length input honors the metric/imperial toggle identically.
10
- *
11
- * Values are always STORED in the field's own unit (meters for `unit === 'm'`).
12
- * When the viewer preference is imperial AND the field is a meter length, the
13
- * value is DISPLAYED (and edited) in feet; otherwise the conversions are the
14
- * identity, so metric fields and non-length units (`'°'`, `'%'`, `'in'`, `''`,
15
- * …) behave exactly as before.
16
- *
17
- * Used by both `SliderControl` and `MetricControl` — keep the two in sync via
18
- * this single source of truth.
19
- */
20
- export function useLinearDisplay(unit: string, precision: number) {
7
+ /** Shared display/input conversion; values and bounds stay in the stored unit. */
8
+ export function useLinearDisplay(unit: string, precision: number, step = 1) {
21
9
  const viewerUnit = useViewer((state) => state.unit)
22
- const isImperial = viewerUnit === 'imperial' && unit === 'm'
23
- const displayUnit = isImperial ? getLinearUnitLabel('imperial') : unit
10
+ const metricNotation = useViewer((state) => state.metricNotation)
24
11
 
25
- const toDisplay = useCallback(
26
- (stored: number) => (isImperial ? metersToLinearUnit(stored, 'imperial') : stored),
27
- [isImperial],
12
+ return useMemo(
13
+ () => getLinearDisplay(unit, viewerUnit, metricNotation, precision, step),
14
+ [unit, viewerUnit, metricNotation, precision, step],
28
15
  )
29
- const toStored = useCallback(
30
- (display: number) => (isImperial ? linearUnitToMeters(display, 'imperial') : display),
31
- [isImperial],
32
- )
33
- // Round a stored value so it lands on a clean number of DISPLAY-unit digits.
34
- const roundStored = useCallback(
35
- (stored: number) => toStored(Number.parseFloat(toDisplay(stored).toFixed(precision))),
36
- [toDisplay, toStored, precision],
37
- )
38
-
39
- return { isImperial, displayUnit, toDisplay, toStored, roundStored }
40
16
  }
@@ -0,0 +1,48 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test'
2
+ import { BuildingNode, LevelNode, useScene } from '@pascal-app/core'
3
+ import { useViewer } from '@pascal-app/viewer'
4
+ import { getActiveBuildingId } from './world-grid-snap'
5
+
6
+ const originalNodes = useScene.getState().nodes
7
+ const originalSelection = useViewer.getState().selection
8
+
9
+ afterEach(() => {
10
+ useScene.setState({ nodes: originalNodes } as never)
11
+ useViewer.setState({ selection: originalSelection } as never)
12
+ })
13
+
14
+ describe('getActiveBuildingId', () => {
15
+ test('prefers the active level owner over a stale selected building', () => {
16
+ const staleBuilding = BuildingNode.parse({})
17
+ const activeBuilding = BuildingNode.parse({})
18
+ const level = LevelNode.parse({ parentId: activeBuilding.id })
19
+ useScene.setState({
20
+ nodes: {
21
+ [staleBuilding.id]: staleBuilding,
22
+ [activeBuilding.id]: activeBuilding,
23
+ [level.id]: level,
24
+ },
25
+ } as never)
26
+ useViewer.setState({
27
+ selection: {
28
+ buildingId: staleBuilding.id,
29
+ levelId: level.id,
30
+ zoneId: null,
31
+ selectedIds: [],
32
+ },
33
+ } as never)
34
+
35
+ expect(getActiveBuildingId()).toBe(activeBuilding.id)
36
+ })
37
+
38
+ test('resolves legacy level ownership from the building children list', () => {
39
+ const level = LevelNode.parse({})
40
+ const building = BuildingNode.parse({ children: [level.id] })
41
+ useScene.setState({ nodes: { [building.id]: building, [level.id]: level } } as never)
42
+ useViewer.setState({
43
+ selection: { buildingId: null, levelId: level.id, zoneId: null, selectedIds: [] },
44
+ } as never)
45
+
46
+ expect(getActiveBuildingId()).toBe(building.id)
47
+ })
48
+ })
@@ -15,6 +15,7 @@ import {
15
15
  type BuildingPose,
16
16
  type ResolveAlignmentInBuildingResult,
17
17
  resolveAlignmentInBuildingWorld,
18
+ resolveBuildingForLevel,
18
19
  snapWorldXZToBuildingLocal,
19
20
  useLiveTransforms,
20
21
  useScene,
@@ -39,7 +40,7 @@ import { useViewer } from '@pascal-app/viewer'
39
40
  * guides drift off the visible grid mid-drag (and through any post-drag
40
41
  * frame where the live override is still set).
41
42
  */
42
- export function getActiveBuildingPose(): BuildingPose | null {
43
+ export function getActiveBuildingId(): AnyNodeId | null {
43
44
  const sel = useViewer.getState().selection
44
45
  const nodes = useScene.getState().nodes
45
46
  // Match `use-floorplan-scene-data.ts`: prefer the active level's
@@ -48,12 +49,15 @@ export function getActiveBuildingPose(): BuildingPose | null {
48
49
  // different level, the level path is the authoritative one.
49
50
  let buildingId: AnyNodeId | null = null
50
51
  if (sel.levelId) {
51
- const level = nodes[sel.levelId]
52
- if (level && level.type === 'level' && level.parentId) {
53
- buildingId = level.parentId as AnyNodeId
54
- }
52
+ buildingId = resolveBuildingForLevel(sel.levelId as AnyNodeId, nodes)
55
53
  }
56
54
  if (!buildingId) buildingId = sel.buildingId ?? null
55
+ return buildingId
56
+ }
57
+
58
+ export function getActiveBuildingPose(): BuildingPose | null {
59
+ const buildingId = getActiveBuildingId()
60
+ const nodes = useScene.getState().nodes
57
61
  const building = buildingId ? nodes[buildingId] : null
58
62
  if (building?.type !== 'building') return null
59
63
  const live = useLiveTransforms.getState().transforms.get(buildingId as string)