@pascal-app/editor 1.0.0-beta.5 → 1.0.0

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 (245) hide show
  1. package/LICENSE +21 -0
  2. package/bunfig.toml +4 -0
  3. package/package.json +13 -7
  4. package/scripts/generate-print-golden-house.ts +89 -0
  5. package/src/components/editor/bake-exporter.tsx +1 -0
  6. package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
  7. package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
  8. package/src/components/editor/capture-camera-rig.tsx +59 -0
  9. package/src/components/editor/custom-camera-controls.tsx +20 -11
  10. package/src/components/editor/editor-layout-mobile.tsx +6 -6
  11. package/src/components/editor/editor-layout-v2.tsx +9 -2
  12. package/src/components/editor/export-manager.tsx +175 -33
  13. package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
  14. package/src/components/editor/first-person-controls.tsx +180 -12
  15. package/src/components/editor/floating-action-menu.tsx +42 -51
  16. package/src/components/editor/floorplan-panel.tsx +76 -12
  17. package/src/components/editor/grid.tsx +12 -7
  18. package/src/components/editor/group-actions.ts +34 -50
  19. package/src/components/editor/group-move-3d.ts +51 -21
  20. package/src/components/editor/group-transform-shared.test.ts +78 -0
  21. package/src/components/editor/group-transform-shared.ts +74 -7
  22. package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
  23. package/src/components/editor/handles/handle-arrow.tsx +109 -48
  24. package/src/components/editor/handles/linear-resize-drag.ts +67 -0
  25. package/src/components/editor/handles/resize-snap.test.ts +46 -0
  26. package/src/components/editor/handles/resize-snap.ts +10 -1
  27. package/src/components/editor/handles/use-handle-drag.ts +18 -1
  28. package/src/components/editor/index.tsx +140 -21
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +47 -58
  31. package/src/components/editor/selection-manager.tsx +104 -42
  32. package/src/components/editor/snapshot-capture-overlay.tsx +298 -39
  33. package/src/components/editor/snapshot-capture.test.ts +422 -0
  34. package/src/components/editor/snapshot-capture.ts +145 -0
  35. package/src/components/editor/thumbnail-generator.tsx +284 -237
  36. package/src/components/editor/use-floorplan-background-placement.ts +53 -5
  37. package/src/components/editor/wall-move-side-handles.tsx +299 -7
  38. package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
  39. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
  40. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
  41. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
  42. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
  43. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
  44. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
  45. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
  46. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +180 -32
  47. package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
  48. package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
  49. package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
  50. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
  51. package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
  52. package/src/components/systems/roof/roof-edit-system.tsx +10 -19
  53. package/src/components/systems/selection-affordance-manager.tsx +59 -9
  54. package/src/components/systems/selection-affordance-services.ts +19 -0
  55. package/src/components/systems/stair/stair-edit-system.tsx +5 -2
  56. package/src/components/tools/fence/fence-drafting.test.ts +100 -0
  57. package/src/components/tools/fence/fence-drafting.ts +6 -25
  58. package/src/components/tools/item/draft-creation.test.ts +12 -0
  59. package/src/components/tools/item/draft-creation.ts +10 -0
  60. package/src/components/tools/item/face-host-commit.test.ts +65 -0
  61. package/src/components/tools/item/face-host-commit.ts +56 -0
  62. package/src/components/tools/item/face-host-preview.test.ts +77 -0
  63. package/src/components/tools/item/face-host-preview.ts +82 -0
  64. package/src/components/tools/item/move-tool.tsx +4 -3
  65. package/src/components/tools/item/placement-math.test.ts +11 -1
  66. package/src/components/tools/item/placement-math.ts +0 -20
  67. package/src/components/tools/item/placement-strategies.test.ts +331 -4
  68. package/src/components/tools/item/placement-strategies.ts +135 -2
  69. package/src/components/tools/item/placement-surface.test.ts +61 -0
  70. package/src/components/tools/item/placement-surface.ts +24 -0
  71. package/src/components/tools/item/placement-types.ts +6 -0
  72. package/src/components/tools/item/test-face-host.ts +121 -0
  73. package/src/components/tools/item/use-draft-node.test.tsx +336 -0
  74. package/src/components/tools/item/use-draft-node.ts +26 -4
  75. package/src/components/tools/item/use-placement-coordinator.tsx +428 -153
  76. package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
  77. package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
  78. package/src/components/tools/registry/move-registry-node-tool.tsx +306 -110
  79. package/src/components/tools/registry-tool-context.tsx +30 -0
  80. package/src/components/tools/select/box-select-tool.tsx +3 -1
  81. package/src/components/tools/select/marquee-footprint.test.ts +53 -0
  82. package/src/components/tools/select/marquee-footprint.ts +29 -0
  83. package/src/components/tools/shared/placement-box.tsx +57 -7
  84. package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
  85. package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
  86. package/src/components/tools/shared/pointer-support-cap.ts +45 -8
  87. package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
  88. package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
  89. package/src/components/tools/stair/stair-tool.tsx +127 -74
  90. package/src/components/tools/tool-manager.tsx +25 -8
  91. package/src/components/tools/wall/wall-drafting.test.ts +320 -2
  92. package/src/components/tools/wall/wall-drafting.ts +23 -114
  93. package/src/components/ui/action-menu/control-modes.tsx +9 -6
  94. package/src/components/ui/action-menu/index.tsx +11 -1
  95. package/src/components/ui/command-palette/editor-commands.tsx +9 -12
  96. package/src/components/ui/command-palette/index.tsx +7 -2
  97. package/src/components/ui/controls/material-paint-panel.tsx +6 -4
  98. package/src/components/ui/controls/material-picker.tsx +4 -4
  99. package/src/components/ui/controls/scene-material-list.tsx +6 -5
  100. package/src/components/ui/controls/segmented-control.tsx +8 -1
  101. package/src/components/ui/controls/slider-control.tsx +19 -3
  102. package/src/components/ui/controls/toggle-control.tsx +18 -5
  103. package/src/components/ui/controls/tool-options-panel.tsx +126 -0
  104. package/src/components/ui/floating-level-selector.tsx +22 -5
  105. package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
  106. package/src/components/ui/helpers/helper-manager.tsx +47 -11
  107. package/src/components/ui/helpers/item-helper.tsx +1 -1
  108. package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
  109. package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
  110. package/src/components/ui/panels/homogeneous-selection.ts +47 -0
  111. package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
  112. package/src/components/ui/panels/multi-field-value.test.ts +209 -0
  113. package/src/components/ui/panels/multi-field-value.ts +163 -0
  114. package/src/components/ui/panels/multi-height-mode.tsx +185 -0
  115. package/src/components/ui/panels/multi-parametric-inspector.tsx +263 -0
  116. package/src/components/ui/panels/multi-selection-panel.tsx +48 -37
  117. package/src/components/ui/panels/node-display.ts +4 -0
  118. package/src/components/ui/panels/panel-manager.tsx +77 -3
  119. package/src/components/ui/panels/parametric-field-control.tsx +173 -0
  120. package/src/components/ui/panels/parametric-field-utils.ts +18 -0
  121. package/src/components/ui/panels/parametric-inspector.tsx +29 -162
  122. package/src/components/ui/panels/reference-panel.tsx +31 -1
  123. package/src/components/ui/primitives/shortcut-token.tsx +23 -1
  124. package/src/components/ui/scene-loader.tsx +35 -0
  125. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +390 -36
  126. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +20 -23
  127. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +140 -0
  128. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +109 -0
  129. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
  130. package/src/components/ui/sidebar/panels/site-panel/index.tsx +116 -46
  131. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
  132. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
  133. package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
  134. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +14 -6
  135. package/src/components/ui/sidebar/tab-bar.tsx +2 -0
  136. package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
  137. package/src/components/viewer/viewer-scene-header.tsx +2 -1
  138. package/src/components/viewer/viewer-stage.tsx +4 -1
  139. package/src/components/viewer-overlay.tsx +1 -22
  140. package/src/components/viewer-zone-system.tsx +6 -2
  141. package/src/components/walkthrough-hud.tsx +19 -10
  142. package/src/hooks/use-auto-save.test.ts +73 -1
  143. package/src/hooks/use-auto-save.ts +76 -5
  144. package/src/hooks/use-ceiling-events.test.ts +122 -0
  145. package/src/hooks/use-drag-action.ts +17 -3
  146. package/src/hooks/use-grid-events.test.ts +240 -0
  147. package/src/hooks/use-grid-events.ts +190 -11
  148. package/src/hooks/use-keyboard.test.ts +244 -0
  149. package/src/hooks/use-keyboard.ts +129 -62
  150. package/src/hooks/use-registered-tool-enabled.ts +20 -0
  151. package/src/hooks/use-save-shortcut.ts +33 -0
  152. package/src/index.tsx +82 -4
  153. package/src/lib/active-placement-surface.test.ts +16 -0
  154. package/src/lib/active-placement-surface.ts +14 -0
  155. package/src/lib/ceiling-surface-raycast.test.ts +64 -0
  156. package/src/lib/ceiling-surface-raycast.ts +12 -0
  157. package/src/lib/contextual-help-extension.ts +17 -0
  158. package/src/lib/continuation.test.ts +11 -0
  159. package/src/lib/continuation.ts +8 -1
  160. package/src/lib/direct-manipulation.test.ts +132 -0
  161. package/src/lib/direct-manipulation.ts +38 -1
  162. package/src/lib/editor-api.ts +6 -3
  163. package/src/lib/elevation-guides.test.ts +28 -1
  164. package/src/lib/elevation-guides.ts +22 -7
  165. package/src/lib/floating-menu-scale.test.ts +29 -0
  166. package/src/lib/floating-menu-scale.ts +14 -0
  167. package/src/lib/floorplan/apply-alignment.test.ts +47 -2
  168. package/src/lib/floorplan/apply-alignment.ts +10 -1
  169. package/src/lib/floorplan/floorplan-export.test.ts +397 -1
  170. package/src/lib/floorplan/floorplan-export.tsx +111 -16
  171. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
  172. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
  173. package/src/lib/floorplan/floorplan-readonly.ts +1 -0
  174. package/src/lib/floorplan-grid-event-point.test.ts +29 -0
  175. package/src/lib/floorplan-grid-event-point.ts +13 -0
  176. package/src/lib/glb-export.test.ts +1169 -21
  177. package/src/lib/glb-export.ts +629 -101
  178. package/src/lib/grid-event-presentation.ts +16 -0
  179. package/src/lib/history.test.ts +528 -147
  180. package/src/lib/history.ts +81 -7
  181. package/src/lib/host-tree-children.test.ts +27 -0
  182. package/src/lib/host-tree-children.ts +79 -0
  183. package/src/lib/interaction/hot-set.test.ts +10 -1
  184. package/src/lib/interaction/hot-set.ts +3 -1
  185. package/src/lib/interaction/overlay-policy.test.ts +33 -1
  186. package/src/lib/interaction/overlay-policy.ts +16 -0
  187. package/src/lib/interaction/registered-drafting.ts +36 -0
  188. package/src/lib/interaction/scope.ts +31 -0
  189. package/src/lib/level-print-export.test.ts +484 -0
  190. package/src/lib/level-print-export.ts +526 -0
  191. package/src/lib/local-project-presentation-persistence.test.ts +302 -0
  192. package/src/lib/local-project-presentation-persistence.ts +335 -0
  193. package/src/lib/material-paint.ts +2 -0
  194. package/src/lib/model-export.ts +29 -0
  195. package/src/lib/paint-preview-owner.ts +71 -0
  196. package/src/lib/planar-cursor-placement.test.ts +178 -1
  197. package/src/lib/planar-cursor-placement.ts +61 -5
  198. package/src/lib/plugin-panels.test.ts +92 -1
  199. package/src/lib/plugin-panels.ts +48 -0
  200. package/src/lib/portable-export.test.ts +366 -0
  201. package/src/lib/portable-export.ts +969 -0
  202. package/src/lib/print-3mf.test.ts +86 -0
  203. package/src/lib/print-3mf.ts +161 -0
  204. package/src/lib/print-content-scope.test.ts +107 -0
  205. package/src/lib/print-content-scope.ts +90 -0
  206. package/src/lib/print-export.test.ts +294 -0
  207. package/src/lib/print-export.ts +735 -0
  208. package/src/lib/print-feature-thickness.test.ts +147 -0
  209. package/src/lib/print-feature-thickness.ts +180 -0
  210. package/src/lib/print-golden-house.test-fixture.ts +352 -0
  211. package/src/lib/print-golden-house.test.ts +249 -0
  212. package/src/lib/print-roof-solids.test.ts +133 -0
  213. package/src/lib/print-roof-solids.ts +630 -0
  214. package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
  215. package/src/lib/print-shell-compiler-baseline.ts +225 -0
  216. package/src/lib/print-shell-compiler-manifold-core.ts +330 -0
  217. package/src/lib/print-shell-compiler-manifold-worker.ts +187 -0
  218. package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
  219. package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
  220. package/src/lib/print-shell-compiler-protocol.ts +30 -0
  221. package/src/lib/print-shell-compiler.ts +273 -0
  222. package/src/lib/print-wall-solids.test.ts +194 -0
  223. package/src/lib/print-wall-solids.ts +328 -0
  224. package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
  225. package/src/lib/rigid-plan-svg-transform.ts +24 -0
  226. package/src/lib/scene.ts +22 -8
  227. package/src/lib/selection-routing.test.ts +105 -1
  228. package/src/lib/selection-routing.ts +19 -0
  229. package/src/lib/sfx-bus.ts +4 -0
  230. package/src/lib/sfx-player.ts +8 -0
  231. package/src/lib/snapping-mode.test.ts +32 -4
  232. package/src/lib/snapping-mode.ts +11 -7
  233. package/src/lib/usdz-export.ts +38 -0
  234. package/src/lib/walkthrough-pointer-lock.ts +47 -0
  235. package/src/store/live-draft-preview-stores.test.ts +3 -0
  236. package/src/store/terrain-sculpt-mode.test.ts +3 -6
  237. package/src/store/tool-mode.test.ts +87 -0
  238. package/src/store/use-camera-hint-focus.ts +48 -0
  239. package/src/store/use-editor.tsx +299 -188
  240. package/src/store/use-floorplan-draft-preview.ts +9 -0
  241. package/src/store/use-interaction-scope.test.ts +23 -1
  242. package/src/store/use-interaction-scope.ts +63 -2
  243. package/src/store/use-placement-preview.ts +56 -3
  244. package/src/components/tools/roof/roof-tool.tsx +0 -656
  245. package/src/components/ui/helpers/roof-helper.tsx +0 -14
