@pascal-app/editor 1.0.0 → 1.0.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 (97) hide show
  1. package/package.json +7 -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/floating-action-menu.tsx +14 -1
  5. package/src/components/editor/floorplan-panel.tsx +23 -3
  6. package/src/components/editor/handles/handle-placement.test.ts +111 -0
  7. package/src/components/editor/handles/handle-placement.ts +69 -0
  8. package/src/components/editor/handles/linear-resize-drag.ts +13 -2
  9. package/src/components/editor/handles/use-handle-drag.ts +5 -2
  10. package/src/components/editor/index.tsx +12 -4
  11. package/src/components/editor/node-arrow-handles.tsx +21 -62
  12. package/src/components/editor/selection-manager.tsx +49 -0
  13. package/src/components/editor/thumbnail-generator.tsx +11 -2
  14. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
  15. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +46 -3
  16. package/src/components/procedural-items/inspector.tsx +223 -0
  17. package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
  18. package/src/components/tools/item/item-surface-placement.test.ts +175 -0
  19. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +210 -0
  20. package/src/components/tools/item/placement-strategies.ts +169 -169
  21. package/src/components/tools/item/placement-types.ts +4 -0
  22. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
  23. package/src/components/tools/item/use-draft-node.ts +95 -45
  24. package/src/components/tools/item/use-placement-coordinator.tsx +247 -106
  25. package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
  26. package/src/components/tools/registry/item-surface-move.test.ts +501 -0
  27. package/src/components/tools/registry/item-surface-move.ts +441 -0
  28. package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
  29. package/src/components/tools/registry/move-registry-node-tool.tsx +215 -70
  30. package/src/components/tools/shared/pointer-support-cap.ts +11 -9
  31. package/src/components/tools/shared/shelf-stickiness.ts +54 -0
  32. package/src/components/tools/shared/surface-hit.test.ts +56 -0
  33. package/src/components/tools/shared/surface-hit.ts +23 -0
  34. package/src/components/tools/shared/surface-rejection.test.tsx +106 -0
  35. package/src/components/tools/shared/surface-rejection.tsx +29 -0
  36. package/src/components/tools/zone/zone-tool.tsx +13 -4
  37. package/src/components/ui/command-palette/editor-commands.tsx +54 -1
  38. package/src/components/ui/controls/metric-control.tsx +29 -13
  39. package/src/components/ui/controls/slider-control.tsx +25 -18
  40. package/src/components/ui/floating-level-selector.tsx +81 -5
  41. package/src/components/ui/panels/multi-parametric-inspector.tsx +6 -3
  42. package/src/components/ui/panels/parametric-field-control.tsx +1 -1
  43. package/src/components/ui/panels/parametric-inspector.tsx +6 -2
  44. package/src/components/ui/panels/reference-panel.tsx +3 -3
  45. package/src/components/ui/primitives/checkbox.tsx +34 -0
  46. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +120 -3
  47. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +31 -4
  48. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +29 -10
  49. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
  50. package/src/components/ui/sidebar/panels/site-panel/index.tsx +106 -4
  51. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
  52. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
  53. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +6 -1
  54. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
  55. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
  56. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
  57. package/src/components/ui/snap-target-badge.tsx +2 -0
  58. package/src/components/viewer/viewer-scene-header.tsx +3 -0
  59. package/src/components/viewer/viewer-units-panel.tsx +116 -0
  60. package/src/hooks/use-ceiling-events.test.ts +24 -2
  61. package/src/hooks/use-ceiling-events.ts +5 -1
  62. package/src/hooks/use-keyboard.ts +3 -1
  63. package/src/index.tsx +5 -0
  64. package/src/lib/export-texture-utils.test.ts +167 -0
  65. package/src/lib/export-texture-utils.ts +121 -0
  66. package/src/lib/export-user-data.test.ts +48 -0
  67. package/src/lib/export-user-data.ts +69 -0
  68. package/src/lib/floorplan/floorplan-export.test.ts +139 -1
  69. package/src/lib/floorplan/floorplan-export.tsx +9 -3
  70. package/src/lib/floorplan/floorplan-readonly.ts +4 -0
  71. package/src/lib/fresh-planar-placement.ts +13 -5
  72. package/src/lib/glb-export-serialize.test.ts +280 -0
  73. package/src/lib/glb-export.test.ts +206 -0
  74. package/src/lib/glb-export.ts +171 -24
  75. package/src/lib/level-duplication.test.ts +73 -0
  76. package/src/lib/level-duplication.ts +32 -2
  77. package/src/lib/linear-display.test.ts +59 -0
  78. package/src/lib/linear-display.ts +39 -0
  79. package/src/lib/local-guide-image.test.ts +24 -0
  80. package/src/lib/local-guide-image.ts +34 -1
  81. package/src/lib/node-action-movement.ts +21 -0
  82. package/src/lib/portable-export.test.ts +89 -0
  83. package/src/lib/portable-export.ts +84 -11
  84. package/src/lib/print-shell-compiler-manifold-core.ts +59 -10
  85. package/src/lib/print-shell-compiler-manifold-worker.ts +14 -1
  86. package/src/lib/print-shell-compiler-manifold.worker.ts +1 -1
  87. package/src/lib/print-shell-compiler-protocol.ts +8 -0
  88. package/src/lib/scene-clipboard.ts +1 -1
  89. package/src/lib/send-to-app.test.ts +108 -0
  90. package/src/lib/send-to-app.ts +121 -0
  91. package/src/lib/sfx/movement-tick.ts +37 -0
  92. package/src/lib/surface-attachment.ts +77 -0
  93. package/src/lib/units.ts +254 -0
  94. package/src/lib/usdz-export.ts +43 -16
  95. package/src/lib/use-linear-display.ts +8 -32
  96. package/src/store/use-handle-group.ts +4 -0
  97. package/src/store/use-send-to-app.ts +21 -0
