@pascal-app/editor 0.9.2 → 1.0.0-beta.1

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 (203) hide show
  1. package/package.json +9 -7
  2. package/src/components/editor/bake-exporter.tsx +3 -1
  3. package/src/components/editor/bake-thumbnail.tsx +95 -0
  4. package/src/components/editor/camera-dragging-lifecycle.test.ts +49 -0
  5. package/src/components/editor/camera-dragging-lifecycle.ts +41 -0
  6. package/src/components/editor/custom-camera-controls.tsx +307 -212
  7. package/src/components/editor/delete-confirmation-dialog.tsx +54 -0
  8. package/src/components/editor/elevation-3d-guide-layer.tsx +120 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +66 -2
  10. package/src/components/editor/first-person/build-collider-world.ts +20 -0
  11. package/src/components/editor/first-person/terrain-collider.test.ts +132 -0
  12. package/src/components/editor/first-person/terrain-collider.ts +212 -0
  13. package/src/components/editor/first-person-controls.tsx +295 -132
  14. package/src/components/editor/floating-action-menu.tsx +59 -65
  15. package/src/components/editor/floorplan-background-selection.test.ts +16 -2
  16. package/src/components/editor/floorplan-background-selection.ts +4 -18
  17. package/src/components/editor/floorplan-camera-sync.test.ts +249 -0
  18. package/src/components/editor/floorplan-camera-sync.ts +213 -0
  19. package/src/components/editor/floorplan-mode-coordinator.tsx +82 -0
  20. package/src/components/editor/floorplan-navigation-presentation.test.ts +142 -0
  21. package/src/components/editor/floorplan-navigation-presentation.ts +156 -0
  22. package/src/components/editor/floorplan-panel.tsx +931 -861
  23. package/src/components/editor/group-actions.ts +155 -15
  24. package/src/components/editor/group-floating-action-menu.tsx +1 -1
  25. package/src/components/editor/group-rotate-handle.tsx +1 -1
  26. package/src/components/editor/group-selection-box-3d.tsx +1 -1
  27. package/src/components/editor/handles/handle-drag-history.test.ts +96 -0
  28. package/src/components/editor/handles/handle-drag-history.ts +14 -0
  29. package/src/components/editor/handles/preview-overrides.test.ts +26 -0
  30. package/src/components/editor/handles/preview-overrides.ts +13 -0
  31. package/src/components/editor/handles/resize-snap.test.ts +52 -0
  32. package/src/components/editor/handles/resize-snap.ts +23 -0
  33. package/src/components/editor/handles/use-handle-drag.ts +17 -7
  34. package/src/components/editor/index.tsx +42 -9
  35. package/src/components/editor/measurement-pill.tsx +6 -1
  36. package/src/components/editor/node-arrow-handles.tsx +51 -15
  37. package/src/components/editor/quick-measurement-card.tsx +9 -3
  38. package/src/components/editor/quick-measurement-hud.tsx +7 -1
  39. package/src/components/editor/selection-manager.tsx +3 -1
  40. package/src/components/editor/site-edge-labels.tsx +24 -2
  41. package/src/components/editor/snapshot-capture-overlay.tsx +4 -1
  42. package/src/components/editor/thumbnail-generator.tsx +66 -294
  43. package/src/components/editor/use-floorplan-hit-testing.test.ts +82 -0
  44. package/src/components/editor/use-floorplan-hit-testing.ts +144 -161
  45. package/src/components/editor/use-mesh-settle-epoch.ts +1 -0
  46. package/src/components/editor/wall-measurement-label.tsx +8 -16
  47. package/src/components/editor/wall-move-side-handles.tsx +193 -18
  48. package/src/components/editor/wall-opening-highlights.tsx +63 -32
  49. package/src/components/editor/wall-snap-beacon-layer.tsx +6 -4
  50. package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +2 -1
  51. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +39 -8
  52. package/src/components/editor-2d/floorplan-cursor-indicator-position.test.ts +31 -0
  53. package/src/components/editor-2d/floorplan-cursor-indicator-position.ts +39 -0
  54. package/src/components/editor-2d/floorplan-group-move.tsx +47 -4
  55. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +21 -5
  56. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +72 -0
  57. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +93 -59
  58. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +27 -0
  59. package/src/components/editor-2d/floorplan-render-context.test.ts +27 -0
  60. package/src/components/editor-2d/floorplan-render-context.tsx +113 -8
  61. package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +285 -0
  62. package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +516 -0
  63. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +236 -0
  64. package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +618 -0
  65. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +369 -0
  66. package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +431 -32
  67. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +75 -1
  68. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +92 -0
  69. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +93 -28
  70. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +279 -0
  71. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +621 -281
  72. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +20 -10
  73. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +12 -7
  74. package/src/components/tools/fence/fence-drafting.ts +27 -0
  75. package/src/components/tools/item/use-draft-node.ts +131 -72
  76. package/src/components/tools/item/use-placement-coordinator.tsx +52 -4
  77. package/src/components/tools/registry/move-registry-node-tool.tsx +75 -4
  78. package/src/components/tools/roof/roof-tool.tsx +16 -0
  79. package/src/components/tools/shared/floor-stack-preview.ts +4 -0
  80. package/src/components/tools/shared/horizontal-construction-plane.ts +123 -0
  81. package/src/components/tools/shared/pointer-support-cap.test.ts +98 -0
  82. package/src/components/tools/shared/pointer-support-cap.ts +273 -0
  83. package/src/components/tools/site/site-boundary-editor.tsx +13 -6
  84. package/src/components/tools/site/stroke-pointer-ownership.test.ts +91 -0
  85. package/src/components/tools/site/stroke-pointer-ownership.ts +57 -0
  86. package/src/components/tools/site/terrain-brush-cursor.tsx +247 -0
  87. package/src/components/tools/site/terrain-sculpt-grid.tsx +192 -0
  88. package/src/components/tools/site/terrain-sculpt-tool.tsx +356 -0
  89. package/src/components/tools/stair/stair-click-guard.test.ts +58 -0
  90. package/src/components/tools/stair/stair-click-guard.ts +72 -0
  91. package/src/components/tools/stair/stair-tool.tsx +98 -47
  92. package/src/components/tools/tool-manager.tsx +67 -9
  93. package/src/components/tools/wall/wall-drafting.test.ts +118 -1
  94. package/src/components/tools/wall/wall-drafting.ts +128 -9
  95. package/src/components/tools/wall/wall-snap-geometry.test.ts +8 -0
  96. package/src/components/tools/wall/wall-snap-geometry.ts +70 -3
  97. package/src/components/tools/zone/zone-tool.tsx +16 -0
  98. package/src/components/ui/action-menu/control-modes.tsx +4 -1
  99. package/src/components/ui/action-menu/measurement-control.tsx +107 -28
  100. package/src/components/ui/command-palette/editor-commands.tsx +14 -1
  101. package/src/components/ui/controls/material-paint-panel.tsx +7 -1
  102. package/src/components/ui/controls/material-picker.tsx +86 -7
  103. package/src/components/ui/controls/terrain-sculpt-panel.tsx +193 -0
  104. package/src/components/ui/floating-level-selector.tsx +73 -19
  105. package/src/components/ui/helpers/helper-manager.tsx +46 -0
  106. package/src/components/ui/panels/multi-selection-panel.tsx +57 -0
  107. package/src/components/ui/panels/panel-manager.tsx +14 -1
  108. package/src/components/ui/panels/parametric-inspector.tsx +4 -2
  109. package/src/components/ui/panels/selection-breakdown.test.ts +24 -0
  110. package/src/components/ui/panels/selection-breakdown.ts +20 -0
  111. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +14 -4
  112. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +16 -0
  113. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -1
  114. package/src/components/ui/sidebar/panels/site-panel/index.tsx +2 -0
  115. package/src/components/viewer/viewer-controls-bar.tsx +466 -0
  116. package/src/components/viewer/viewer-scene-header.tsx +235 -0
  117. package/src/components/viewer-overlay.tsx +20 -655
  118. package/src/components/walkthrough-hud.tsx +111 -0
  119. package/src/hooks/use-grid-events.ts +24 -0
  120. package/src/hooks/use-keyboard.ts +53 -30
  121. package/src/index.tsx +137 -4
  122. package/src/lib/active-placement-surface.test.ts +24 -0
  123. package/src/lib/active-placement-surface.ts +8 -1
  124. package/src/lib/camera-pose.test.ts +237 -0
  125. package/src/lib/camera-pose.ts +172 -0
  126. package/src/lib/direct-manipulation.test.ts +83 -0
  127. package/src/lib/direct-manipulation.ts +9 -0
  128. package/src/lib/door-interaction.ts +1 -1
  129. package/src/lib/elevation-guides.test.ts +107 -0
  130. package/src/lib/elevation-guides.ts +236 -0
  131. package/src/lib/floorplan/annotation-visibility.test.ts +327 -0
  132. package/src/lib/floorplan/annotation-visibility.ts +133 -0
  133. package/src/lib/floorplan/drawing-coordination.test.ts +46 -0
  134. package/src/lib/floorplan/drawing-coordination.ts +13 -0
  135. package/src/lib/floorplan/floorplan-export.test.ts +319 -0
  136. package/src/lib/floorplan/floorplan-export.tsx +745 -131
  137. package/src/lib/floorplan/floorplan-extension.test.ts +43 -0
  138. package/src/lib/floorplan/floorplan-extension.ts +181 -0
  139. package/src/lib/floorplan/floorplan-mode.test.ts +120 -0
  140. package/src/lib/floorplan/floorplan-mode.ts +73 -0
  141. package/src/lib/floorplan/floorplan-pdfkit-document.ts +194 -0
  142. package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +107 -0
  143. package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +680 -0
  144. package/src/lib/floorplan/geometry.ts +2 -3
  145. package/src/lib/fresh-planar-placement.test.ts +44 -1
  146. package/src/lib/fresh-planar-placement.ts +21 -4
  147. package/src/lib/glb-export.test.ts +116 -1
  148. package/src/lib/glb-export.ts +204 -7
  149. package/src/lib/ground-surface.ts +95 -0
  150. package/src/lib/history.test.ts +96 -0
  151. package/src/lib/history.ts +22 -0
  152. package/src/lib/interaction/overlay-policy.test.ts +2 -1
  153. package/src/lib/interaction/scope.ts +40 -8
  154. package/src/lib/level-duplication.test.ts +2 -1
  155. package/src/lib/measurements.test.ts +5 -0
  156. package/src/lib/measurements.ts +12 -1
  157. package/src/lib/quick-action-feedback.ts +33 -0
  158. package/src/lib/quick-action-nodes.test.ts +94 -0
  159. package/src/lib/quick-action-nodes.ts +47 -0
  160. package/src/lib/scene-clipboard.test.ts +130 -1
  161. package/src/lib/scene-clipboard.ts +237 -13
  162. package/src/lib/selection-routing.test.ts +16 -1
  163. package/src/lib/selection-routing.ts +5 -0
  164. package/src/lib/sfx-bus.ts +33 -2
  165. package/src/lib/sfx-player.test.ts +148 -6
  166. package/src/lib/sfx-player.ts +156 -8
  167. package/src/lib/site-boundary.test.ts +53 -0
  168. package/src/lib/site-boundary.ts +27 -0
  169. package/src/lib/snapping-mode.test.ts +29 -2
  170. package/src/lib/snapping-mode.ts +15 -3
  171. package/src/lib/stair-duplication.test.ts +103 -0
  172. package/src/lib/stair-duplication.ts +57 -85
  173. package/src/lib/stair-levels.ts +2 -0
  174. package/src/lib/terrain-sculpt.test.ts +429 -0
  175. package/src/lib/terrain-sculpt.ts +301 -0
  176. package/src/lib/terrain-verb-color.test.ts +51 -0
  177. package/src/lib/terrain-verb-color.ts +58 -0
  178. package/src/lib/touch-gesture-priority.test.ts +45 -0
  179. package/src/lib/touch-gesture-priority.ts +30 -0
  180. package/src/lib/window-interaction.ts +1 -1
  181. package/src/store/camera-pose-store.test.ts +29 -0
  182. package/src/store/camera-pose-store.ts +27 -0
  183. package/src/store/live-draft-preview-stores.test.ts +136 -0
  184. package/src/store/navigation-sync-pose-store.test.ts +34 -0
  185. package/src/store/navigation-sync-pose-store.ts +27 -0
  186. package/src/store/terrain-sculpt-mode.test.ts +223 -0
  187. package/src/store/use-delete-confirmation.ts +27 -0
  188. package/src/store/use-drawing-view.test.ts +23 -0
  189. package/src/store/use-drawing-view.ts +84 -0
  190. package/src/store/use-editor.tsx +184 -15
  191. package/src/store/use-elevation-guides.ts +40 -0
  192. package/src/store/use-fence-curve-draft.ts +38 -8
  193. package/src/store/use-first-person-hud.ts +40 -0
  194. package/src/store/use-floorplan-annotation-visibility.ts +60 -0
  195. package/src/store/use-floorplan-draft-preview.ts +54 -4
  196. package/src/store/use-floorplan-mode.ts +110 -0
  197. package/src/store/use-interaction-scope.test.ts +23 -3
  198. package/src/store/use-interaction-scope.ts +8 -0
  199. package/src/store/use-measurement-draft.test.ts +29 -0
  200. package/src/store/use-measurement-draft.ts +33 -6
  201. package/src/store/use-path-draft-preview.ts +90 -0
  202. package/src/store/use-stair-build-preview.ts +12 -2
  203. package/src/lib/floorplan/selection-tool.ts +0 -271