@@ -1,4 +1,5 @@
1
1
  import type { ToolHint } from '@pascal-app/core'
2
+ import { useMemo, useSyncExternalStore } from 'react'
2
3
  import type { ContinuationContext } from '../../../lib/continuation'
3
4
  import type { SnapContext } from '../../../lib/snapping-mode'
4
5
  import useEditor from '../../../store/use-editor'
@@ -27,11 +28,31 @@ export function RegisteredToolHelper({
27
28
  // Live vertex count of an in-progress polygon draft, so hints gated on a
28
29
  // minimum (e.g. "Finish" at ≥ 3) only appear once they're actually possible.
29
30
  const draftVertexCount = useEditor((s) => s.draftVertexCount)
31
+ const visibilityStore = useMemo(
32
+ () => ({
33
+ subscribe: (onChange: () => void) => {
34
+ const unsubscribers = hints.flatMap((hint) =>
35
+ hint.visible ? [hint.visible.subscribe(onChange)] : [],
36
+ )
37
+ return () => {
38
+ for (const unsubscribe of unsubscribers) unsubscribe()
39
+ }
40
+ },
41
+ getSnapshot: () => hints.map((hint) => (hint.visible?.value() === false ? '0' : '1')).join(''),
42
+ }),
43
+ [hints],
44
+ )
45
+ useSyncExternalStore(
46
+ visibilityStore.subscribe,
47
+ visibilityStore.getSnapshot,
48
+ visibilityStore.getSnapshot,
49
+ )
30
50
  // Some hints are replaced by live contextual chips, so keep the generic
31
51
  // registry renderer from duplicating stale/static versions.
32
52
  const visible = hints.filter(
33
53
  (hint) =>
34
54
  !(hint.key === 'Shift' && hint.label === 'Cycle snapping mode') &&
55
+ hint.visible?.value() !== false &&
35
56
  (hint.minDraftVertices == null || draftVertexCount >= hint.minDraftVertices),
36
57
  )
37
58
  if (visible.length === 0 && !snapContext && !continuationContext) return null
@@ -0,0 +1,80 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import type { AnyNode, AnyNodeId } from '@pascal-app/core'
3
+ import {
4
+ resolveHomogeneousSelection,
5
+ resolveUniqueSelectionIds,
6
+ } from './homogeneous-selection'
7
+
8
+ function node(
9
+ id: string,
10
+ type: string,
11
+ metadata?: Record<string, unknown>,
12
+ ): AnyNode {
13
+ return {
14
+ object: 'node',
15
+ id: id as AnyNodeId,
16
+ type,
17
+ parentId: null,
18
+ visible: true,
19
+ metadata: metadata ?? {},
20
+ children: [],
21
+ } as unknown as AnyNode
22
+ }
23
+
24
+ describe('resolveHomogeneousSelection', () => {
25
+ test('mixed selection is null', () => {
26
+ const nodes = {
27
+ wall_a: node('wall_a', 'wall'),
28
+ slab_a: node('slab_a', 'slab'),
29
+ }
30
+ expect(resolveHomogeneousSelection(['wall_a', 'slab_a'], nodes)).toBeNull()
31
+ })
32
+
33
+ test('three walls share the wall type', () => {
34
+ const nodes = {
35
+ wall_a: node('wall_a', 'wall'),
36
+ wall_b: node('wall_b', 'wall'),
37
+ wall_c: node('wall_c', 'wall'),
38
+ }
39
+ expect(resolveHomogeneousSelection(['wall_a', 'wall_b', 'wall_c'], nodes)).toBe('wall')
40
+ })
41
+
42
+ test('proxy-promoted children resolve to the parent type', () => {
43
+ const nodes = {
44
+ cabinet_run: node('cabinet_run', 'cabinet'),
45
+ 'cabinet-module_a': node('cabinet-module_a', 'cabinet-module', {
46
+ nodeSelectionProxyId: 'cabinet_run',
47
+ }),
48
+ 'cabinet-module_b': node('cabinet-module_b', 'cabinet-module', {
49
+ nodeSelectionProxyId: 'cabinet_run',
50
+ }),
51
+ 'cabinet-module_c': node('cabinet-module_c', 'cabinet-module', {
52
+ nodeSelectionProxyId: 'cabinet_run',
53
+ }),
54
+ cabinet_run_b: node('cabinet_run_b', 'cabinet'),
55
+ 'cabinet-module_d': node('cabinet-module_d', 'cabinet-module', {
56
+ nodeSelectionProxyId: 'cabinet_run_b',
57
+ }),
58
+ }
59
+ expect(resolveHomogeneousSelection(['cabinet-module_a', 'cabinet-module_d'], nodes)).toBe(
60
+ 'cabinet',
61
+ )
62
+ expect(resolveUniqueSelectionIds(['cabinet-module_a', 'cabinet-module_b', 'cabinet-module_c'], nodes)).toEqual(
63
+ ['cabinet_run'],
64
+ )
65
+ expect(resolveHomogeneousSelection(['cabinet-module_a', 'cabinet-module_b'], nodes)).toBeNull()
66
+ })
67
+
68
+ test('stale ids are skipped without breaking a homogeneous remainder', () => {
69
+ const nodes = {
70
+ wall_a: node('wall_a', 'wall'),
71
+ wall_b: node('wall_b', 'wall'),
72
+ }
73
+ expect(resolveHomogeneousSelection(['wall_a', 'gone', 'wall_b'], nodes)).toBe('wall')
74
+ })
75
+
76
+ test('a single live node after skips is not homogeneous', () => {
77
+ const nodes = { wall_a: node('wall_a', 'wall') }
78
+ expect(resolveHomogeneousSelection(['wall_a', 'gone'], nodes)).toBeNull()
79
+ })
80
+ })
@@ -0,0 +1,47 @@
1
+ import {
2
+ type AnyNode,
3
+ type AnyNodeId,
4
+ resolveSelectionProxyId,
5
+ } from '@pascal-app/core'
6
+
7
+ /**
8
+ * Resolve selection proxies and drop duplicates / missing ids. Session groups
9
+ * are just selections — mixed-type groups stay mixed after this pass, and a
10
+ * pair of children that both proxy to the same parent collapse to one id.
11
+ */
12
+ export function resolveUniqueSelectionIds(
13
+ ids: readonly string[],
14
+ nodes: Readonly<Record<string, AnyNode | undefined>>,
15
+ ): AnyNodeId[] {
16
+ const resolved: AnyNodeId[] = []
17
+ const seen = new Set<string>()
18
+ for (const id of ids) {
19
+ const node = nodes[id]
20
+ if (!node) continue
21
+ const resolvedId = resolveSelectionProxyId(node, nodes)
22
+ if (seen.has(resolvedId)) continue
23
+ seen.add(resolvedId)
24
+ resolved.push(resolvedId)
25
+ }
26
+ return resolved
27
+ }
28
+
29
+ /**
30
+ * Shared type when every resolved id is the same kind and at least two nodes
31
+ * remain. Otherwise null — including mixed session groups and proxy-collapsed
32
+ * selections that shrink below two distinct nodes.
33
+ */
34
+ export function resolveHomogeneousSelection(
35
+ ids: readonly string[],
36
+ nodes: Readonly<Record<string, AnyNode | undefined>>,
37
+ ): AnyNode['type'] | null {
38
+ const resolvedIds = resolveUniqueSelectionIds(ids, nodes)
39
+ if (resolvedIds.length < 2) return null
40
+ const first = nodes[resolvedIds[0] ?? '']
41
+ if (!first) return null
42
+ for (let i = 1; i < resolvedIds.length; i++) {
43
+ const node = nodes[resolvedIds[i] ?? '']
44
+ if (!node || node.type !== first.type) return null
45
+ }
46
+ return first.type
47
+ }
@@ -8,7 +8,9 @@ import { cn } from '../../../lib/utils'
8
8
  import { getNodeDisplay } from './node-display'
9
9
 
10
10
  interface MobileSelectionBarProps {
11
- node: AnyNode
11
+ node: AnyNode | null
12
+ label?: string
13
+ icon?: string
12
14
  onMove: () => void
13
15
  onDuplicate: () => void
14
16
  onDelete: () => void
@@ -20,19 +22,23 @@ const ACTION_BTN =
20
22
 
21
23
  export function MobileSelectionBar({
22
24
  node,
25
+ label,
26
+ icon,
23
27
  onMove,
24
28
  onDuplicate,
25
29
  onDelete,
26
30
  onEdit,
27
31
  }: MobileSelectionBarProps) {
28
- const { icon, label } = getNodeDisplay(node)
32
+ const display = getNodeDisplay(node)
33
+ const resolvedLabel = label ?? display.label
34
+ const resolvedIcon = icon ?? display.icon
29
35
 
30
36
  const stop: MouseEventHandler<HTMLButtonElement> = (e) => e.stopPropagation()
31
37
 
32
38
  return (
33
39
  <div className="pointer-events-auto absolute right-3 bottom-6 left-3 z-50 flex h-12 items-stretch gap-1 rounded-2xl border border-border/50 bg-background/95 px-2 shadow-2xl backdrop-blur-xl">
34
40
  <button
35
- aria-label={`Edit ${label}`}
41
+ aria-label={`Edit ${resolvedLabel}`}
36
42
  className={cn(
37
43
  'flex min-w-0 flex-1 items-center gap-2 rounded-lg px-2 text-left transition-colors hover:bg-white/8',
38
44
  )}
@@ -43,10 +49,10 @@ export function MobileSelectionBar({
43
49
  alt=""
44
50
  className="shrink-0 rounded object-contain"
45
51
  height={20}
46
- src={icon}
52
+ src={resolvedIcon}
47
53
  width={20}
48
54
  />
49
- <span className="truncate font-medium text-foreground text-sm">{label}</span>
55
+ <span className="truncate font-medium text-foreground text-sm">{resolvedLabel}</span>
50
56
  </button>
51
57
 
52
58
  <div className="flex items-center gap-0.5 border-border/40 border-l pl-1">
@@ -0,0 +1,209 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
+ import {
3
+ type AnyNode,
4
+ type AnyNodeId,
5
+ BuildingNode,
6
+ clearSceneHistory,
7
+ LevelNode,
8
+ useLiveNodeOverrides,
9
+ useScene,
10
+ WallNode,
11
+ } from '@pascal-app/core'
12
+ import {
13
+ buildMultiNodePatches,
14
+ commitMultiNodeFields,
15
+ fieldVisibleForAll,
16
+ reduceFieldValue,
17
+ reduceHeightBoundMode,
18
+ } from './multi-field-value'
19
+
20
+ type RafFn = (cb: (time: number) => void) => number
21
+ ;(globalThis as unknown as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= (cb) => {
22
+ cb(0)
23
+ return 0
24
+ }
25
+ ;(globalThis as unknown as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??=
26
+ () => {}
27
+
28
+ const BUILDING_ID = 'building_multi_edit' as AnyNodeId
29
+ const LEVEL_ID = 'level_multi_edit' as AnyNodeId
30
+ const WALL_A = 'wall_multi_a' as AnyNodeId
31
+ const WALL_B = 'wall_multi_b' as AnyNodeId
32
+ const WALL_C = 'wall_multi_c' as AnyNodeId
33
+
34
+ function makeNode(id: string, type: string, fields: Record<string, unknown> = {}): AnyNode {
35
+ return {
36
+ object: 'node',
37
+ id: id as AnyNodeId,
38
+ type,
39
+ parentId: null,
40
+ visible: true,
41
+ metadata: {},
42
+ children: [],
43
+ ...fields,
44
+ } as unknown as AnyNode
45
+ }
46
+
47
+ describe('reduceFieldValue', () => {
48
+ test('same values collapse to one', () => {
49
+ const nodes = {
50
+ a: makeNode('a', 'wall', { height: 2.4, thickness: 0.2 }),
51
+ b: makeNode('b', 'wall', { height: 2.4, thickness: 0.15 }),
52
+ }
53
+ expect(reduceFieldValue(['a', 'b'], 'height', nodes)).toEqual({ kind: 'same', value: 2.4 })
54
+ expect(reduceFieldValue(['a', 'b'], 'thickness', nodes)).toEqual({ kind: 'mixed' })
55
+ })
56
+
57
+ test('vec3 compares elementwise', () => {
58
+ const nodes = {
59
+ a: makeNode('a', 'item', { position: [1, 0, 2] }),
60
+ b: makeNode('b', 'item', { position: [1, 0, 2] }),
61
+ c: makeNode('c', 'item', { position: [1, 0, 3] }),
62
+ }
63
+ expect(reduceFieldValue(['a', 'b'], 'position', nodes)).toEqual({
64
+ kind: 'same',
65
+ value: [1, 0, 2],
66
+ })
67
+ expect(reduceFieldValue(['a', 'c'], 'position', nodes)).toEqual({ kind: 'mixed' })
68
+ })
69
+ })
70
+
71
+ describe('reduceHeightBoundMode', () => {
72
+ test('absent height is follows-level, present height is custom', () => {
73
+ const nodes = {
74
+ a: makeNode('a', 'wall'),
75
+ b: makeNode('b', 'wall'),
76
+ c: makeNode('c', 'wall', { height: 3 }),
77
+ }
78
+ expect(reduceHeightBoundMode(['a', 'b'], nodes)).toEqual({ kind: 'same', value: 'storey' })
79
+ expect(reduceHeightBoundMode(['c'], nodes)).toEqual({ kind: 'same', value: 'custom' })
80
+ expect(reduceHeightBoundMode(['a', 'c'], nodes)).toEqual({ kind: 'mixed' })
81
+ })
82
+ })
83
+
84
+ describe('fieldVisibleForAll', () => {
85
+ test('hides when any node would hide the field', () => {
86
+ const nodes = {
87
+ a: makeNode('a', 'slab', { recessed: false }),
88
+ b: makeNode('b', 'slab', { recessed: true }),
89
+ }
90
+ const visibleIf = (n: AnyNode) => !(n as { recessed?: boolean }).recessed
91
+ expect(fieldVisibleForAll(['a'], visibleIf, nodes)).toBe(true)
92
+ expect(fieldVisibleForAll(['a', 'b'], visibleIf, nodes)).toBe(false)
93
+ })
94
+ })
95
+
96
+ describe('buildMultiNodePatches', () => {
97
+ test('a mixed field that is never edited produces no patch for it', () => {
98
+ const nodes = {
99
+ a: makeNode('a', 'wall', { height: 2.4, thickness: 0.2 }),
100
+ b: makeNode('b', 'wall', { height: 3, thickness: 0.15 }),
101
+ }
102
+ const patches = buildMultiNodePatches(
103
+ ['a' as AnyNodeId, 'b' as AnyNodeId],
104
+ () => ({ thickness: 0.3 }),
105
+ nodes,
106
+ )
107
+ expect(patches).toEqual([
108
+ { id: 'a' as AnyNodeId, data: { thickness: 0.3 } },
109
+ { id: 'b' as AnyNodeId, data: { thickness: 0.3 } },
110
+ ])
111
+ for (const patch of patches) {
112
+ expect(patch.data).not.toHaveProperty('height')
113
+ }
114
+ })
115
+
116
+ test('derive and reconcile fan out per node into one batch', () => {
117
+ const nodes = {
118
+ a: makeNode('a', 'wall', { height: 2 }),
119
+ b: makeNode('b', 'wall', { height: 2 }),
120
+ }
121
+ const patches = buildMultiNodePatches(
122
+ ['a' as AnyNodeId, 'b' as AnyNodeId],
123
+ () => ({ height: 4 }),
124
+ nodes,
125
+ {
126
+ derive: (next) => ({ thickness: (next as { height: number }).height / 10 }),
127
+ reconcile: (prev) => [
128
+ { id: `${prev.id}_follow` as AnyNodeId, data: { height: 4 } },
129
+ ],
130
+ },
131
+ )
132
+ expect(patches).toEqual([
133
+ { id: 'a' as AnyNodeId, data: { height: 4, thickness: 0.4 } },
134
+ { id: 'b' as AnyNodeId, data: { height: 4, thickness: 0.4 } },
135
+ { id: 'a_follow' as AnyNodeId, data: { height: 4 } },
136
+ { id: 'b_follow' as AnyNodeId, data: { height: 4 } },
137
+ ])
138
+ })
139
+ })
140
+
141
+ describe('commitMultiNodeFields', () => {
142
+ beforeEach(() => {
143
+ const wallA = WallNode.parse({
144
+ id: WALL_A,
145
+ parentId: LEVEL_ID,
146
+ start: [0, 0],
147
+ end: [4, 0],
148
+ height: 2.4,
149
+ })
150
+ const wallB = WallNode.parse({
151
+ id: WALL_B,
152
+ parentId: LEVEL_ID,
153
+ start: [4, 0],
154
+ end: [4, 3],
155
+ height: 3,
156
+ })
157
+ const wallC = WallNode.parse({
158
+ id: WALL_C,
159
+ parentId: LEVEL_ID,
160
+ start: [4, 3],
161
+ end: [0, 3],
162
+ height: 2.7,
163
+ })
164
+ const level = LevelNode.parse({
165
+ id: LEVEL_ID,
166
+ parentId: BUILDING_ID,
167
+ children: [WALL_A, WALL_B, WALL_C],
168
+ level: 0,
169
+ })
170
+ const building = BuildingNode.parse({
171
+ id: BUILDING_ID,
172
+ parentId: null,
173
+ children: [LEVEL_ID],
174
+ })
175
+ useScene.setState({
176
+ nodes: {
177
+ [BUILDING_ID]: building,
178
+ [LEVEL_ID]: level,
179
+ [WALL_A]: wallA,
180
+ [WALL_B]: wallB,
181
+ [WALL_C]: wallC,
182
+ },
183
+ rootNodeIds: [BUILDING_ID],
184
+ dirtyNodes: new Set<AnyNodeId>(),
185
+ collections: {},
186
+ materials: {},
187
+ readOnly: false,
188
+ } as never)
189
+ clearSceneHistory()
190
+ useLiveNodeOverrides.getState().clearAll()
191
+ })
192
+
193
+ afterEach(() => {
194
+ useLiveNodeOverrides.getState().clearAll()
195
+ })
196
+
197
+ test('dragging height onto three walls is one undo step', () => {
198
+ commitMultiNodeFields([WALL_A, WALL_B, WALL_C], () => ({ height: 4 }))
199
+ expect((useScene.getState().nodes[WALL_A] as { height?: number }).height).toBe(4)
200
+ expect((useScene.getState().nodes[WALL_B] as { height?: number }).height).toBe(4)
201
+ expect((useScene.getState().nodes[WALL_C] as { height?: number }).height).toBe(4)
202
+ expect(useScene.temporal.getState().pastStates).toHaveLength(1)
203
+
204
+ useScene.temporal.getState().undo()
205
+ expect((useScene.getState().nodes[WALL_A] as { height?: number }).height).toBe(2.4)
206
+ expect((useScene.getState().nodes[WALL_B] as { height?: number }).height).toBe(3)
207
+ expect((useScene.getState().nodes[WALL_C] as { height?: number }).height).toBe(2.7)
208
+ })
209
+ })
@@ -0,0 +1,163 @@
1
+ import {
2
+ type AnyNode,
3
+ type AnyNodeId,
4
+ type ParametricDescriptor,
5
+ useLiveNodeOverrides,
6
+ useScene,
7
+ } from '@pascal-app/core'
8
+
9
+ export type ReducedFieldValue<T = unknown> =
10
+ | { kind: 'same'; value: T }
11
+ | { kind: 'mixed' }
12
+
13
+ const MIXED: { kind: 'mixed' } = { kind: 'mixed' }
14
+
15
+ function fieldValuesEqual(a: unknown, b: unknown): boolean {
16
+ if (Object.is(a, b)) return true
17
+ if (Array.isArray(a) && Array.isArray(b) && a.length === b.length) {
18
+ return a.every((value, i) => Object.is(value, b[i]))
19
+ }
20
+ return false
21
+ }
22
+
23
+ export function reduceFieldValue(
24
+ nodeIds: readonly string[],
25
+ key: string,
26
+ nodes: Readonly<Record<string, AnyNode | undefined>>,
27
+ ): ReducedFieldValue {
28
+ let seen = false
29
+ let shared: unknown
30
+ for (const id of nodeIds) {
31
+ const node = nodes[id]
32
+ if (!node) continue
33
+ const value = (node as Record<string, unknown>)[key]
34
+ if (!seen) {
35
+ seen = true
36
+ shared = value
37
+ continue
38
+ }
39
+ if (!fieldValuesEqual(shared, value)) return MIXED
40
+ }
41
+ if (!seen) return MIXED
42
+ return { kind: 'same', value: shared }
43
+ }
44
+
45
+ export type HeightBoundMode = 'storey' | 'custom'
46
+
47
+ export function reduceHeightBoundMode(
48
+ nodeIds: readonly string[],
49
+ nodes: Readonly<Record<string, AnyNode | undefined>>,
50
+ ): ReducedFieldValue<HeightBoundMode> {
51
+ let seen = false
52
+ let shared: HeightBoundMode | undefined
53
+ for (const id of nodeIds) {
54
+ const node = nodes[id]
55
+ if (!node) continue
56
+ const mode: HeightBoundMode = (node as { height?: number }).height == null ? 'storey' : 'custom'
57
+ if (!seen) {
58
+ seen = true
59
+ shared = mode
60
+ continue
61
+ }
62
+ if (mode !== shared) return MIXED
63
+ }
64
+ if (!seen || !shared) return MIXED
65
+ return { kind: 'same', value: shared }
66
+ }
67
+
68
+ export function fieldVisibleForAll(
69
+ nodeIds: readonly string[],
70
+ visibleIf: ((node: AnyNode) => boolean) | undefined,
71
+ nodes: Readonly<Record<string, AnyNode | undefined>>,
72
+ ): boolean {
73
+ if (!visibleIf) return true
74
+ for (const id of nodeIds) {
75
+ const node = nodes[id]
76
+ if (!node || !visibleIf(node)) return false
77
+ }
78
+ return true
79
+ }
80
+
81
+ export function firstNumericFieldValue(
82
+ nodeIds: readonly string[],
83
+ key: string,
84
+ nodes: Readonly<Record<string, AnyNode | undefined>>,
85
+ fallback = 0,
86
+ ): number {
87
+ for (const id of nodeIds) {
88
+ const node = nodes[id]
89
+ const value = node ? (node as Record<string, unknown>)[key] : undefined
90
+ if (typeof value === 'number' && Number.isFinite(value)) return value
91
+ }
92
+ return fallback
93
+ }
94
+
95
+ export function firstVec3FieldValue(
96
+ nodeIds: readonly string[],
97
+ key: string,
98
+ nodes: Readonly<Record<string, AnyNode | undefined>>,
99
+ ): [number, number, number] {
100
+ for (const id of nodeIds) {
101
+ const node = nodes[id]
102
+ const value = node ? (node as Record<string, unknown>)[key] : undefined
103
+ if (Array.isArray(value) && value.length >= 3) {
104
+ return [Number(value[0]) || 0, Number(value[1]) || 0, Number(value[2]) || 0]
105
+ }
106
+ }
107
+ return [0, 0, 0]
108
+ }
109
+
110
+ export function buildMultiNodePatches(
111
+ nodeIds: readonly AnyNodeId[],
112
+ patchFor: (node: AnyNode) => Partial<AnyNode>,
113
+ nodes: Readonly<Record<string, AnyNode | undefined>>,
114
+ parametrics?: Pick<ParametricDescriptor<AnyNode>, 'derive' | 'reconcile'>,
115
+ ): Array<{ id: AnyNodeId; data: Partial<AnyNode> }> {
116
+ const updates: Array<{ id: AnyNodeId; data: Partial<AnyNode> }> = []
117
+ const followUps: Array<{ id: AnyNodeId; data: Partial<AnyNode> }> = []
118
+ for (const id of nodeIds) {
119
+ const node = nodes[id]
120
+ if (!node) continue
121
+ let patch = patchFor(node)
122
+ if (Object.keys(patch).length === 0) continue
123
+ if (parametrics?.derive) {
124
+ const next = { ...node, ...patch } as AnyNode
125
+ patch = { ...patch, ...parametrics.derive(next, patch, node) } as Partial<AnyNode>
126
+ }
127
+ updates.push({ id, data: patch })
128
+ if (parametrics?.reconcile) {
129
+ const next = { ...node, ...patch } as AnyNode
130
+ followUps.push(...parametrics.reconcile(node, next))
131
+ }
132
+ }
133
+ return [...updates, ...followUps]
134
+ }
135
+
136
+ export function previewMultiNodeFields(
137
+ entries: ReadonlyArray<readonly [AnyNodeId, Partial<AnyNode>]>,
138
+ ): void {
139
+ if (entries.length === 0) return
140
+ useLiveNodeOverrides.getState().setMany(entries)
141
+ const scene = useScene.getState()
142
+ for (const [id] of entries) scene.markDirty(id)
143
+ }
144
+
145
+ export function commitMultiNodeFields(
146
+ nodeIds: readonly AnyNodeId[],
147
+ patchFor: (node: AnyNode) => Partial<AnyNode>,
148
+ parametrics?: Pick<ParametricDescriptor<AnyNode>, 'derive' | 'reconcile'>,
149
+ ): void {
150
+ const scene = useScene.getState()
151
+ const patches = buildMultiNodePatches(nodeIds, patchFor, scene.nodes, parametrics)
152
+ const keys = new Set<string>()
153
+ for (const patch of patches) {
154
+ for (const key of Object.keys(patch.data)) keys.add(key)
155
+ }
156
+ const live = useLiveNodeOverrides.getState()
157
+ const keyList = [...keys]
158
+ for (const id of nodeIds) {
159
+ if (keyList.length > 0) live.clearFields(id, keyList)
160
+ scene.markDirty(id)
161
+ }
162
+ if (patches.length > 0) scene.updateNodes(patches)
163
+ }