@@ -0,0 +1,116 @@
1
+ 'use client'
2
+
3
+ import {
4
+ type AnyNode,
5
+ buildUnitReport,
6
+ type LevelNode,
7
+ type UnitKind,
8
+ type UnitNode,
9
+ } from '@pascal-app/core'
10
+ import { useViewer } from '@pascal-app/viewer'
11
+ import { useMemo } from 'react'
12
+ import { getAreaUnitLabel, squareMetersToAreaUnit } from '../../lib/measurements'
13
+ import { cn } from '../../lib/utils'
14
+
15
+ const UNIT_KIND_LABELS: Record<UnitKind, string> = {
16
+ apartment: 'Apartment',
17
+ 'hotel-room': 'Hotel room',
18
+ commercial: 'Commercial',
19
+ common: 'Common',
20
+ }
21
+
22
+ type UnitRow = {
23
+ unit: UnitNode
24
+ zoneCount: number
25
+ areaM2: number
26
+ /** Levels carrying member zones, lowest ordinal first. */
27
+ levelIds: LevelNode['id'][]
28
+ }
29
+
30
+ /**
31
+ * Read-only unit list shared by the editor preview and the published viewer.
32
+ * Clicking a row focuses the unit and reveals a member floor through the shared
33
+ * `useViewer.selection`; a second click clears the focus. Hidden without units.
34
+ */
35
+ export function ViewerUnitsPanel({ nodes }: { nodes: Readonly<Record<string, AnyNode>> }) {
36
+ const focusedUnitId = useViewer((s) => s.focusedUnitId)
37
+ const areaUnit = useViewer((s) => s.unit)
38
+
39
+ const rows = useMemo<UnitRow[]>(
40
+ () =>
41
+ Object.values(nodes)
42
+ .filter((node): node is UnitNode => node.type === 'unit')
43
+ .map((unit) => {
44
+ const report = buildUnitReport(unit, nodes)
45
+ const ordinals = new Map<LevelNode['id'], number>()
46
+ for (const member of report.members) {
47
+ if (member.levelId && member.levelOrdinal !== null) {
48
+ ordinals.set(member.levelId, member.levelOrdinal)
49
+ }
50
+ }
51
+ return {
52
+ unit,
53
+ zoneCount: report.memberCount,
54
+ areaM2: report.grossAreaM2,
55
+ levelIds: [...ordinals.entries()].sort((a, b) => a[1] - b[1]).map(([id]) => id),
56
+ }
57
+ }),
58
+ [nodes],
59
+ )
60
+
61
+ if (rows.length === 0) return null
62
+
63
+ return (
64
+ <div className="corner-smooth pointer-events-auto flex w-48 flex-col overflow-hidden rounded-2xl border border-border/40 bg-background/95 py-1 shadow-elevation-4 backdrop-blur-xl">
65
+ <span className="px-3 py-2 font-medium text-[10px] text-muted-foreground uppercase tracking-wider">
66
+ Units
67
+ </span>
68
+ <div className="flex flex-col">
69
+ {rows.map((row) => {
70
+ const isFocused = row.unit.id === focusedUnitId
71
+ const area = Math.round(squareMetersToAreaUnit(row.areaM2, areaUnit))
72
+ const details = `${UNIT_KIND_LABELS[row.unit.kind]} · ${row.zoneCount} ${row.zoneCount === 1 ? 'zone' : 'zones'} · ${area} ${getAreaUnitLabel(areaUnit)}`
73
+ return (
74
+ <button
75
+ className={cn(
76
+ 'group/row relative flex w-full cursor-pointer select-none items-center border-border/50 border-r border-r-transparent border-b px-3 py-1.5 text-sm transition-all duration-200',
77
+ isFocused
78
+ ? 'border-r-3 border-r-white bg-accent/50 text-foreground'
79
+ : 'text-muted-foreground hover:bg-accent/30 hover:text-foreground',
80
+ )}
81
+ key={row.unit.id}
82
+ onClick={() => {
83
+ const viewer = useViewer.getState()
84
+ if (isFocused) {
85
+ viewer.setFocusedUnit(null)
86
+ return
87
+ }
88
+ viewer.setFocusedUnit(row.unit.id)
89
+ // Same rule as the editor: keep the current floor when it
90
+ // already carries a member zone, otherwise reveal the lowest one.
91
+ const current = viewer.selection.levelId
92
+ const levelId =
93
+ current && row.levelIds.includes(current) ? current : row.levelIds[0]
94
+ if (levelId && levelId !== current) viewer.setSelection({ levelId })
95
+ }}
96
+ type="button"
97
+ >
98
+ <div className="flex min-w-0 flex-1 items-center gap-2">
99
+ <span className="flex h-4 w-4 shrink-0 items-center justify-center">
100
+ <span
101
+ className="h-2.5 w-2.5 rounded-full"
102
+ style={{ backgroundColor: row.unit.color }}
103
+ />
104
+ </span>
105
+ <div className="min-w-0 flex-1 text-left">
106
+ <div className="truncate">{row.unit.name}</div>
107
+ <div className="truncate text-[11px] text-muted-foreground">{details}</div>
108
+ </div>
109
+ </div>
110
+ </button>
111
+ )
112
+ })}
113
+ </div>
114
+ </div>
115
+ )
116
+ }
@@ -7,6 +7,7 @@ import {
7
7
  sceneRegistry,
8
8
  useScene,
9
9
  } from '@pascal-app/core'
