@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
@@ -7,6 +7,7 @@ import {
7
7
  findLevelAncestorId,
8
8
  type GeometryContext,
9
9
  getLevelDisplayName,
10
+ hidesDescendants,
10
11
  isNodeKindEnabled,
11
12
  isOperationDoorType,
12
13
  itemClipRegistry,
@@ -35,9 +36,13 @@ import {
35
36
  type GLTFExporterPlugin,
36
37
  type GLTFWriter,
37
38
  } from 'three/examples/jsm/exporters/GLTFExporter.js'
38
- import * as WebGPUTextureUtils from 'three/examples/jsm/utils/WebGPUTextureUtils.js'
39
+ import { createExportTextureUtils, type ExportTextureUtils } from './export-texture-utils'
40
+ import { cloneExportUserData } from './export-user-data'
39
41
  import {
42
+ type CompressedTextureDecompressor,
43
+ decompressCanonicalNormalMaps,
40
44
  disposeExportResources,
45
+ GLASS_OPACITY_USERDATA,
41
46
  normalizePortableScene,
42
47
  normalizeViewerArtifactMaterials,
43
48
  } from './portable-export'
@@ -69,12 +74,61 @@ export type GlbExportOptions = {
69
74
  excludedNodeTypes?: readonly string[]
70
75
  /** Selected static viewer-presentation contributions; omitted means none. */
71
76
  includedPresentationIds?: readonly string[]
72
- /** Portable downloads are static; the baked viewer retains internal clips. */
77
+ /** Portable materials/geometry normalisation vs the baked viewer artifact. */
73
78
  purpose?: 'portable' | 'viewer'
79
+ /**
80
+ * Door/window open clips. Defaults to `keep` for the viewer bake and for GLB
81
+ * downloads (Blender turns them into actions); USDZ and print pass `none`
82
+ * because those formats freeze geometry and cannot play them.
83
+ */
84
+ animations?: 'keep' | 'none'
74
85
  /** Called for actual lossy portable conversions discovered during preparation. */
75
86
  onWarning?: (warning: string) => void
76
87
  /** Reject retained node kinds whose export geometry can only be baked asynchronously. */
77
88
  requireSynchronousBake?: boolean
89
+ /** GPU decompressor for compressed normal maps that must be baked; defaults to `textureUtils`. */
90
+ decompressTexture?: CompressedTextureDecompressor
91
+ /**
92
+ * Shared GPU decompressor for every compressed texture in the export. The
93
+ * export entry points create one per export and dispose it; supplying your
94
+ * own keeps ownership with you.
95
+ */
96
+ textureUtils?: ExportTextureUtils
97
+ /**
98
+ * Wall-clock deadline for the whole export (preparation and serialisation),
99
+ * after which it rejects instead of leaving the caller waiting. Defaults to
100
+ * `DEFAULT_MODEL_EXPORT_TIMEOUT_MS`; `Infinity` disables it.
101
+ */
102
+ timeoutMs?: number
103
+ }
104
+
105
+ export const DEFAULT_MODEL_EXPORT_TIMEOUT_MS = 180_000
106
+
107
+ /**
108
+ * three's exporters finish inside `FileReader.onloadend` and `canvas.toBlob`
109
+ * callbacks that carry no error path: a failed read or a callback the browser
110
+ * never invokes calls neither `onDone` nor `onError`, so without a deadline the
111
+ * returned promise stays pending forever and the export UI is stuck.
112
+ */
113
+ export function withExportDeadline<Result>(
114
+ promise: Promise<Result>,
115
+ timeoutMs: number,
116
+ format: string,
117
+ onTimeout?: () => void,
118
+ ): Promise<Result> {
119
+ if (!Number.isFinite(timeoutMs)) return promise
120
+ let timer: ReturnType<typeof setTimeout> | undefined
121
+ const deadline = new Promise<never>((_, reject) => {
122
+ timer = setTimeout(() => {
123
+ onTimeout?.()
124
+ reject(
125
+ new Error(
126
+ `${format} export timed out after ${Math.max(1, Math.round(timeoutMs / 1000))} s. Try again with “Visible nodes only” to shrink the scene, or reload the page if it keeps failing.`,
127
+ ),
128
+ )
129
+ }, timeoutMs)
130
+ })
131
+ return Promise.race([promise, deadline]).finally(() => clearTimeout(timer))
78
132
  }
79
133
 
80
134
  /** Resolve after the next couple of animation frames, giving React/R3F time to
@@ -144,15 +198,43 @@ export async function exportSceneToGlb(
144
198
  options: GlbExportOptions = {},
145
199
  ): Promise<ArrayBuffer> {
146
200
  const textureMode = options.textures ?? 'embed'
147
- const prepared = await preparePortableSceneFromViewer(sceneGroup, nodes, options)
148
- for (const warning of prepared.warnings) options.onWarning?.(warning)
201
+ const textureUtils = options.textureUtils ?? createExportTextureUtils()
202
+ // Preparation awaits plugin bake hooks and presentation builders, so the
203
+ // deadline has to cover it as well as the serialisation.
204
+ const preparation = preparePortableSceneFromViewer(sceneGroup, nodes, {
205
+ ...options,
206
+ textureUtils,
207
+ animations: options.animations ?? 'keep',
208
+ })
209
+ // Assigned inside the raced closure, so a plain `let` narrows to `never` in
210
+ // the `finally`.
211
+ const run: { prepared: GlbExport | null; abandoned: boolean } = {
212
+ prepared: null,
213
+ abandoned: false,
214
+ }
149
215
  try {
150
- return await serializePreparedSceneToGlb(prepared, {
151
- textures: textureMode,
152
- onlyVisible: options.onlyVisible,
153
- })
216
+ return await withExportDeadline(
217
+ (async () => {
218
+ run.prepared = await preparation
219
+ // The race has already rejected; don't serialise a file nobody will get.
220
+ if (run.abandoned) throw new Error('GLB export abandoned')
221
+ for (const warning of run.prepared.warnings) options.onWarning?.(warning)
222
+ return serializePreparedSceneToGlb(run.prepared, {
223
+ textures: textureMode,
224
+ onlyVisible: options.onlyVisible,
225
+ textureUtils,
226
+ })
227
+ })(),
228
+ options.timeoutMs ?? DEFAULT_MODEL_EXPORT_TIMEOUT_MS,
229
+ 'GLB',
230
+ () => {
231
+ run.abandoned = true
232
+ },
233
+ )
154
234
  } finally {
155
- prepared.dispose()
235
+ if (run.prepared) run.prepared.dispose()
236
+ else preparation.then((late) => late.dispose()).catch(() => {})
237
+ if (!options.textureUtils) await textureUtils.dispose()
156
238
  }
157
239
  }
158
240
  /**
@@ -179,24 +261,30 @@ export async function preparePortableSceneFromViewer(
179
261
  return completeSceneExportPreparation(preparation)
180
262
  }
181
263
 
264
+ /** Serialise a prepared scene; callers own the deadline (see `exportSceneToGlb`). */
182
265
  export function serializePreparedSceneToGlb(
183
266
  prepared: GlbExport,
184
- options: Pick<GlbExportOptions, 'textures' | 'onlyVisible'> = {},
267
+ options: Pick<GlbExportOptions, 'textures' | 'onlyVisible'> & {
268
+ textureUtils: ExportTextureUtils
269
+ },
185
270
  ): Promise<ArrayBuffer> {
186
271
  const exporter = new GLTFExporter()
187
272
  if ((options.textures ?? 'embed') === 'reference') {
188
273
  exporter.register(textureReferencePlugin)
189
274
  }
190
- exporter.setTextureUtils(WebGPUTextureUtils)
275
+ exporter.setTextureUtils(options.textureUtils)
191
276
 
192
277
  return new Promise<ArrayBuffer>((resolve, reject) => {
193
278
  exporter.parse(
194
279
  prepared.scene,
195
280
  (gltf) => {
196
- resolve(gltf as ArrayBuffer)
281
+ // A failed final FileReader hands GLTFExporter's onDone `null`, which
282
+ // `new Blob([null])` would happily turn into a four-byte download.
283
+ if (gltf instanceof ArrayBuffer) resolve(gltf)
284
+ else reject(new Error('GLB export produced no data'))
197
285
  },
198
286
  (error) => {
199
- reject(error)
287
+ reject(error instanceof Error ? error : new Error(String(error)))
200
288
  },
201
289
  {
202
290
  binary: true,
@@ -342,22 +430,47 @@ function startSceneExportPreparation(
342
430
  }
343
431
  }
344
432
 
433
+ /** Own a throwaway decompressor only when the caller supplied neither form. */
434
+ function resolveNormalMapDecompressor(options: GlbExportOptions): {
435
+ decompress: CompressedTextureDecompressor
436
+ dispose: () => Promise<void>
437
+ } {
438
+ if (options.decompressTexture) {
439
+ return { decompress: options.decompressTexture, dispose: async () => {} }
440
+ }
441
+ const utils = options.textureUtils ?? createExportTextureUtils()
442
+ return {
443
+ decompress: (texture) => utils.decompress(texture),
444
+ dispose: options.textureUtils ? async () => {} : () => utils.dispose(),
445
+ }
446
+ }
447
+
345
448
  async function completeSceneExportPreparation(
346
449
  preparation: SceneExportPreparation,
347
450
  ): Promise<GlbExport> {
451
+ const decompressor = resolveNormalMapDecompressor(preparation.options)
348
452
  try {
349
453
  await replaceBakeGeometryAsync(preparation)
350
454
  await appendSelectedPresentations(preparation)
351
455
  const prepared = finishSceneExportPreparation(preparation)
456
+ const { options } = preparation
457
+ const byReference = (options.textures ?? 'embed') === 'reference'
458
+ const normalizeOptions = {
459
+ preserveNormalMap: (texture: THREE.Texture) =>
460
+ byReference && getPascalTextureRef(texture) !== null,
461
+ }
462
+ await decompressCanonicalNormalMaps(prepared.scene, decompressor.decompress, normalizeOptions)
352
463
  prepared.warnings.push(
353
- ...(preparation.options.purpose === 'viewer'
354
- ? normalizeViewerArtifactMaterials(prepared.scene)
355
- : normalizePortableScene(prepared.scene)),
464
+ ...(options.purpose === 'viewer'
465
+ ? normalizeViewerArtifactMaterials(prepared.scene, normalizeOptions)
466
+ : normalizePortableScene(prepared.scene, normalizeOptions)),
356
467
  )
357
468
  return prepared
358
469
  } catch (error) {
359
470
  disposeExportResources(preparation.scene)
360
471
  throw error
472
+ } finally {
473
+ await decompressor.dispose()
361
474
  }
362
475
  }
363
476
 
@@ -374,10 +487,12 @@ function finishSceneExportPreparation(preparation: SceneExportPreparation): GlbE
374
487
  convertMaterials(scene, options.textures ?? 'embed', options.purpose ?? 'viewer')
375
488
 
376
489
  const retainedCloneByOriginal = retainedClones(scene, cloneByOriginal)
377
- const animation =
378
- options.purpose === 'viewer'
379
- ? bakeAnimationClips(retainedCloneByOriginal, nodes, registryEntries)
380
- : { clips: [], clipNamesByNode: new Map<string, string[]>() }
490
+ const keepClips = options.animations
491
+ ? options.animations === 'keep'
492
+ : options.purpose === 'viewer'
493
+ const animation = keepClips
494
+ ? bakeAnimationClips(retainedCloneByOriginal, nodes, registryEntries)
495
+ : { clips: [], clipNamesByNode: new Map<string, string[]>() }
381
496
  stampIdentity(scene, retainedCloneByOriginal, nodes, animation.clipNamesByNode, registryEntries)
382
497
 
383
498
  let disposed = false
@@ -495,7 +610,7 @@ function ownBorrowedPresentationTextures(root: THREE.Object3D): void {
495
610
  let ownedTexture = ownedTextures.get(sourceTexture)
496
611
  if (!ownedTexture) {
497
612
  ownedTexture = sourceTexture.clone()
498
- ownedTexture.userData = structuredClone(sourceTexture.userData)
613
+ ownedTexture.userData = cloneExportUserData(sourceTexture.userData)
499
614
  ownedTexture.needsUpdate = true
500
615
  ownedTextures.set(sourceTexture, ownedTexture)
501
616
  }
@@ -601,16 +716,6 @@ function pruneHiddenSceneNodes(
601
716
  registryEntries: readonly RegistryEntry[],
602
717
  ) {
603
718
  const visibility = new Map<string, boolean>()
604
- const declaredSiteParents = new Map<string, string>()
605
- for (const node of Object.values(nodes)) {
606
- if (node.type !== 'site' || !('children' in node) || !Array.isArray(node.children)) continue
607
- for (const childId of node.children) {
608
- const child = nodes[childId]
609
- if (child && !child.parentId && !declaredSiteParents.has(childId)) {
610
- declaredSiteParents.set(childId, node.id)
611
- }
612
- }
613
- }
614
719
 
615
720
  const isVisible = (id: string, path: Set<string>): boolean => {
616
721
  const cached = visibility.get(id)
@@ -622,8 +727,9 @@ function pruneHiddenSceneNodes(
622
727
  visibility.set(id, false)
623
728
  return false
624
729
  }
625
- const parentId = node.parentId || declaredSiteParents.get(id)
626
- if (!parentId || path.has(id)) {
730
+ const parentId = node.parentId
731
+ const parent = parentId ? nodes[parentId] : undefined
732
+ if (!parentId || path.has(id) || (parent && !hidesDescendants(parent))) {
627
733
  visibility.set(id, true)
628
734
  return true
629
735
  }
@@ -635,12 +741,33 @@ function pruneHiddenSceneNodes(
635
741
  return visible
636
742
  }
637
743
 
744
+ const nodeClones = new Set<THREE.Object3D>()
745
+ for (const [, original] of registryEntries) {
746
+ const clone = cloneByOriginal.get(original)
747
+ if (clone) nodeClones.add(clone)
748
+ }
749
+
638
750
  for (const [id, original] of registryEntries) {
639
751
  if (isVisible(id, new Set())) continue
640
- cloneByOriginal.get(original)?.removeFromParent()
752
+ const clone = cloneByOriginal.get(original)
753
+ if (!clone) continue
754
+ const node = nodes[id]
755
+ if (!node || hidesDescendants(node)) {
756
+ clone.removeFromParent()
757
+ continue
758
+ }
759
+ // Drop the hidden Site's own ground fill and boundary; keep what it hosts.
760
+ for (const child of [...clone.children]) {
761
+ if (!hostsSceneNode(child, nodeClones)) child.removeFromParent()
762
+ }
641
763
  }
642
764
  }
643
765
 
766
+ function hostsSceneNode(object: THREE.Object3D, nodeClones: Set<THREE.Object3D>): boolean {
767
+ if (nodeClones.has(object)) return true
768
+ return object.children.some((child) => hostsSceneNode(child, nodeClones))
769
+ }
770
+
644
771
  function retainedClones(
645
772
  root: THREE.Object3D,
646
773
  cloneByOriginal: Map<THREE.Object3D, THREE.Object3D>,
@@ -670,7 +797,7 @@ function cloneSceneForExport(
670
797
  if (excludedObjects.has(source)) return new THREE.Group()
671
798
 
672
799
  const clone = source.clone(false)
673
- clone.userData = structuredClone(source.userData)
800
+ clone.userData = cloneExportUserData(source.userData)
674
801
  const renderable = source as THREE.Mesh
675
802
  const renderableClone = clone as THREE.Mesh
676
803
  if (renderable.geometry) {
@@ -693,7 +820,7 @@ function cloneSceneForExport(
693
820
  let textureClone = cache.textures.get(texture)
694
821
  if (!textureClone) {
695
822
  textureClone = texture.clone()
696
- textureClone.userData = structuredClone(texture.userData)
823
+ textureClone.userData = cloneExportUserData(texture.userData)
697
824
  textureClone.needsUpdate = true
698
825
  cache.textures.set(texture, textureClone)
699
826
  }
@@ -1047,7 +1174,8 @@ function convertMaterial(
1047
1174
  if (cached) return cached
1048
1175
 
1049
1176
  const src = material as THREE.Material & Record<string, unknown>
1050
- const target = new THREE.MeshStandardMaterial()
1177
+ const glass = purpose === 'portable' && isPortableGlass(material)
1178
+ const target = glass ? new THREE.MeshPhysicalMaterial() : new THREE.MeshStandardMaterial()
1051
1179
 
1052
1180
  target.name = material.name
1053
1181
  if (src.color instanceof THREE.Color) target.color.copy(src.color)
@@ -1083,12 +1211,44 @@ function convertMaterial(
1083
1211
  }
1084
1212
  }
1085
1213
 
1214
+ if (glass) applyPortableGlass(target as THREE.MeshPhysicalMaterial, material)
1086
1215
  if (textureMode === 'reference') replaceReferencedTextures(target, placeholderCache)
1087
1216
 
1088
1217
  cache.set(material, target)
1089
1218
  return target
1090
1219
  }
1091
1220
 
1221
+ /** Mirrors the viewer's own rule (`maybeApplyGlassFresnel`): an untextured
1222
+ * see-through surface below this opacity is glass, not tinted plastic. */
1223
+ const GLASS_OPACITY_THRESHOLD = 0.6
1224
+
1225
+ function isPortableGlass(material: THREE.Material): boolean {
1226
+ const src = material as THREE.Material & Record<string, unknown>
1227
+ return (
1228
+ material.transparent &&
1229
+ material.opacity < GLASS_OPACITY_THRESHOLD &&
1230
+ !(src.map instanceof THREE.Texture)
1231
+ )
1232
+ }
1233
+
1234
+ /**
1235
+ * The viewer sells glass with a fresnel-driven opacity node, which glTF cannot
1236
+ * carry; a plain alpha blend lands in every other tool as a blue film. Real
1237
+ * transmission (KHR_materials_transmission + ior) is what Blender, Unity and
1238
+ * Unreal all render as glass. The authored opacity becomes the tint strength.
1239
+ */
1240
+ function applyPortableGlass(target: THREE.MeshPhysicalMaterial, source: THREE.Material) {
1241
+ target.color.lerp(new THREE.Color(0xffffff), 1 - source.opacity)
1242
+ target.transmission = 1
1243
+ target.ior = 1.5
1244
+ target.roughness = Math.min(target.roughness, 0.15)
1245
+ target.metalness = 0
1246
+ target.transparent = false
1247
+ target.opacity = 1
1248
+ target.depthWrite = true
1249
+ target.userData[GLASS_OPACITY_USERDATA] = source.opacity
1250
+ }
1251
+
1092
1252
  function replaceReferencedTextures(
1093
1253
  material: THREE.Material,
1094
1254
  placeholderCache: Map<THREE.Texture, THREE.Texture>,
@@ -0,0 +1,19 @@
1
+ import { expect, test } from 'bun:test'
2
+ import { Group, Vector3 } from 'three'
3
+ import { gridLocalNormal, gridLocalPoint } from './grid-frame'
4
+
5
+ test('grid floor and cursor stay in model coordinates after God-view pan, scale and yaw', () => {
6
+ const root = new Group()
7
+ root.position.set(2, 4, -3)
8
+ root.rotation.y = Math.PI / 3
9
+ root.scale.setScalar(0.2)
10
+ root.updateMatrixWorld(true)
11
+ const local = new Vector3(3, 2.5, 7)
12
+ const world = root.localToWorld(local.clone())
13
+ expect(gridLocalPoint(root, world, new Vector3()).distanceTo(local)).toBeLessThan(1e-8)
14
+ const normal = new Vector3(1, 0, 0).transformDirection(root.matrixWorld)
15
+ expect(
16
+ gridLocalNormal(root, normal, new Vector3()).distanceTo(new Vector3(1, 0, 0)),
17
+ ).toBeLessThan(1e-8)
18
+ expect(world.distanceTo(local)).toBeGreaterThan(1)
19
+ })
@@ -0,0 +1,18 @@
1
+ import type { Object3D, Vector3 } from 'three'
2
+
3
+ // Placement surfaces are world-space; the grid lives under the scalable XR
4
+ // scene root. Keep its lattice and reveal in that root's model coordinates.
5
+ export function gridLocalPoint(parent: Object3D | null, world: Vector3, target: Vector3) {
6
+ target.copy(world)
7
+ if (parent) {
8
+ parent.updateWorldMatrix(true, false)
9
+ parent.worldToLocal(target)
10
+ }
11
+ return target
12
+ }
13
+
14
+ export function gridLocalNormal(parent: Object3D | null, normal: Vector3, target: Vector3) {
15
+ target.copy(normal)
16
+ if (parent) target.transformDirection(parent.matrixWorld.clone().invert())
17
+ return target
18
+ }
@@ -19,7 +19,15 @@ export type ReshapeDriver = 'tool' | 'floorplan'
19
19
  // node, one in-flight reshape. Grouping them as sub-states of `reshaping`
20
20
  // (rather than four sibling scopes) keeps the union small while still making
21
21
  // "curving and hole-editing at once" unrepresentable.
22
- export type ReshapeKind = 'curve' | 'hole' | 'endpoint' | 'boundary' | 'control-point' | 'tangent'
22
+ export type ReshapeKind =
23
+ | 'curve'
24
+ | 'hole'
25
+ | 'endpoint'
26
+ | 'boundary'
27
+ | 'control-point'
28
+ | 'tangent'
29
+ // A kind's own reshape (wall 'split'): mounted from `def.affordanceTools[reshape]`.
30
+ | 'split'
23
31
 
24
32
  export type InteractionScope =
25
33
  | { kind: 'idle' }
@@ -0,0 +1,62 @@
1
+ import { afterAll, beforeAll, expect, test } from 'bun:test'
2
+ import {
3
+ acceptsKeyboardPan,
4
+ clearKeyboardPanKeys,
5
+ hasKeyboardPanInput,
6
+ isKeyboardPanKey,
7
+ type KeyboardPanState,
8
+ keyboardPanDirection,
9
+ keyboardPanSpeed,
10
+ setKeyboardPanKey,
11
+ } from './keyboard-pan'
12
+
13
+ const idle = (): KeyboardPanState => ({
14
+ forward: false,
15
+ backward: false,
16
+ left: false,
17
+ right: false,
18
+ })
19
+ const key = (init: Partial<KeyboardEvent>) => ({ target: null, ...init }) as KeyboardEvent
20
+
21
+ // No DOM in this runner: stand in for the element classes the editable check reads.
22
+ const DOM_CLASSES = ['HTMLElement', 'HTMLInputElement', 'HTMLTextAreaElement', 'HTMLSelectElement']
23
+ const stubbed: string[] = []
24
+ beforeAll(() => {
25
+ const scope = globalThis as Record<string, unknown>
26
+ for (const name of DOM_CLASSES) {
27
+ if (scope[name]) continue
28
+ scope[name] = class {}
29
+ stubbed.push(name)
30
+ }
31
+ })
32
+ afterAll(() => {
33
+ for (const name of stubbed) delete (globalThis as Record<string, unknown>)[name]
34
+ })
35
+
36
+ test('physical WASD keys drive a screen-space direction; letters on other layouts do not', () => {
37
+ const state = idle()
38
+ expect(isKeyboardPanKey('KeyZ')).toBe(false)
39
+ expect(setKeyboardPanKey(state, 'KeyW', true)).toBe(true)
40
+ expect(setKeyboardPanKey(state, 'KeyW', true)).toBe(false)
41
+ setKeyboardPanKey(state, 'KeyD', true)
42
+ expect(keyboardPanDirection(state)).toEqual({ horizontal: 1, vertical: 1 })
43
+ setKeyboardPanKey(state, 'KeyA', true)
44
+ expect(keyboardPanDirection(state)).toEqual({ horizontal: 0, vertical: 1 })
45
+ clearKeyboardPanKeys(state)
46
+ expect(hasKeyboardPanInput(state)).toBe(false)
47
+ })
48
+
49
+ test('modifier chords stay shortcuts, and typing in a field never pans', () => {
50
+ expect(acceptsKeyboardPan(key({}))).toBe(true)
51
+ for (const modifier of ['metaKey', 'ctrlKey', 'altKey'] as const)
52
+ expect(acceptsKeyboardPan(key({ [modifier]: true }))).toBe(false)
53
+ const Input = (globalThis as unknown as { HTMLInputElement: new () => EventTarget })
54
+ .HTMLInputElement
55
+ expect(acceptsKeyboardPan(key({ target: new Input() }))).toBe(false)
56
+ })
57
+
58
+ test('speed scales with the visible width within fixed bounds', () => {
59
+ expect(keyboardPanSpeed(0)).toBe(2)
60
+ expect(keyboardPanSpeed(20)).toBeCloseTo(13)
61
+ expect(keyboardPanSpeed(1000)).toBe(55)
62
+ })
@@ -0,0 +1,89 @@
1
+ // WASD navigation shared by the 3D camera and the 2D floor plan, so both views
2
+ // move the same way. Keys match by physical position (`event.code`): the
3
+ // cluster stays under the left hand on any layout (Z/Q/S/D on AZERTY).
4
+
5
+ export type KeyboardPanState = {
6
+ forward: boolean
7
+ backward: boolean
8
+ left: boolean
9
+ right: boolean
10
+ }
11
+
12
+ const KEYBOARD_PAN_VIEW_WIDTH_PER_SECOND = 0.65
13
+ const KEYBOARD_PAN_MIN_SPEED = 2
14
+ const KEYBOARD_PAN_MAX_SPEED = 55
15
+
16
+ export function isEditableKeyboardTarget(target: EventTarget | null) {
17
+ return (
18
+ target instanceof HTMLInputElement ||
19
+ target instanceof HTMLTextAreaElement ||
20
+ target instanceof HTMLSelectElement ||
21
+ (target instanceof HTMLElement && target.isContentEditable)
22
+ )
23
+ }
24
+
25
+ export function setKeyboardPanKey(
26
+ state: KeyboardPanState,
27
+ code: string,
28
+ pressed: boolean,
29
+ ): boolean {
30
+ if (code === 'KeyW') {
31
+ const changed = state.forward !== pressed
32
+ state.forward = pressed
33
+ return changed
34
+ }
35
+ if (code === 'KeyS') {
36
+ const changed = state.backward !== pressed
37
+ state.backward = pressed
38
+ return changed
39
+ }
40
+ if (code === 'KeyA') {
41
+ const changed = state.left !== pressed
42
+ state.left = pressed
43
+ return changed
44
+ }
45
+ if (code === 'KeyD') {
46
+ const changed = state.right !== pressed
47
+ state.right = pressed
48
+ return changed
49
+ }
50
+ return false
51
+ }
52
+
53
+ export function isKeyboardPanKey(code: string): boolean {
54
+ return code === 'KeyW' || code === 'KeyA' || code === 'KeyS' || code === 'KeyD'
55
+ }
56
+
57
+ export function hasKeyboardPanInput(state: KeyboardPanState): boolean {
58
+ return state.forward || state.backward || state.left || state.right
59
+ }
60
+
61
+ export function clearKeyboardPanKeys(state: KeyboardPanState) {
62
+ state.forward = false
63
+ state.backward = false
64
+ state.left = false
65
+ state.right = false
66
+ }
67
+
68
+ /** Pan keys are ignored with a modifier held (shortcuts) or while typing. */
69
+ export function acceptsKeyboardPan(event: KeyboardEvent) {
70
+ return (
71
+ !(event.metaKey || event.ctrlKey || event.altKey) && !isEditableKeyboardTarget(event.target)
72
+ )
73
+ }
74
+
75
+ /** Screen-space direction: `horizontal` +1 is right, `vertical` +1 is forward (up). */
76
+ export function keyboardPanDirection(state: KeyboardPanState) {
77
+ return {
78
+ horizontal: (state.right ? 1 : 0) - (state.left ? 1 : 0),
79
+ vertical: (state.forward ? 1 : 0) - (state.backward ? 1 : 0),
80
+ }
81
+ }
82
+
83
+ /** World units per second for a view `viewWidth` wide. */
84
+ export function keyboardPanSpeed(viewWidth: number) {
85
+ return Math.min(
86
+ Math.max(viewWidth * KEYBOARD_PAN_VIEW_WIDTH_PER_SECOND, KEYBOARD_PAN_MIN_SPEED),
87
+ KEYBOARD_PAN_MAX_SPEED,
88
+ )
89
+ }
@@ -5,7 +5,9 @@ import {
5
5
  BuildingNode,
6
6
  LevelNode,
7
7
  SpawnNode,
8
+ UnitNode,
8
9
  WallNode,
10
+ ZoneNode,
9
11
  } from '@pascal-app/core/schema'
10
12
  import { buildLevelDuplicateCreateOps } from './level-duplication'
11
13
 
@@ -69,3 +71,74 @@ describe('buildLevelDuplicateCreateOps', () => {
69
71
  expect(copiedLevel?.children).toEqual([])
70
72
  })
71
73
  })
74
+
75
+ describe('unit duplication', () => {
76
+ for (const preset of [
77
+ 'everything',
78
+ 'structure',
79
+ 'structure-materials',
80
+ 'structure-furniture',
81
+ ] as const) {
82
+ test(`duplicates only units wholly on the source level with ${preset}`, () => {
83
+ const building = BuildingNode.parse({})
84
+ const level = LevelNode.parse({ parentId: building.id, level: 0 })
85
+ const upper = LevelNode.parse({ parentId: building.id, level: 1 })
86
+ const kitchen = ZoneNode.parse({ parentId: level.id, name: 'Kitchen', polygon: [] })
87
+ const living = ZoneNode.parse({ parentId: level.id, name: 'Living', polygon: [] })
88
+ const bedroom = ZoneNode.parse({ parentId: upper.id, name: 'Bedroom', polygon: [] })
89
+ const single = UnitNode.parse({
90
+ parentId: building.id,
91
+ name: 'Suite',
92
+ kind: 'hotel-room',
93
+ color: '#123456',
94
+ members: [kitchen.id, living.id],
95
+ })
96
+ const duplex = UnitNode.parse({
97
+ parentId: building.id,
98
+ name: 'Duplex',
99
+ members: [living.id, bedroom.id],
100
+ })
101
+ const empty = UnitNode.parse({ parentId: building.id })
102
+ const dangling = UnitNode.parse({
103
+ parentId: building.id,
104
+ members: [kitchen.id, 'zone_missing'],
105
+ })
106
+ level.children = [kitchen.id, living.id]
107
+ upper.children = [bedroom.id]
108
+ building.children = [level.id, upper.id, single.id, duplex.id, empty.id, dangling.id]
109
+ const nodes: Record<AnyNodeId, AnyNode> = Object.fromEntries(
110
+ [building, level, upper, kitchen, living, bedroom, single, duplex, empty, dangling].map(
111
+ (node) => [node.id, node],
112
+ ),
113
+ )
114
+ const before = structuredClone(nodes)
115
+ const { createOps, newLevelId, shiftedLevels } = buildLevelDuplicateCreateOps({
116
+ nodes,
117
+ level,
118
+ levels: [level, upper],
119
+ preset,
120
+ })
121
+ const unitOps = createOps.filter((op) => op.node.type === 'unit')
122
+ expect(unitOps).toHaveLength(1)
123
+ const op = unitOps[0]!
124
+ expect(op.parentId).toBe(building.id)
125
+ expect(op.node.type).toBe('unit')
126
+ if (op.node.type !== 'unit') return
127
+ expect(op.node.id).not.toBe(single.id)
128
+ expect(op.node.name).toBe('Suite copy')
129
+ expect(op.node.kind).toBe(single.kind)
130
+ expect(op.node.color).toBe(single.color)
131
+ expect(op.node.parentId).toBe(building.id)
132
+ expect(op.node.members).toEqual(
133
+ createOps.flatMap((entry) => (entry.node.type === 'zone' ? [entry.node.id] : [])),
134
+ )
135
+ expect(
136
+ createOps
137
+ .filter((entry) => entry.node.type === 'zone')
138
+ .every((entry) => entry.parentId === newLevelId),
139
+ ).toBe(true)
140
+ expect(shiftedLevels).toEqual([{ id: upper.id, level: 2 }])
141
+ expect(nodes).toEqual(before)
142
+ })
143
+ }
144
+ })