@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.
- package/package.json +7 -6
- package/src/components/editor/bake-exporter.tsx +4 -0
- package/src/components/editor/bake-thumbnail.tsx +4 -1
- package/src/components/editor/floating-action-menu.tsx +14 -1
- package/src/components/editor/floorplan-panel.tsx +23 -3
- package/src/components/editor/handles/handle-placement.test.ts +111 -0
- package/src/components/editor/handles/handle-placement.ts +69 -0
- package/src/components/editor/handles/linear-resize-drag.ts +13 -2
- package/src/components/editor/handles/use-handle-drag.ts +5 -2
- package/src/components/editor/index.tsx +12 -4
- package/src/components/editor/node-arrow-handles.tsx +21 -62
- package/src/components/editor/selection-manager.tsx +49 -0
- package/src/components/editor/thumbnail-generator.tsx +11 -2
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +46 -3
- package/src/components/procedural-items/inspector.tsx +223 -0
- package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
- package/src/components/tools/item/item-surface-placement.test.ts +175 -0
- package/src/components/tools/item/item-surface-strategy-resolver.test.ts +210 -0
- package/src/components/tools/item/placement-strategies.ts +169 -169
- package/src/components/tools/item/placement-types.ts +4 -0
- package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
- package/src/components/tools/item/use-draft-node.ts +95 -45
- package/src/components/tools/item/use-placement-coordinator.tsx +247 -106
- package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
- package/src/components/tools/registry/item-surface-move.test.ts +501 -0
- package/src/components/tools/registry/item-surface-move.ts +441 -0
- package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
- package/src/components/tools/registry/move-registry-node-tool.tsx +215 -70
- package/src/components/tools/shared/pointer-support-cap.ts +11 -9
- package/src/components/tools/shared/shelf-stickiness.ts +54 -0
- package/src/components/tools/shared/surface-hit.test.ts +56 -0
- package/src/components/tools/shared/surface-hit.ts +23 -0
- package/src/components/tools/shared/surface-rejection.test.tsx +106 -0
- package/src/components/tools/shared/surface-rejection.tsx +29 -0
- package/src/components/tools/zone/zone-tool.tsx +13 -4
- package/src/components/ui/command-palette/editor-commands.tsx +54 -1
- package/src/components/ui/controls/metric-control.tsx +29 -13
- package/src/components/ui/controls/slider-control.tsx +25 -18
- package/src/components/ui/floating-level-selector.tsx +81 -5
- package/src/components/ui/panels/multi-parametric-inspector.tsx +6 -3
- package/src/components/ui/panels/parametric-field-control.tsx +1 -1
- package/src/components/ui/panels/parametric-inspector.tsx +6 -2
- package/src/components/ui/panels/reference-panel.tsx +3 -3
- package/src/components/ui/primitives/checkbox.tsx +34 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +120 -3
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +31 -4
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +29 -10
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +106 -4
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +6 -1
- package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
- package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
- package/src/components/ui/snap-target-badge.tsx +2 -0
- package/src/components/viewer/viewer-scene-header.tsx +3 -0
- package/src/components/viewer/viewer-units-panel.tsx +116 -0
- package/src/hooks/use-ceiling-events.test.ts +24 -2
- package/src/hooks/use-ceiling-events.ts +5 -1
- package/src/hooks/use-keyboard.ts +3 -1
- package/src/index.tsx +5 -0
- package/src/lib/export-texture-utils.test.ts +167 -0
- package/src/lib/export-texture-utils.ts +121 -0
- package/src/lib/export-user-data.test.ts +48 -0
- package/src/lib/export-user-data.ts +69 -0
- package/src/lib/floorplan/floorplan-export.test.ts +139 -1
- package/src/lib/floorplan/floorplan-export.tsx +9 -3
- package/src/lib/floorplan/floorplan-readonly.ts +4 -0
- package/src/lib/fresh-planar-placement.ts +13 -5
- package/src/lib/glb-export-serialize.test.ts +280 -0
- package/src/lib/glb-export.test.ts +206 -0
- package/src/lib/glb-export.ts +171 -24
- package/src/lib/level-duplication.test.ts +73 -0
- package/src/lib/level-duplication.ts +32 -2
- package/src/lib/linear-display.test.ts +59 -0
- package/src/lib/linear-display.ts +39 -0
- package/src/lib/local-guide-image.test.ts +24 -0
- package/src/lib/local-guide-image.ts +34 -1
- package/src/lib/node-action-movement.ts +21 -0
- package/src/lib/portable-export.test.ts +89 -0
- package/src/lib/portable-export.ts +84 -11
- package/src/lib/print-shell-compiler-manifold-core.ts +59 -10
- package/src/lib/print-shell-compiler-manifold-worker.ts +14 -1
- package/src/lib/print-shell-compiler-manifold.worker.ts +1 -1
- package/src/lib/print-shell-compiler-protocol.ts +8 -0
- package/src/lib/scene-clipboard.ts +1 -1
- package/src/lib/send-to-app.test.ts +108 -0
- package/src/lib/send-to-app.ts +121 -0
- package/src/lib/sfx/movement-tick.ts +37 -0
- package/src/lib/surface-attachment.ts +77 -0
- package/src/lib/units.ts +254 -0
- package/src/lib/usdz-export.ts +43 -16
- package/src/lib/use-linear-display.ts +8 -32
- package/src/store/use-handle-group.ts +4 -0
- 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(
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|