10
+ import { ProceduralItemNode, shelfRecipe } from '@pascal-app/core/procedural-items'
10
11
  import { hideFromScene, showInScene, useViewer } from '@pascal-app/viewer'
11
12
  import { _roots, act, createRoot } from '@react-three/fiber'
12
13
  import { createElement } from 'react'
@@ -18,11 +19,16 @@ import {
18
19
  type WebGLRenderer,
19
20
  } from 'three'
20
21
  import useEditor from '../store/use-editor'
22
+ import useInteractionScope from '../store/use-interaction-scope'
21
23
  import { useCeilingEvents } from './use-ceiling-events'
22
24
 
23
- test('ceiling-item placement keeps move and commit hits while an unhovered ceiling is batched', async () => {
25
+ test.each([
26
+ 'item',
27
+ 'procedural-item',
28
+ ])('%s ceiling placement keeps move and commit hits while an unhovered ceiling is batched', async (kind) => {
24
29
  const previousViewer = useViewer.getState()
25
30
  const previousEditor = useEditor.getState()
31
+ const previousInteraction = useInteractionScope.getState()
26
32
  const previousScene = useScene.getState()
27
33
  const actGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
28
34
  const previousAct = actGlobal.IS_REACT_ACT_ENVIRONMENT
@@ -73,7 +79,22 @@ test('ceiling-item placement keeps move and commit hits while an unhovered ceili
73
79
  cameraDragging: false,
74
80
  selection: { buildingId: null, levelId: level.id, zoneId: null, selectedIds: [] },
75
81
  })
76
- useEditor.setState({ selectedItem: { attachTo: 'ceiling' } as never })
82
+ if (kind === 'item') useEditor.setState({ selectedItem: { attachTo: 'ceiling' } as never })
83
+ else {
84
+ useEditor.setState({ selectedItem: null })
85
+ useEditor.getState().setMovingNode(
86
+ ProceduralItemNode.parse({
87
+ recipe: {
88
+ ...shelfRecipe,
89
+ mounting: { attachTo: 'ceiling', reference: 'top' },
90
+ surfaces: [
91
+ { id: 'top', label: 'Top', position: [0, 'height', 0], size: ['width', 'depth'] },
92
+ ],
93
+ },
94
+ parentId: ceiling.id,
95
+ }) as never,
96
+ )
97
+ }
77
98
  await root.configure({
78
99
  gl: {
79
100
  domElement: canvas,
@@ -116,6 +137,7 @@ test('ceiling-item placement keeps move and commit hits while an unhovered ceili
116
137
  surface.material.dispose()
117
138
  useScene.setState(previousScene)
118
139
  useEditor.setState(previousEditor)
140
+ useInteractionScope.setState(previousInteraction)
119
141
  useViewer.setState(previousViewer)
120
142
  actGlobal.IS_REACT_ACT_ENVIRONMENT = previousAct
121
143
  }
@@ -7,6 +7,7 @@ import {
7
7
  sceneRegistry,
8
8
  useScene,
9
9
  } from '@pascal-app/core'
10
+ import { isProceduralItem } from '@pascal-app/core/procedural-items'
10
11
  import { useViewer } from '@pascal-app/viewer'
11
12
  import { useThree } from '@react-three/fiber'
12
13
  import { useEffect, useRef } from 'react'
@@ -61,7 +62,10 @@ export function useCeilingEvents() {
61
62
  const ed = useEditor.getState()
62
63
  if (ed.selectedItem?.attachTo === 'ceiling') return true
63
64
  const moving = getMovingNode()
64
- return moving?.type === 'item' && moving.asset?.attachTo === 'ceiling'
65
+ return (
66
+ (moving?.type === 'item' && moving.asset?.attachTo === 'ceiling') ||
67
+ (isProceduralItem(moving) && moving.recipe.mounting?.attachTo === 'ceiling')
68
+ )
65
69
  }
66
70
 
67
71
  type Hit = { node: CeilingNode; mesh: Object3D; world: Vector3; local: Vector3 }
@@ -32,6 +32,7 @@ import { isActive } from '../lib/interaction/scope'
32
32
  import { copySelectedNodesToEditorClipboard } from '../lib/scene-clipboard'
33
33
  import { sfxEmitter } from '../lib/sfx-bus'
34
34
  import { activeSiteNode, clampBrushRadius } from '../lib/terrain-sculpt'
35
+ import { leaveUnitFocus } from '../lib/units'
35
36
  import { toggleWindowOpenState } from '../lib/window-interaction'
36
37
  import useDeleteConfirmation from '../store/use-delete-confirmation'
37
38
  import useEditor, { getActiveContinuationContext, getActiveSnapContext } from '../store/use-editor'
@@ -390,7 +391,8 @@ export const useKeyboard = ({
390
391
  // Only switch to select mode if no tool had an active mid-action to cancel.
391
392
  // (e.g. mid-wall draw or mid-slab polygon should only cancel the action, not exit the tool)
392
393
  if (!_toolCancelConsumed) {
393
- exitToSelectAfterUnconsumedCancel()
394
+ if (leaveUnitFocus()) useEditor.getState().armToolMode({ mode: 'select' })
395
+ else exitToSelectAfterUnconsumedCancel()
394
396
  }
395
397
  } else if (e.key === '1' && !e.metaKey && !e.ctrlKey) {
396
398
  e.preventDefault()
package/src/index.tsx CHANGED
@@ -137,6 +137,7 @@ export {
137
137
  type PlacementCoordinatorConfig,
138
138
  usePlacementCoordinator,
139
139
  } from './components/tools/item/use-placement-coordinator'
140
+ export { MoveRegistryNodeTool } from './components/tools/registry/move-registry-node-tool'
140
141
  export { useRegistryToolContext } from './components/tools/registry-tool-context'
141
142
  export { CursorSphere } from './components/tools/shared/cursor-sphere'
142
143
  export { DragBoundingBox } from './components/tools/shared/drag-bounding-box'
@@ -317,6 +318,7 @@ export {
317
318
  ViewerStageSwitcher,
318
319
  type ViewerStageSwitcherProps,
319
320
  } from './components/viewer/viewer-stage-switcher'
321
+ export { ViewerUnitsPanel } from './components/viewer/viewer-units-panel'
320
322
  export {
321
323
  WalkthroughHud,
322
324
  type WalkthroughHudProps,
@@ -378,6 +380,7 @@ export {
378
380
  resolveElevatorSupportLevelId,
379
381
  resolveElevatorSupportY,
380
382
  } from './lib/elevator-support'
383
+ export type { ExportTextureUtils } from './lib/export-texture-utils'
381
384
  export { getFloatingMenuScale } from './lib/floating-menu-scale'
382
385
  // Floor-plan stair helpers — the cumulative-transform walk
383
386
  // (`computeFloorplanStairSegmentTransforms`) and the rich segment-entry
@@ -544,6 +547,8 @@ export {
544
547
  editorHostPanelRegistry,
545
548
  registerEditorHostPanel,
546
549
  } from './lib/plugin-panels'
550
+ export { configureManifoldRuntime } from './lib/print-shell-compiler-manifold-worker'
551
+ export type { ManifoldRuntimeOptions } from './lib/print-shell-compiler-protocol'
547
552
  export {
548
553
  createQuickMeasurementPointerScheduler,
549
554
  quickMeasurementContext,
@@ -0,0 +1,167 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test'
2
+ import * as THREE from 'three'
3
+ import type { WebGPURenderer } from 'three/webgpu'
4
+ import { createExportTextureUtils } from './export-texture-utils'
5
+
6
+ const globals = globalThis as unknown as { document?: unknown }
7
+ const previousDocument = globals.document
8
+
9
+ type FakeRenderer = {
10
+ renderer: WebGPURenderer
11
+ calls: { render: number; dispose: number; destroy: number; sizes: [number, number][] }
12
+ domElement: object
13
+ }
14
+
15
+ function fakeRenderer(): FakeRenderer {
16
+ const calls = { render: 0, dispose: 0, destroy: 0, sizes: [] as [number, number][] }
17
+ const domElement = { kind: 'renderer-canvas' }
18
+ const renderer = {
19
+ domElement,
20
+ outputColorSpace: THREE.SRGBColorSpace,
21
+ backend: {
22
+ device: {
23
+ destroy: () => {
24
+ calls.destroy += 1
25
+ },
26
+ },
27
+ },
28
+ setSize: (width: number, height: number) => {
29
+ calls.sizes.push([width, height])
30
+ },
31
+ render: () => {
32
+ calls.render += 1
33
+ },
34
+ dispose: async () => {
35
+ calls.dispose += 1
36
+ },
37
+ }
38
+ return { renderer: renderer as unknown as WebGPURenderer, calls, domElement }
39
+ }
40
+
41
+ function installCanvasDocument(drawn: object[]) {
42
+ globals.document = {
43
+ createElement: (tagName: string) => {
44
+ if (tagName !== 'canvas') throw new Error(`Unexpected element request: ${tagName}`)
45
+ return {
46
+ width: 0,
47
+ height: 0,
48
+ getContext: () => ({
49
+ drawImage: (source: object) => {
50
+ drawn.push(source)
51
+ },
52
+ }),
53
+ }
54
+ },
55
+ }
56
+ }
57
+
58
+ function compressedTexture(name: string, width = 8, height = 4): THREE.CompressedTexture {
59
+ const texture = new THREE.CompressedTexture([], width, height)
60
+ texture.name = name
61
+ texture.colorSpace = THREE.NoColorSpace
62
+ texture.wrapS = THREE.RepeatWrapping
63
+ texture.wrapT = THREE.MirroredRepeatWrapping
64
+ texture.minFilter = THREE.NearestFilter
65
+ texture.magFilter = THREE.NearestFilter
66
+ return texture
67
+ }
68
+
69
+ afterEach(() => {
70
+ globals.document = previousDocument
71
+ })
72
+
73
+ describe('createExportTextureUtils', () => {
74
+ test('starts one renderer for every compressed texture in the export and releases it once', async () => {
75
+ const drawn: object[] = []
76
+ installCanvasDocument(drawn)
77
+ const fake = fakeRenderer()
78
+ let starts = 0
79
+ const utils = createExportTextureUtils(async () => {
80
+ starts += 1
81
+ return fake.renderer
82
+ })
83
+
84
+ const readable = await Promise.all([
85
+ utils.decompress(compressedTexture('NormalGL_a')),
86
+ utils.decompress(compressedTexture('Color_b', 16, 16)),
87
+ utils.decompress(compressedTexture('Rough_c', 2, 8)),
88
+ ])
89
+
90
+ expect(starts).toBe(1)
91
+ expect(fake.calls.render).toBe(3)
92
+ expect(fake.calls.sizes).toEqual([
93
+ [8, 4],
94
+ [16, 16],
95
+ [2, 8],
96
+ ])
97
+ expect(drawn).toEqual([fake.domElement, fake.domElement, fake.domElement])
98
+ expect(fake.renderer.outputColorSpace).toBe(THREE.SRGBColorSpace)
99
+
100
+ const first = readable[0] as THREE.CanvasTexture
101
+ expect(first.isCanvasTexture).toBe(true)
102
+ expect(first.name).toBe('NormalGL_a')
103
+ expect(first.colorSpace).toBe(THREE.NoColorSpace)
104
+ expect(first.wrapS).toBe(THREE.RepeatWrapping)
105
+ expect(first.wrapT).toBe(THREE.MirroredRepeatWrapping)
106
+ expect(first.minFilter).toBe(THREE.NearestFilter)
107
+ expect(first.magFilter).toBe(THREE.NearestFilter)
108
+
109
+ await utils.dispose()
110
+ await utils.dispose()
111
+ expect(fake.calls.dispose).toBe(1)
112
+ expect(fake.calls.destroy).toBe(1)
113
+ })
114
+
115
+ test('clamps the readback to maxTextureSize', async () => {
116
+ installCanvasDocument([])
117
+ const fake = fakeRenderer()
118
+ const utils = createExportTextureUtils(async () => fake.renderer)
119
+
120
+ const readable = await utils.decompress(compressedTexture('Color', 4096, 1024), 2048)
121
+
122
+ expect(fake.calls.sizes).toEqual([[2048, 1024]])
123
+ expect((readable.image as { width: number; height: number }).width).toBe(2048)
124
+ await utils.dispose()
125
+ })
126
+
127
+ test('rejects when no GPU renderer can start instead of leaving the export pending', async () => {
128
+ installCanvasDocument([])
129
+ const utils = createExportTextureUtils(async () => {
130
+ throw new Error('no adapter')
131
+ })
132
+
133
+ await expect(utils.decompress(compressedTexture('Color'))).rejects.toThrow('no adapter')
134
+ await expect(utils.decompress(compressedTexture('Color'))).rejects.toThrow('no adapter')
135
+ await expect(utils.dispose()).resolves.toBeUndefined()
136
+ })
137
+
138
+ test('disposing without decompressing never starts a renderer', async () => {
139
+ let starts = 0
140
+ const utils = createExportTextureUtils(async () => {
141
+ starts += 1
142
+ return fakeRenderer().renderer
143
+ })
144
+
145
+ await utils.dispose()
146
+ expect(starts).toBe(0)
147
+ })
148
+
149
+ test('a decompress after dispose rejects instead of starting an unowned renderer', async () => {
150
+ installCanvasDocument([])
151
+ const fake = fakeRenderer()
152
+ let starts = 0
153
+ const utils = createExportTextureUtils(async () => {
154
+ starts += 1
155
+ return fake.renderer
156
+ })
157
+
158
+ await utils.decompress(compressedTexture('Color'))
159
+ await utils.dispose()
160
+ await expect(utils.decompress(compressedTexture('Color'))).rejects.toThrow('abandoned')
161
+ await utils.dispose()
162
+
163
+ expect(starts).toBe(1)
164
+ expect(fake.calls.dispose).toBe(1)
165
+ expect(fake.calls.destroy).toBe(1)
166
+ })
167
+ })
@@ -0,0 +1,121 @@
1
+ import { initializeGpuRenderer } from '@pascal-app/viewer'
2
+ import type * as THREE from 'three'
3
+ import { texture as textureNode, uv } from 'three/tsl'
4
+ import { CanvasTexture, NodeMaterial, QuadMesh, WebGPURenderer } from 'three/webgpu'
5
+
6
+ /**
7
+ * GPU decompressor handed to three's GLTF/USDZ exporters for compressed (KTX2)
8
+ * textures. One instance lives for the duration of an export and is disposed
9
+ * with it.
10
+ */
11
+ export type ExportTextureUtils = {
12
+ decompress(texture: THREE.Texture, maxTextureSize?: number): Promise<THREE.Texture>
13
+ dispose(): Promise<void>
14
+ }
15
+
16
+ // The WebGL probe context initializeGpuRenderer opens on this canvas is never
17
+ // released, so one canvas serves every export instead of one per export.
18
+ let probeCanvas: HTMLCanvasElement | null | undefined
19
+
20
+ async function initializeDecompressRenderer(): Promise<WebGPURenderer> {
21
+ probeCanvas ??= typeof document === 'undefined' ? null : document.createElement('canvas')
22
+ const result = await initializeGpuRenderer({
23
+ probeCanvas,
24
+ createRenderer: (parameters) =>
25
+ new WebGPURenderer(parameters as ConstructorParameters<typeof WebGPURenderer>[0]),
26
+ })
27
+ if (result.status !== 'ready') {
28
+ throw new Error('Could not start a GPU renderer to decompress textures for export.', {
29
+ cause: result.error,
30
+ })
31
+ }
32
+ return result.renderer
33
+ }
34
+
35
+ function releaseDevice(renderer: WebGPURenderer) {
36
+ // initializeGpuRenderer hands three a caller-owned device, which
37
+ // WebGPUBackend.dispose() deliberately leaves alive.
38
+ const backend = renderer.backend as { device?: { destroy?: () => void } | null }
39
+ try {
40
+ backend.device?.destroy?.()
41
+ } catch {}
42
+ }
43
+
44
+ /**
45
+ * Mirrors three's `WebGPUTextureUtils.decompress` but keeps a single renderer
46
+ * for the whole export. The stock helper creates, initialises and disposes a
47
+ * WebGPURenderer — adapter and device request included, with no timeout — for
48
+ * every compressed texture it is handed, so a furnished scene paid that cost
49
+ * dozens of times and hung outright when one device request stalled. Handing
50
+ * it a renderer is not an option: it resizes whatever renderer it receives
51
+ * (the live viewer's canvas) and, in r186, throws on the first call that
52
+ * supplies one.
53
+ */
54
+ export function createExportTextureUtils(
55
+ initialize: () => Promise<WebGPURenderer> = initializeDecompressRenderer,
56
+ ): ExportTextureUtils {
57
+ let pending: Promise<WebGPURenderer> | null = null
58
+ let disposed = false
59
+ const quad = new QuadMesh()
60
+
61
+ return {
62
+ async decompress(texture, maxTextureSize = Number.POSITIVE_INFINITY) {
63
+ if (disposed) {
64
+ throw new Error('The export was abandoned before this texture was decompressed.')
65
+ }
66
+ pending ??= initialize()
67
+ const renderer = await pending
68
+
69
+ // Everything below the await is synchronous, which is what lets
70
+ // overlapping calls share `quad` and the renderer's colour space.
71
+ const image = texture.image as { width: number; height: number }
72
+ const width = Math.min(image.width, maxTextureSize)
73
+ const height = Math.min(image.height, maxTextureSize)
74
+
75
+ const material = new NodeMaterial()
76
+ material.fragmentNode = textureNode(texture, uv().flipY())
77
+ const previousColorSpace = renderer.outputColorSpace
78
+ renderer.setSize(width, height)
79
+ renderer.outputColorSpace = texture.colorSpace
80
+ quad.material = material
81
+ try {
82
+ quad.render(renderer)
83
+ } finally {
84
+ renderer.outputColorSpace = previousColorSpace
85
+ material.dispose()
86
+ }
87
+
88
+ const canvas = document.createElement('canvas')
89
+ canvas.width = width
90
+ canvas.height = height
91
+ const context = canvas.getContext('2d')
92
+ if (!context) {
93
+ throw new Error('Could not create a 2D canvas to read back a decompressed texture.')
94
+ }
95
+ context.drawImage(renderer.domElement, 0, 0, width, height)
96
+
97
+ const readable = new CanvasTexture(canvas)
98
+ readable.minFilter = texture.minFilter
99
+ readable.magFilter = texture.magFilter
100
+ readable.wrapS = texture.wrapS
101
+ readable.wrapT = texture.wrapT
102
+ readable.colorSpace = texture.colorSpace
103
+ readable.name = texture.name
104
+ return readable
105
+ },
106
+
107
+ async dispose() {
108
+ disposed = true
109
+ const started = pending
110
+ pending = null
111
+ if (!started) return
112
+ const renderer = await started.catch(() => null)
113
+ if (!renderer) return
114
+ try {
115
+ await renderer.dispose()
116
+ } finally {
117
+ releaseDevice(renderer)
118
+ }
119
+ },
120
+ }
121
+ }
@@ -0,0 +1,48 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import * as THREE from 'three'
3
+ import { uniform } from 'three/tsl'
4
+ import { cloneExportUserData } from './export-user-data'
5
+
6
+ describe('cloneExportUserData', () => {
7
+ test('keeps plain data and deep-copies it', () => {
8
+ const source = {
9
+ pascalId: 'wall-1',
10
+ pascalSwingLeaf: { axis: 'y', openRotationY: 1.2 },
11
+ tags: ['a', 'b'],
12
+ offsets: new Float32Array([1, 2, 3]),
13
+ }
14
+ const copy = cloneExportUserData(source)
15
+ expect(copy).toEqual(source)
16
+ expect(copy.pascalSwingLeaf).not.toBe(source.pascalSwingLeaf)
17
+ expect(copy.tags).not.toBe(source.tags)
18
+ expect(copy.offsets).not.toBe(source.offsets)
19
+ })
20
+
21
+ test('drops functions and live three resources instead of throwing', () => {
22
+ const material = new THREE.MeshStandardMaterial()
23
+ material.addEventListener('dispose', () => {})
24
+ const source = {
25
+ pascalId: 'pool-1',
26
+ waterEffect: { material, uniforms: { time: uniform(0) }, strength: 0.5 },
27
+ mesh: new THREE.Mesh(new THREE.BoxGeometry(), material),
28
+ geometry: new THREE.BufferGeometry(),
29
+ texture: new THREE.Texture(),
30
+ onDispose: () => {},
31
+ list: [material, 'kept'],
32
+ }
33
+ expect(() => structuredClone(source)).toThrow()
34
+ expect(cloneExportUserData(source)).toEqual({
35
+ pascalId: 'pool-1',
36
+ waterEffect: { uniforms: {}, strength: 0.5 },
37
+ list: [undefined, 'kept'],
38
+ })
39
+ })
40
+
41
+ test('tolerates cycles', () => {
42
+ const source: Record<string, unknown> = { pascalId: 'x' }
43
+ source.self = source
44
+ const copy = cloneExportUserData(source)
45
+ expect(copy.pascalId).toBe('x')
46
+ expect(copy.self).toBe(copy)
47
+ })
48
+ })
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Copy `userData` for an export clone. `structuredClone` throws the moment it
3
+ * meets a function, and live scenes keep runtime resources in userData (a
4
+ * plugin's effect material, TSL uniforms, a mesh reference) whose dispose
5
+ * listeners are functions — so a plain `structuredClone(userData)` aborts the
6
+ * whole export. Exports only ever read plain data back out of userData, so
7
+ * runtime resources and functions are dropped instead of cloned.
8
+ */
9
+
10
+ type RuntimeResource = {
11
+ isObject3D?: boolean
12
+ isMaterial?: boolean
13
+ isTexture?: boolean
14
+ isBufferGeometry?: boolean
15
+ isNode?: boolean
16
+ }
17
+
18
+ function isRuntimeResource(value: object): boolean {
19
+ const flags = value as RuntimeResource
20
+ return Boolean(
21
+ flags.isObject3D ||
22
+ flags.isMaterial ||
23
+ flags.isTexture ||
24
+ flags.isBufferGeometry ||
25
+ flags.isNode,
26
+ )
27
+ }
28
+
29
+ function cloneValue(value: unknown, seen: Map<object, unknown>): unknown {
30
+ if (typeof value === 'function') return undefined
31
+ if (value === null || typeof value !== 'object') return value
32
+ if (seen.has(value)) return seen.get(value)
33
+ if (isRuntimeResource(value)) return undefined
34
+ if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) {
35
+ const copy = structuredClone(value)
36
+ seen.set(value, copy)
37
+ return copy
38
+ }
39
+ if (value instanceof Date || value instanceof RegExp) return structuredClone(value)
40
+ if (Array.isArray(value)) {
41
+ const copy: unknown[] = []
42
+ seen.set(value, copy)
43
+ for (const entry of value) copy.push(cloneValue(entry, seen))
44
+ return copy
45
+ }
46
+ if (value instanceof Map) {
47
+ const copy = new Map<unknown, unknown>()
48
+ seen.set(value, copy)
49
+ for (const [key, entry] of value) copy.set(cloneValue(key, seen), cloneValue(entry, seen))
50
+ return copy
51
+ }
52
+ if (value instanceof Set) {
53
+ const copy = new Set<unknown>()
54
+ seen.set(value, copy)
55
+ for (const entry of value) copy.add(cloneValue(entry, seen))
56
+ return copy
57
+ }
58
+ const copy: Record<string, unknown> = {}
59
+ seen.set(value, copy)
60
+ for (const [key, entry] of Object.entries(value)) {
61
+ const cloned = cloneValue(entry, seen)
62
+ if (cloned !== undefined) copy[key] = cloned
63
+ }
64
+ return copy
65
+ }
66
+
67
+ export function cloneExportUserData(userData: Record<string, unknown>): Record<string, unknown> {
68
+ return cloneValue(userData, new Map()) as Record<string, unknown>
69
+ }