@@ -8,10 +8,17 @@ import {
8
8
  findLevelAncestorId,
9
9
  nodeRegistry,
10
10
  registerNode,
11
+ type SceneCommit,
12
+ subscribeSceneCommits,
11
13
  useScene,
12
14
  } from '@pascal-app/core'
13
15
  import { z } from 'zod'
14
- import { commitFreshPlacementSubtree, createFreshPlacementSubtree } from './fresh-planar-placement'
16
+ import {
17
+ commitFreshPlacementSubtree,
18
+ createFreshPlacementSubtree,
19
+ duplicatesAsFreshSubtree,
20
+ prepareFreshPlacementRootDuplicate,
21
+ } from './fresh-planar-placement'
15
22
 
16
23
  type RafFn = (cb: (time: number) => void) => number
17
24
  ;(globalThis as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= ((
@@ -170,12 +177,15 @@ describe('commitFreshPlacementSubtree', () => {
170
177
  })
171
178
 
172
179
  test('commits a fresh draft as one undoable clean subtree', () => {
180
+ const commits: SceneCommit[] = []
181
+ const unsubscribe = subscribeSceneCommits((commit) => commits.push(commit))
173
182
  useScene.temporal.getState().pause()
174
183
 
175
184
  const committedId = commitFreshPlacementSubtree(SHELF_ID, {
176
185
  position: [2, 0, 3],
177
186
  visible: true,
178
187
  } as Partial<AnyNode>)
188
+ unsubscribe()
179
189
 
180
190
  expect(committedId).toBeTruthy()
181
191
  expect(committedId).not.toBe(SHELF_ID)
@@ -192,6 +202,12 @@ describe('commitFreshPlacementSubtree', () => {
192
202
  expect((useScene.getState().nodes[LEVEL_ID] as { children: AnyNodeId[] }).children).toEqual([
193
203
  finalId,
194
204
  ])
205
+ expect(commits).toHaveLength(1)
206
+ expect(commits[0]?.origin).toBe('local')
207
+ expect(commits[0]?.before.nodes[SHELF_ID]).toBeUndefined()
208
+ expect(commits[0]?.before.nodes[finalId]).toBeUndefined()
209
+ expect(commits[0]?.current.nodes[SHELF_ID]).toBeUndefined()
210
+ expect(commits[0]?.current.nodes[finalId]).toEqual(committed)
195
211
 
196
212
  useScene.temporal.getState().resume()
197
213
  useScene.temporal.getState().undo()
@@ -201,6 +217,33 @@ describe('commitFreshPlacementSubtree', () => {
201
217
  expect((useScene.getState().nodes[LEVEL_ID] as { children: AnyNodeId[] }).children).toEqual([])
202
218
  })
203
219
 
220
+ test('uses the subtree contract for childless variants and never aliases root-only children', () => {
221
+ registerCabinetClonePrepTestKind()
222
+ const childlessCabinet = {
223
+ ...shelf(),
224
+ type: 'cabinet',
225
+ children: [],
226
+ metadata: { isTransient: true, label: 'source' },
227
+ } as AnyNode
228
+ expect(duplicatesAsFreshSubtree(childlessCabinet)).toBe(true)
229
+
230
+ const source = {
231
+ ...shelf(),
232
+ children: ['item_original' as AnyNodeId],
233
+ metadata: { isTransient: true, label: 'source' },
234
+ } as AnyNode
235
+ const duplicate = prepareFreshPlacementRootDuplicate(source) as AnyNode & {
236
+ children: AnyNodeId[]
237
+ id?: AnyNodeId
238
+ metadata?: Record<string, unknown>
239
+ }
240
+
241
+ expect(duplicate.id).toBeUndefined()
242
+ expect(duplicate.children).toEqual([])
243
+ expect(duplicate.metadata).toEqual({ isNew: true, label: 'source' })
244
+ expect((source as AnyNode & { children: AnyNodeId[] }).children).toEqual(['item_original'])
245
+ })
246
+
204
247
  test('commits a duplicated cabinet draft without deleting the original modules', () => {
205
248
  seedCabinetRun()
206
249
  useScene.temporal.getState().clear()
@@ -27,10 +27,27 @@ function duplicableConfigFor(node: AnyNode): DuplicableConfig | null {
27
27
  }
28
28
 
29
29
  export function duplicatesAsFreshSubtree(node: AnyNode): boolean {
30
- const children = (node as { children?: unknown }).children
31
- return (
32
- duplicableConfigFor(node)?.subtree === true && Array.isArray(children) && children.length > 0
33
- )
30
+ return duplicableConfigFor(node)?.subtree === true
31
+ }
32
+
33
+ /**
34
+ * Prepares a non-subtree duplicate without retaining ownership of the
35
+ * original node's children. Subtree-capable kinds take the path above and
36
+ * receive fresh descendant IDs; every other kind duplicates only its root.
37
+ */
38
+ export function prepareFreshPlacementRootDuplicate(node: AnyNode): AnyNode {
39
+ const duplicate = structuredClone(node) as unknown as Record<string, unknown> & {
40
+ id?: AnyNodeId
41
+ children?: unknown
42
+ metadata?: unknown
43
+ }
44
+ delete duplicate.id
45
+ if (Array.isArray(duplicate.children)) duplicate.children = []
46
+ duplicate.metadata = {
47
+ ...getPlacementMetadataRecord(stripPlacementMetadataFlags(duplicate.metadata)),
48
+ isNew: true,
49
+ }
50
+ return duplicate as unknown as AnyNode
34
51
  }
35
52
 
36
53
  /**
@@ -2,7 +2,13 @@ import { afterEach, describe, expect, test } from 'bun:test'
2
2
  import { type AnyNode, DoorNode, registerNode, sceneRegistry } from '@pascal-app/core'
3
3
  import { buildDoorPreviewMesh } from '@pascal-app/viewer'
4
4
  import * as THREE from 'three'
5
- import { prepareSceneForExport } from './glb-export'
5
+ import type { GLTFWriter } from 'three/examples/jsm/exporters/GLTFExporter.js'
6
+ import { prepareSceneForExport, writeTextureReferenceExtras } from './glb-export'
7
+
8
+ // The reference module reads the storage origin lazily on first use, so
9
+ // setting the env here (before any validation call) pins it for the file.
10
+ process.env.NEXT_PUBLIC_SUPABASE_URL ??= 'https://test-storage.supabase.co'
11
+ const STORAGE_ORIGIN = new URL(process.env.NEXT_PUBLIC_SUPABASE_URL).origin
6
12
 
7
13
  afterEach(() => {
8
14
  sceneRegistry.clear()
@@ -62,6 +68,79 @@ describe('prepareSceneForExport', () => {
62
68
  expect(meshes[0]!.material).toBe(meshes[1]!.material)
63
69
  })
64
70
 
71
+ test('reference mode swaps stamped compressed textures and leaves unstamped textures embedded', () => {
72
+ const root = new THREE.Group()
73
+ const stamped = new THREE.CompressedTexture([], 4, 4)
74
+ stamped.wrapS = THREE.MirroredRepeatWrapping
75
+ stamped.wrapT = THREE.ClampToEdgeWrapping
76
+ stamped.repeat.set(2, 3)
77
+ stamped.offset.set(0.25, 0.5)
78
+ stamped.center.set(0.5, 0.5)
79
+ stamped.rotation = 0.75
80
+ stamped.flipY = false
81
+ stamped.colorSpace = THREE.SRGBColorSpace
82
+ stamped.updateMatrix()
83
+ stamped.userData.pascalTextureRef = {
84
+ v: 1,
85
+ kind: 'library-material',
86
+ src: `${STORAGE_ORIGIN}/storage/v1/object/public/materials/user/material/oak_basecolor_512.ktx2`,
87
+ map: 'basecolor',
88
+ colorSpace: 'srgb',
89
+ }
90
+ const unstamped = new THREE.DataTexture(new Uint8Array([128, 128, 255, 255]), 1, 1)
91
+ root.add(
92
+ meshWithNodeMaterial(nodeMaterial({ map: stamped, normalMap: unstamped })),
93
+ meshWithNodeMaterial(nodeMaterial({ map: stamped })),
94
+ )
95
+
96
+ const { scene } = prepareSceneForExport(root, {}, { textures: 'reference' })
97
+
98
+ const material = (scene.children[0] as THREE.Mesh).material as THREE.MeshStandardMaterial
99
+ const placeholder = material.map as THREE.DataTexture
100
+ expect(placeholder).not.toBe(stamped)
101
+ expect(placeholder.isDataTexture).toBe(true)
102
+ expect(
103
+ (placeholder as THREE.DataTexture & { isCompressedTexture?: boolean }).isCompressedTexture,
104
+ ).toBeUndefined()
105
+ expect(placeholder.image.width).toBe(1)
106
+ expect(placeholder.image.height).toBe(1)
107
+ expect(Array.from(placeholder.image.data!)).toEqual([255, 255, 255, 255])
108
+ expect(placeholder.wrapS).toBe(stamped.wrapS)
109
+ expect(placeholder.wrapT).toBe(stamped.wrapT)
110
+ expect(placeholder.repeat.toArray()).toEqual(stamped.repeat.toArray())
111
+ expect(placeholder.offset.toArray()).toEqual(stamped.offset.toArray())
112
+ expect(placeholder.center.toArray()).toEqual(stamped.center.toArray())
113
+ expect(placeholder.rotation).toBe(stamped.rotation)
114
+ expect(placeholder.flipY).toBe(stamped.flipY)
115
+ expect(placeholder.colorSpace).toBe(stamped.colorSpace)
116
+ expect(placeholder.userData.pascalTextureRef).toEqual(stamped.userData.pascalTextureRef)
117
+ expect(material.normalMap).toBe(unstamped)
118
+ const sharedMaterial = (scene.children[1] as THREE.Mesh).material as THREE.MeshStandardMaterial
119
+ expect(sharedMaterial.map).toBe(placeholder)
120
+ })
121
+
122
+ test('writes identical texture-reference extras to the texture and image definitions', () => {
123
+ const texture = new THREE.DataTexture(new Uint8Array([255, 255, 255, 255]), 1, 1)
124
+ const ref = {
125
+ v: 1,
126
+ kind: 'item-glb',
127
+ src: `${STORAGE_ORIGIN}/storage/v1/object/public/items/system/chair/models/chair.glb`,
128
+ imageIndex: 3,
129
+ map: 'normal',
130
+ colorSpace: 'linear',
131
+ }
132
+ texture.userData.pascalTextureRef = ref
133
+ const imageDef: { extras?: Record<string, unknown> } = {}
134
+ const textureDef: { source: number; extras?: Record<string, unknown> } = { source: 0 }
135
+ const writer = { json: { images: [imageDef] } } as unknown as GLTFWriter
136
+
137
+ writeTextureReferenceExtras(writer, texture, textureDef)
138
+
139
+ expect(textureDef.extras?.pascalTextureRef).toEqual(ref)
140
+ expect(imageDef.extras?.pascalTextureRef).toEqual(ref)
141
+ expect(textureDef.extras?.pascalTextureRef).toEqual(imageDef.extras?.pascalTextureRef)
142
+ })
143
+
65
144
  test('strips editor overlays that live off the scene layer', () => {
66
145
  const root = new THREE.Group()
67
146
  const realMesh = meshWithNodeMaterial(nodeMaterial())
@@ -78,6 +157,22 @@ describe('prepareSceneForExport', () => {
78
157
  expect(meshes).toHaveLength(1)
79
158
  })
80
159
 
160
+ test('strips presentation-only geometry marked by its renderer', () => {
161
+ const root = new THREE.Group()
162
+ const siteGround = meshWithNodeMaterial(nodeMaterial())
163
+ const horizonDisc = meshWithNodeMaterial(nodeMaterial())
164
+ horizonDisc.userData.pascalExport = 'strip'
165
+ root.add(siteGround, horizonDisc)
166
+
167
+ const { scene } = prepareSceneForExport(root, {})
168
+
169
+ const meshes: THREE.Mesh[] = []
170
+ scene.traverse((object) => {
171
+ if ((object as THREE.Mesh).isMesh) meshes.push(object as THREE.Mesh)
172
+ })
173
+ expect(meshes).toHaveLength(1)
174
+ })
175
+
81
176
  test('neutralises an invisible hitbox root but keeps its visible children', () => {
82
177
  // Door/window roots are selection hitboxes: a box geometry with an invisible
83
178
  // material (object stays visible). Left intact it would plug the wall opening.
@@ -107,6 +202,26 @@ describe('prepareSceneForExport', () => {
107
202
  expect(visibleChildren).toHaveLength(1)
108
203
  })
109
204
 
205
+ test('fills undefined slots in an array material so no undefined survives the prune', () => {
206
+ // Multi-material / group geometry where one slot was never assigned:
207
+ // `mesh.material = [validMat, undefined]`. The scalar-null guard doesn't
208
+ // catch this (an array is never `== null`), so the undefined slot used to
209
+ // reach GLTFExporter and crash on `material.isShaderMaterial`.
210
+ const root = new THREE.Group()
211
+ const mesh = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1))
212
+ mesh.material = [nodeMaterial(), undefined as unknown as THREE.Material]
213
+ root.add(mesh)
214
+
215
+ const { scene } = prepareSceneForExport(root, {})
216
+
217
+ const exported = scene.children[0] as THREE.Mesh
218
+ const materials = exported.material as THREE.Material[]
219
+ expect(Array.isArray(materials)).toBe(true)
220
+ expect(materials).toHaveLength(2)
221
+ // No undefined/null slot survives; every slot is a real material.
222
+ expect(materials.every((m) => m != null)).toBe(true)
223
+ })
224
+
110
225
  test('stamps identity from the scene registry and strips other userData', () => {
111
226
  const root = new THREE.Group()
112
227
  const doorGroup = new THREE.Group()
@@ -13,6 +13,7 @@ import {
13
13
  type ZoneNode,
14
14
  } from '@pascal-app/core'
15
15
  import {
16
+ getPascalTextureRef,
16
17
  poseDoorMovingParts,
17
18
  poseWindowMovingParts,
18
19
  SCENE_LAYER,
@@ -20,7 +21,11 @@ import {
20
21
  } from '@pascal-app/viewer'
21
22
  import type { Object3D } from 'three'
22
23
  import * as THREE from 'three'
23
- import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js'
24
+ import {
25
+ GLTFExporter,
26
+ type GLTFExporterPlugin,
27
+ type GLTFWriter,
28
+ } from 'three/examples/jsm/exporters/GLTFExporter.js'
24
29
  import * as WebGPUTextureUtils from 'three/examples/jsm/utils/WebGPUTextureUtils.js'
25
30
 
26
31
  /**
@@ -41,6 +46,10 @@ export type GlbExport = {
41
46
  animations: THREE.AnimationClip[]
42
47
  }
43
48
 
49
+ export type GlbExportOptions = {
50
+ textures?: 'embed' | 'reference'
51
+ }
52
+
44
53
  /** Resolve after the next couple of animation frames, giving React/R3F time to
45
54
  * commit and mount export-only geometry (e.g. instanced kinds' real meshes)
46
55
  * before the exporter clones the scene graph. Callers must set
@@ -51,10 +60,63 @@ export function nextFrames(): Promise<void> {
51
60
  })
52
61
  }
53
62
 
63
+ type GltfExtrasDef = {
64
+ extras?: Record<string, unknown>
65
+ }
66
+
67
+ type TextureReferenceWriter = GLTFWriter & {
68
+ json: {
69
+ images?: GltfExtrasDef[]
70
+ }
71
+ }
72
+
73
+ function getExportedImageIndex(textureDef: Record<string, unknown>): number | null {
74
+ if (Number.isInteger(textureDef.source)) return textureDef.source as number
75
+ const extensions = textureDef.extensions as Record<string, { source?: unknown }> | undefined
76
+ const source = extensions?.EXT_texture_webp?.source ?? extensions?.EXT_texture_avif?.source
77
+ return Number.isInteger(source) ? (source as number) : null
78
+ }
79
+
80
+ export function writeTextureReferenceExtras(
81
+ writer: GLTFWriter,
82
+ texture: THREE.Texture,
83
+ textureDef: Record<string, unknown>,
84
+ ) {
85
+ const ref = getPascalTextureRef(texture)
86
+ if (!ref) return
87
+
88
+ const imageIndex = getExportedImageIndex(textureDef)
89
+ const imageDef =
90
+ imageIndex === null ? undefined : (writer as TextureReferenceWriter).json.images?.[imageIndex]
91
+ if (!imageDef) {
92
+ throw new Error('GLTFExporter did not expose an image for a referenced Pascal texture')
93
+ }
94
+
95
+ const textureWithExtras = textureDef as GltfExtrasDef
96
+ textureWithExtras.extras = {
97
+ ...textureWithExtras.extras,
98
+ pascalTextureRef: ref,
99
+ }
100
+ imageDef.extras = {
101
+ ...imageDef.extras,
102
+ pascalTextureRef: ref,
103
+ }
104
+ }
105
+
106
+ function textureReferencePlugin(writer: GLTFWriter): GLTFExporterPlugin {
107
+ return {
108
+ writeTexture: (texture, textureDef) => {
109
+ writeTextureReferenceExtras(writer, texture, textureDef)
110
+ },
111
+ }
112
+ }
113
+
54
114
  export async function exportSceneToGlb(
55
115
  sceneGroup: Object3D,
56
116
  nodes: Record<string, AnyNode>,
117
+ options: GlbExportOptions = {},
57
118
  ): Promise<ArrayBuffer> {
119
+ const textureMode = options.textures ?? 'embed'
58
120
  emitter.emit('thumbnail:before-capture', undefined)
59
121
  // Snap levels to their true stacked positions (like thumbnail capture) so the
60
122
  // export always reflects the clean stacked building, regardless of the live
@@ -63,7 +125,10 @@ export async function exportSceneToGlb(
63
125
  const restoreLevels = snapLevelsToTruePositions()
64
126
  let prepared: ReturnType<typeof prepareSceneForExport>
65
127
  try {
66
- prepared = prepareSceneForExport(sceneGroup, nodes)
128
+ prepared =
129
+ textureMode === 'reference'
130
+ ? prepareSceneForExport(sceneGroup, nodes, { textures: 'reference' })
131
+ : prepareSceneForExport(sceneGroup, nodes)
67
132
  } finally {
68
133
  restoreLevels()
69
134
  emitter.emit('thumbnail:after-capture', undefined)
@@ -71,6 +136,7 @@ export async function exportSceneToGlb(
71
136
  const { scene: exportScene, animations } = prepared
72
137
 
73
138
  const exporter = new GLTFExporter()
139
+ if (textureMode === 'reference') exporter.register(textureReferencePlugin)
74
140
  // Painted finishes use KTX2 (GPU-compressed) maps; GLTFExporter can't read
75
141
  // those directly. WebGPUTextureUtils blits each one to RGBA on its own
76
142
  // offscreen renderer (passing the live renderer would resize/draw over the
@@ -112,6 +178,7 @@ export async function exportSceneToGlb(
112
178
  export function prepareSceneForExport(
113
179
  source: THREE.Object3D,
114
180
  nodes: Record<string, AnyNode>,
181
+ options: GlbExportOptions = {},
115
182
  ): GlbExport {
116
183
  const scene = source.clone(true)
117
184
  const cloneByOriginal = pairClones(source, scene)
@@ -142,7 +209,7 @@ export function prepareSceneForExport(
142
209
 
143
210
  pruneNonRenderableMeshes(scene, identityNodes)
144
211
  sanitizeMaterialGroups(scene, identityNodes)
145
- convertMaterials(scene)
212
+ convertMaterials(scene, options.textures ?? 'embed')
146
213
 
147
214
  const { clips, clipNamesByNode } = bakeAnimationClips(cloneByOriginal, nodes)
148
215
 
@@ -185,6 +252,10 @@ const PLACEHOLDER_MATERIAL = new THREE.MeshBasicMaterial({ visible: false })
185
252
 
186
253
  /**
187
254
  * Strip everything that must not bake into the model:
255
+ * - Renderer-owned presentation geometry explicitly marked
256
+ * `userData.pascalExport = 'strip'` (for example the site's 800 m horizon
257
+ * disc). These meshes make the authoring viewport look grounded but aren't
258
+ * part of the portable scene artifact.
188
259
  * - Editor overlays on non-scene layers (gizmos, selection handles, ground
189
260
  * grid, zone fills). The editor camera shows them via extra layers; a
190
261
  * thumbnail/bake is layer 0 only. Scene-layer affordances that can't be
@@ -199,6 +270,10 @@ const PLACEHOLDER_MATERIAL = new THREE.MeshBasicMaterial({ visible: false })
199
270
  function pruneNonRenderableMeshes(root: THREE.Object3D, identityNodes: Set<THREE.Object3D>) {
200
271
  const toRemove: THREE.Object3D[] = []
201
272
  root.traverse((object) => {
273
+ if (object.userData.pascalExport === 'strip') {
274
+ toRemove.push(object)
275
+ return
276
+ }
202
277
  // Editor-only overlays (gizmos, selection handles, ground grid, zone fills)
203
278
  // live off the scene layer; the editor camera shows them via extra layers
204
279
  // but a thumbnail/bake only wants layer 0. Drop the whole overlay subtree —
@@ -228,6 +303,20 @@ function pruneNonRenderableMeshes(root: THREE.Object3D, identityNodes: Set<THREE
228
303
  }
229
304
  return
230
305
  }
306
+ // The scalar branch above only catches `material == null`; an ARRAY material
307
+ // (multi-material / group geometry) is never `== null`, so a slot that was
308
+ // never assigned — e.g. `[validMat, undefined]` — slips through and reaches
309
+ // GLTFExporter, which reads `material.isShaderMaterial` on every group's
310
+ // material and crashes on the undefined slot. Don't drop the mesh (the other
311
+ // slots may be real geometry): fill any null/undefined holes with the hidden
312
+ // placeholder so the exporter never sees an undefined material.
313
+ if (
314
+ (renderable.isMesh === true || renderable.isLine === true || renderable.isPoints === true) &&
315
+ Array.isArray(renderable.material) &&
316
+ renderable.material.some((m) => m == null)
317
+ ) {
318
+ renderable.material = renderable.material.map((m) => m ?? PLACEHOLDER_MATERIAL)
319
+ }
231
320
  const mesh = object as THREE.Mesh
232
321
  if (!mesh.isMesh || isRenderableMesh(mesh)) return
233
322
  if (mesh.children.length > 0) {
@@ -330,14 +419,31 @@ const STANDARD_MAP_SLOTS = [
330
419
  'bumpMap',
331
420
  ] as const
332
421
 
333
- function convertMaterials(root: THREE.Object3D) {
422
+ const REFERENCE_MAP_SLOTS = [
423
+ ...STANDARD_MAP_SLOTS,
424
+ 'clearcoatMap',
425
+ 'clearcoatNormalMap',
426
+ 'clearcoatRoughnessMap',
427
+ 'iridescenceMap',
428
+ 'iridescenceThicknessMap',
429
+ 'transmissionMap',
430
+ 'thicknessMap',
431
+ 'specularIntensityMap',
432
+ 'specularColorMap',
433
+ 'sheenRoughnessMap',
434
+ 'sheenColorMap',
435
+ 'anisotropyMap',
436
+ ] as const
437
+
438
+ function convertMaterials(root: THREE.Object3D, textureMode: 'embed' | 'reference') {
334
439
  const cache = new Map<THREE.Material, THREE.Material>()
440
+ const placeholderCache = new Map<THREE.Texture, THREE.Texture>()
335
441
  root.traverse((object) => {
336
442
  const mesh = object as THREE.Mesh
337
443
  if (!mesh.isMesh) return
338
444
  const material = mesh.material
339
445
  if (Array.isArray(material)) {
340
- mesh.material = material.map((m) => convertMaterial(m, cache))
446
+ mesh.material = material.map((m) => convertMaterial(m, cache, textureMode, placeholderCache))
341
447
  return
342
448
  }
343
449
  // glTF has no BackSide — GLTFExporter renders the *front* face for any
@@ -351,7 +457,7 @@ function convertMaterials(root: THREE.Object3D) {
351
457
  ) {
352
458
  mesh.geometry = flipGeometryWinding(mesh.geometry)
353
459
  }
354
- mesh.material = convertMaterial(material, cache)
460
+ mesh.material = convertMaterial(material, cache, textureMode, placeholderCache)
355
461
  })
356
462
  }
357
463
 
@@ -401,8 +507,19 @@ function flipGeometryWinding(geometry: THREE.BufferGeometry): THREE.BufferGeomet
401
507
  function convertMaterial(
402
508
  material: THREE.Material,
403
509
  cache: Map<THREE.Material, THREE.Material>,
510
+ textureMode: 'embed' | 'reference',
511
+ placeholderCache: Map<THREE.Texture, THREE.Texture>,
404
512
  ): THREE.Material {
405
- if ((material as { isNodeMaterial?: boolean }).isNodeMaterial !== true) return material
513
+ const isNodeMaterial = (material as { isNodeMaterial?: boolean }).isNodeMaterial === true
514
+ if (!isNodeMaterial) {
515
+ if (textureMode === 'embed') return material
516
+ const cached = cache.get(material)
517
+ if (cached) return cached
518
+ const target = material.clone()
519
+ replaceReferencedTextures(target, placeholderCache)
520
+ cache.set(material, target)
521
+ return target
522
+ }
406
523
 
407
524
  const cached = cache.get(material)
408
525
  if (cached) return cached
@@ -443,10 +560,90 @@ function convertMaterial(
443
560
  }
444
561
  }
445
562
 
563
+ if (textureMode === 'reference') replaceReferencedTextures(target, placeholderCache)
564
+
446
565
  cache.set(material, target)
447
566
  return target
448
567
  }
449
568
 
569
+ function replaceReferencedTextures(
570
+ material: THREE.Material,
571
+ placeholderCache: Map<THREE.Texture, THREE.Texture>,
572
+ ) {
573
+ const textureMaterial = material as THREE.Material & Record<string, unknown>
574
+ for (const slot of REFERENCE_MAP_SLOTS) {
575
+ const texture = textureMaterial[slot]
576
+ if (!(texture instanceof THREE.Texture) || !getPascalTextureRef(texture)) continue
577
+
578
+ let placeholder = placeholderCache.get(texture)
579
+ if (!placeholder) {
580
+ placeholder = createReferencePlaceholder(texture)
581
+ placeholderCache.set(texture, placeholder)
582
+ }
583
+ textureMaterial[slot] = placeholder
584
+ }
585
+ }
586
+
587
+ /** GLTFExporter serializes images via canvas drawImage/createImageBitmap,
588
+ * which reject a DataTexture's raw `{data,width,height}` image — so in DOM
589
+ * environments the placeholder must be canvas-backed. The DataTexture branch
590
+ * covers non-DOM runs (bun tests), where the exporter itself never runs. */
591
+ function createPlaceholderCanvas(): OffscreenCanvas | HTMLCanvasElement | null {
592
+ const canvas =
593
+ typeof OffscreenCanvas !== 'undefined'
594
+ ? new OffscreenCanvas(1, 1)
595
+ : typeof document !== 'undefined'
596
+ ? Object.assign(document.createElement('canvas'), { width: 1, height: 1 })
597
+ : null
598
+ if (!canvas) return null
599
+ const ctx = canvas.getContext('2d') as
600
+ | OffscreenCanvasRenderingContext2D
601
+ | CanvasRenderingContext2D
602
+ | null
603
+ if (!ctx) return null
604
+ ctx.fillStyle = '#ffffff'
605
+ ctx.fillRect(0, 0, 1, 1)
606
+ return canvas
607
+ }
608
+
609
+ function createReferencePlaceholder(texture: THREE.Texture): THREE.Texture {
610
+ const ref = getPascalTextureRef(texture)
611
+ if (!ref) throw new Error('Cannot create a placeholder for an invalid Pascal texture reference')
612
+
613
+ const canvas = createPlaceholderCanvas()
614
+ const placeholder = canvas
615
+ ? new THREE.Texture(canvas)
616
+ : new THREE.DataTexture(
617
+ new Uint8Array([255, 255, 255, 255]),
618
+ 1,
619
+ 1,
620
+ THREE.RGBAFormat,
621
+ THREE.UnsignedByteType,
622
+ )
623
+ placeholder.name = texture.name
624
+ placeholder.mapping = texture.mapping
625
+ placeholder.channel = texture.channel
626
+ placeholder.wrapS = texture.wrapS
627
+ placeholder.wrapT = texture.wrapT
628
+ placeholder.magFilter = texture.magFilter
629
+ placeholder.minFilter = texture.minFilter
630
+ placeholder.anisotropy = texture.anisotropy
631
+ placeholder.offset.copy(texture.offset)
632
+ placeholder.repeat.copy(texture.repeat)
633
+ placeholder.center.copy(texture.center)
634
+ placeholder.rotation = texture.rotation
635
+ placeholder.matrixAutoUpdate = texture.matrixAutoUpdate
636
+ placeholder.matrix.copy(texture.matrix)
637
+ placeholder.generateMipmaps = texture.generateMipmaps
638
+ placeholder.premultiplyAlpha = texture.premultiplyAlpha
639
+ placeholder.flipY = texture.flipY
640
+ placeholder.unpackAlignment = texture.unpackAlignment
641
+ placeholder.colorSpace = texture.colorSpace
642
+ placeholder.userData = { pascalTextureRef: ref }
643
+ placeholder.needsUpdate = true
644
+ return placeholder
645
+ }
646
+
450
647
  // --- Animation clip baking ----------------------------------------------
451
648
 
452
649
  function bakeAnimationClips(
@@ -0,0 +1,95 @@
1
+ // The one place that answers "where is the ground under this pointer ray?".
2
+ //
3
+ // Before terrain there was no question to answer: the ground was `y = 0` and
4
+ // every consumer intersected its own horizontal plane. Terrain makes those
5
+ // planes wrong in a way that is invisible on flat ground and badly wrong on a
6
+ // slope — a ray intersected at the datum instead of at the surface reports an XZ
7
+ // that slides along the ray, further the more oblique the camera. Item placement
8
+ // already repairs this locally (`resolvePointerSupportSurface`); this module
9
+ // exists so the repair is shared instead of copied into column, wall, fence, and
10
+ // the sculpt tool.
11
+ //
12
+ // It lives in `editor` rather than `core` because the decision needs one piece of
13
+ // editor knowledge — which plane a consumer is asking about — while the geometry
14
+ // it delegates to (`raycastTerrain`, `heightAt`) is pure core.
15
+
16
+ import {
17
+ heightAt,
18
+ isSiteDatum,
19
+ raycastTerrain,
20
+ type SiteNode,
21
+ type TerrainField,
22
+ type TerrainHit,
23
+ terrainFieldOf,
24
+ useScene,
25
+ } from '@pascal-app/core'
26
+
27
+ /** The active site's terrain field, or null when the scene has no sculpted ground. */
28
+ export function siteTerrainField(): TerrainField | null {
29
+ const { nodes, rootNodeIds } = useScene.getState()
30
+ const siteId = rootNodeIds[0]
31
+ const site = siteId ? nodes[siteId] : null
32
+ if (site?.type !== 'site') return null
33
+ return terrainFieldOf(site as SiteNode)
34
+ }
35
+
36
+ /**
37
+ * True when a horizontal query plane at `planeY` is the *site ground*, as opposed
38
+ * to a built flat surface that happens to be horizontal.
39
+ *
40
+ * This is the whole scoping rule, and getting it wrong in either direction is
41
+ * worse than not having terrain at all:
42
+ *
43
+ * - Treat every horizontal plane as ground and a second-storey wall draft snaps
44
+ * to the hillside under the building.
45
+ * - Treat none of them as ground and every ground tool keeps the perspective skew
46
+ * terrain was supposed to remove.
47
+ *
48
+ * The datum is exactly the right discriminator because terrain *is* the surface at
49
+ * the datum: the site renderer removes the flat ground fill when terrain mounts,
50
+ * so at the datum there is nothing else to hit. Every other horizontal plane —
51
+ * a storey base, a slab top, a shelf — is a real flat surface built by the user,
52
+ * and a plane is the correct model for it.
53
+ *
54
+ * `isSiteDatum` comes from core so this rule and the one the support resolver
55
+ * applies (`terrain-support.ts`) cannot drift into disagreeing about which
56
+ * surfaces are ground.
57
+ */
58
+ export function isSiteGroundPlane(planeY: number): boolean {
59
+ return isSiteDatum(planeY)
60
+ }
61
+
62
+ /**
63
+ * Where a pointer ray meets the sculpted ground, or null when the flat-plane path
64
+ * should be used instead — no terrain, the ray misses the field, or the query
65
+ * plane is a built surface rather than the ground.
66
+ *
67
+ * Returning null rather than a fallback hit is deliberate: the caller already owns
68
+ * a correct plane intersection for its own frame (world for grid events,
69
+ * level-local for support election), and duplicating that here would mean two
70
+ * definitions of the fallback to keep in sync.
71
+ */
72
+ export function resolveTerrainGroundHit(
73
+ origin: readonly [number, number, number],
74
+ direction: readonly [number, number, number],
75
+ planeY: number,
76
+ ): TerrainHit | null {
77
+ if (!isSiteGroundPlane(planeY)) return null
78
+ const field = siteTerrainField()
79
+ if (!field) return null
80
+ return raycastTerrain(field, origin, direction)
81
+ }
82
+
83
+ /**
84
+ * Ground height at a world XZ, for consumers with no ray to march.
85
+ *
86
+ * The 2D plan view is the case this exists for: an orthographic top-down camera
87
+ * makes the pointer ray vertical, so the march degenerates to a single sample and
88
+ * there is no XZ skew to correct — only a Y to report.
89
+ */
90
+ export function groundHeightAt(x: number, z: number, planeY: number): number | null {
91
+ if (!isSiteGroundPlane(planeY)) return null
92
+ const field = siteTerrainField()
93
+ if (!field) return null
94
+ return heightAt(field, x, z)
95
+ }