@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.
- package/package.json +12 -6
- package/src/components/editor/bake-exporter.tsx +4 -0
- package/src/components/editor/bake-thumbnail.tsx +4 -1
- package/src/components/editor/custom-camera-controls.tsx +22 -74
- package/src/components/editor/editor-layout-mobile.tsx +1 -0
- package/src/components/editor/floating-action-menu.tsx +14 -1
- package/src/components/editor/floorplan-panel.tsx +233 -166
- package/src/components/editor/grid.tsx +31 -7
- package/src/components/editor/group-actions.ts +5 -9
- package/src/components/editor/group-floating-action-menu.tsx +19 -9
- package/src/components/editor/group-move-3d.ts +4 -6
- package/src/components/editor/group-rotate-handle.tsx +81 -35
- package/src/components/editor/group-selection-box-3d.tsx +25 -17
- package/src/components/editor/group-transform-shared.test.ts +126 -1
- package/src/components/editor/group-transform-shared.ts +79 -73
- package/src/components/editor/handles/handle-arrow.tsx +72 -2
- 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 +80 -8
- package/src/components/editor/index.tsx +36 -13
- package/src/components/editor/node-action-menu.tsx +2 -0
- package/src/components/editor/node-arrow-handles.tsx +162 -69
- package/src/components/editor/registry-action-contributions.tsx +53 -0
- package/src/components/editor/selection-manager.tsx +49 -0
- package/src/components/editor/thumbnail-generator.tsx +11 -2
- package/src/components/editor/wall-move-side-handles.tsx +71 -21
- package/src/components/editor/wall-opening-highlights.tsx +2 -3
- package/src/components/editor-2d/floorplan-group-move.tsx +17 -24
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +33 -12
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +154 -49
- package/src/components/editor-2d/renderers/floorplan-draft-wall-measurement.tsx +166 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +49 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +113 -24
- package/src/components/procedural-items/inspector.tsx +223 -0
- package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
- package/src/components/systems/zone/zone-system.tsx +8 -4
- package/src/components/tools/__fixtures__/hosting.ts +10 -0
- package/src/components/tools/item/item-surface-placement.test.ts +169 -0
- package/src/components/tools/item/item-surface-strategy-resolver.test.ts +204 -0
- package/src/components/tools/item/placement-strategies.ts +212 -200
- 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 +136 -47
- package/src/components/tools/item/use-placement-coordinator.tsx +391 -157
- package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
- package/src/components/tools/registry/item-surface-move.test.ts +509 -0
- package/src/components/tools/registry/item-surface-move.ts +463 -0
- package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
- package/src/components/tools/registry/move-registry-node-tool.tsx +237 -78
- package/src/components/tools/shared/affordance-dispatch.test.ts +24 -0
- package/src/components/tools/shared/affordance-dispatch.ts +13 -0
- package/src/components/tools/shared/draft-measurement-label.tsx +38 -0
- package/src/components/tools/shared/pointer-support-cap.test.ts +27 -0
- package/src/components/tools/shared/pointer-support-cap.ts +27 -13
- 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 +107 -0
- package/src/components/tools/shared/surface-rejection.tsx +43 -0
- package/src/components/tools/tool-manager.tsx +36 -3
- 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/material-paint-panel.tsx +6 -67
- package/src/components/ui/controls/material-picker.tsx +5 -67
- package/src/components/ui/controls/metric-control.tsx +29 -13
- package/src/components/ui/controls/slider-control.tsx +87 -35
- package/src/components/ui/floating-level-selector.tsx +81 -5
- package/src/components/ui/helpers/helper-manager.tsx +24 -6
- package/src/components/ui/helpers/registered-tool-helper.tsx +2 -32
- package/src/components/ui/item-catalog/item-catalog.tsx +5 -30
- package/src/components/ui/panels/multi-height-mode.tsx +27 -21
- 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 +9 -17
- package/src/components/ui/panels/reference-panel.tsx +3 -3
- package/src/components/ui/primitives/checkbox.tsx +34 -0
- package/src/components/ui/primitives/shortcut-token.tsx +4 -0
- package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +669 -336
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
- 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 +30 -11
- 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/floorplan-preview-visibility.test.ts +35 -0
- package/src/components/viewer/floorplan-preview-visibility.ts +19 -0
- package/src/components/viewer/floorplan-preview.tsx +1 -12
- package/src/components/viewer/viewer-scene-header.tsx +3 -0
- package/src/components/viewer/viewer-units-panel.tsx +116 -0
- package/src/components/viewer-zone-system.tsx +7 -2
- package/src/hooks/use-auto-save.integration.test.tsx +197 -0
- package/src/hooks/use-auto-save.ts +21 -6
- package/src/hooks/use-ceiling-events.test.ts +25 -2
- package/src/hooks/use-ceiling-events.ts +7 -1
- package/src/hooks/use-grid-events.test.ts +2 -0
- package/src/hooks/use-grid-events.ts +16 -11
- package/src/hooks/use-keyboard.test.ts +7 -0
- package/src/hooks/use-keyboard.ts +43 -28
- package/src/index.tsx +68 -3
- package/src/lib/catalog-panel-model.ts +39 -0
- package/src/lib/catalog-selection.test.ts +27 -0
- package/src/lib/catalog-selection.ts +8 -0
- package/src/lib/contextual-help.test.ts +27 -1
- package/src/lib/contextual-help.ts +16 -9
- 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 +142 -2
- package/src/lib/floorplan/floorplan-export.tsx +9 -3
- package/src/lib/floorplan/floorplan-extension.ts +16 -0
- package/src/lib/floorplan/floorplan-readonly.ts +4 -0
- package/src/lib/fresh-planar-placement.ts +173 -33
- package/src/lib/glb-export-serialize.test.ts +280 -0
- package/src/lib/glb-export.test.ts +265 -16
- package/src/lib/glb-export.ts +197 -37
- package/src/lib/grid-frame.test.ts +19 -0
- package/src/lib/grid-frame.ts +18 -0
- package/src/lib/interaction/scope.ts +9 -1
- package/src/lib/keyboard-pan.test.ts +62 -0
- package/src/lib/keyboard-pan.ts +89 -0
- 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/material-catalog-model.ts +72 -0
- package/src/lib/material-paint-panel-model.ts +76 -0
- package/src/lib/node-action-movement.ts +21 -0
- package/src/lib/overlay-transform.test.ts +28 -0
- package/src/lib/overlay-transform.ts +12 -0
- package/src/lib/panel-rows.ts +75 -0
- package/src/lib/panel-tool-options.test.ts +34 -0
- package/src/lib/panel-tool-options.ts +46 -0
- package/src/lib/parametric-node-update.ts +37 -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/scene.test.ts +99 -0
- package/src/lib/scene.ts +21 -3
- 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/snapping-mode.test.ts +13 -2
- package/src/lib/snapping-mode.ts +11 -6
- package/src/lib/spatial-drag-plane.test.ts +53 -0
- package/src/lib/spatial-drag-plane.ts +19 -0
- package/src/lib/spatial-pointer-input.test.ts +72 -0
- package/src/lib/spatial-pointer-input.ts +62 -0
- package/src/lib/spatial-snap.test.ts +47 -0
- package/src/lib/surface-attachment.ts +77 -0
- package/src/lib/terrain-panel-model.ts +117 -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/lib/world-grid-snap.test.ts +48 -0
- package/src/lib/world-grid-snap.ts +9 -5
- package/src/store/rotation-snapping.test.ts +53 -0
- package/src/store/use-editor.tsx +10 -2
- package/src/store/use-floorplan-draft-preview.ts +10 -1
- package/src/store/use-handle-group.ts +4 -0
- package/src/store/use-interaction-scope.ts +155 -6
- package/src/store/use-send-to-app.ts +21 -0
|
@@ -3,11 +3,24 @@ import {
|
|
|
3
3
|
type AnyNodeId,
|
|
4
4
|
cloneNodesInto,
|
|
5
5
|
collectSubtree,
|
|
6
|
+
createSceneApi,
|
|
6
7
|
type DuplicableConfig,
|
|
8
|
+
getSurfaceProvider,
|
|
7
9
|
nodeRegistry,
|
|
10
|
+
resolveSurfacePlacement,
|
|
11
|
+
type SurfaceRejectReason,
|
|
8
12
|
useScene,
|
|
9
13
|
} from '@pascal-app/core'
|
|
14
|
+
import { evaluateRecipe, isProceduralItem } from '@pascal-app/core/procedural-items'
|
|
15
|
+
import useInteractionScope from '../store/use-interaction-scope'
|
|
16
|
+
import usePlacementPreview from '../store/use-placement-preview'
|
|
10
17
|
import { getPlacementMetadataRecord, stripPlacementMetadataFlags } from './placement-metadata'
|
|
18
|
+
import {
|
|
19
|
+
surfaceAttachmentId,
|
|
20
|
+
surfaceAttachmentUpdates,
|
|
21
|
+
surfaceFramePose,
|
|
22
|
+
updateSurfaceNode,
|
|
23
|
+
} from './surface-attachment'
|
|
11
24
|
|
|
12
25
|
function cleanPlacementMetadata<N extends AnyNode>(node: N): N {
|
|
13
26
|
return {
|
|
@@ -27,7 +40,13 @@ function duplicableConfigFor(node: AnyNode): DuplicableConfig | null {
|
|
|
27
40
|
}
|
|
28
41
|
|
|
29
42
|
export function duplicatesAsFreshSubtree(node: AnyNode): boolean {
|
|
30
|
-
|
|
43
|
+
// A surface-local root needs the same attachment lifecycle even without descendants.
|
|
44
|
+
if (surfaceAttachmentId(node) !== null) return true
|
|
45
|
+
const policy = duplicableConfigFor(node)?.subtree
|
|
46
|
+
return (
|
|
47
|
+
policy === true ||
|
|
48
|
+
(policy === 'with-children' && 'children' in node && node.children.length > 0)
|
|
49
|
+
)
|
|
31
50
|
}
|
|
32
51
|
|
|
33
52
|
/**
|
|
@@ -94,54 +113,175 @@ export function createFreshPlacementSubtree(
|
|
|
94
113
|
parentId,
|
|
95
114
|
})
|
|
96
115
|
|
|
97
|
-
|
|
98
|
-
.
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
)
|
|
116
|
+
scene.applyNodeChanges({
|
|
117
|
+
create: cloned.nodes.map((node, index) =>
|
|
118
|
+
index === 0 && parentId ? { node, parentId } : { node },
|
|
119
|
+
),
|
|
120
|
+
update: surfaceAttachmentUpdates(cloned.rootId, parentId, surfaceAttachmentId(subtree.root)),
|
|
121
|
+
})
|
|
102
122
|
|
|
123
|
+
const created = useScene.getState().nodes[cloned.rootId]
|
|
124
|
+
if (!created) return null
|
|
125
|
+
useInteractionScope.getState().noteSubtreeCreation(created)
|
|
103
126
|
return cloned.rootId
|
|
104
127
|
}
|
|
105
128
|
|
|
129
|
+
export function discardFreshPlacementSubtree(rootId: AnyNodeId): void {
|
|
130
|
+
const scene = useScene.getState()
|
|
131
|
+
if (!scene.nodes[rootId]) return
|
|
132
|
+
scene.applyNodeChanges({
|
|
133
|
+
delete: [rootId],
|
|
134
|
+
update: surfaceAttachmentUpdates(rootId, null, null),
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function namedSurfaceRejection(
|
|
139
|
+
root: AnyNode,
|
|
140
|
+
surfaceId: string | null,
|
|
141
|
+
): SurfaceRejectReason | null {
|
|
142
|
+
if (surfaceId === null) return null
|
|
143
|
+
const scene = createSceneApi(useScene)
|
|
144
|
+
const host = root.parentId ? scene.get(root.parentId as AnyNodeId) : undefined
|
|
145
|
+
if (!host || !('position' in root) || !Array.isArray(root.rotation)) return 'no-surface'
|
|
146
|
+
const surface = getSurfaceProvider(host)
|
|
147
|
+
.surfaces?.(host, { scene })
|
|
148
|
+
.find((s) => s.id === surfaceId)
|
|
149
|
+
if (!surface) return 'no-surface'
|
|
150
|
+
const pose = surfaceFramePose(
|
|
151
|
+
root.parentId,
|
|
152
|
+
surfaceId,
|
|
153
|
+
root as {
|
|
154
|
+
position: [number, number, number]
|
|
155
|
+
rotation: [number, number, number]
|
|
156
|
+
},
|
|
157
|
+
false,
|
|
158
|
+
)
|
|
159
|
+
const capabilities = nodeRegistry.get(root.type)?.capabilities
|
|
160
|
+
const evaluated = isProceduralItem(root) ? evaluateRecipe(root.recipe, root.parameters) : null
|
|
161
|
+
const bounds =
|
|
162
|
+
capabilities?.dragBounds?.(root, scene.nodes()) ??
|
|
163
|
+
(evaluated
|
|
164
|
+
? {
|
|
165
|
+
size: evaluated.dimensions,
|
|
166
|
+
center: evaluated.min.map((v, i) => (v + evaluated.max[i]!) / 2) as [
|
|
167
|
+
number,
|
|
168
|
+
number,
|
|
169
|
+
number,
|
|
170
|
+
],
|
|
171
|
+
}
|
|
172
|
+
: undefined)
|
|
173
|
+
const size = bounds?.size ??
|
|
174
|
+
capabilities?.floorPlaced?.footprint?.(root, { nodes: scene.nodes() }).dimensions ?? [0, 0, 0]
|
|
175
|
+
const center = bounds?.center
|
|
176
|
+
const localBounds = center
|
|
177
|
+
? {
|
|
178
|
+
min: center.map((value, axis) => value - size[axis]! / 2) as [number, number, number],
|
|
179
|
+
max: center.map((value, axis) => value + size[axis]! / 2) as [number, number, number],
|
|
180
|
+
}
|
|
181
|
+
: undefined
|
|
182
|
+
let reason: SurfaceRejectReason | null = null
|
|
183
|
+
const placement = resolveSurfacePlacement({
|
|
184
|
+
host,
|
|
185
|
+
surface,
|
|
186
|
+
childKind: root.type,
|
|
187
|
+
childId: root.id,
|
|
188
|
+
childFootprint: {
|
|
189
|
+
size,
|
|
190
|
+
rotationY: pose.rotation[1],
|
|
191
|
+
rotation: pose.rotation,
|
|
192
|
+
localBounds,
|
|
193
|
+
},
|
|
194
|
+
hit: { point: pose.position, normalWorldY: 1 },
|
|
195
|
+
origin: pose.position,
|
|
196
|
+
scene,
|
|
197
|
+
onReject: (value) => {
|
|
198
|
+
reason = value
|
|
199
|
+
},
|
|
200
|
+
})
|
|
201
|
+
if (!placement) return reason ?? 'no-surface'
|
|
202
|
+
return null
|
|
203
|
+
}
|
|
204
|
+
|
|
106
205
|
/**
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
* Fresh drafts already exist in the scene so renderers and move tools can
|
|
110
|
-
* preview real geometry. On commit we delete that draft while history is
|
|
111
|
-
* paused, then create a clean clone at the final cursor position with history
|
|
112
|
-
* resumed. Undo therefore removes the placed node instead of resurrecting the
|
|
113
|
-
* hidden draft at its origin.
|
|
206
|
+
* Replace the draft in one validated write. History already excludes fresh
|
|
207
|
+
* subtrees, so this records one creation without first deleting the preview.
|
|
114
208
|
*/
|
|
115
209
|
export function commitFreshPlacementSubtree(
|
|
116
210
|
rootId: AnyNodeId,
|
|
117
211
|
rootPatch: Partial<AnyNode>,
|
|
212
|
+
onReject?: (reason: SurfaceRejectReason) => void,
|
|
118
213
|
): AnyNodeId | null {
|
|
119
214
|
const scene = useScene.getState()
|
|
120
215
|
const subtree = collectSubtree(scene.nodes, rootId)
|
|
121
|
-
if (!subtree) return null
|
|
216
|
+
if (!subtree || scene.readOnly) return null
|
|
122
217
|
|
|
123
|
-
const root = cleanPlacementMetadata({
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
218
|
+
const root = cleanPlacementMetadata({ ...subtree.root, ...rootPatch } as AnyNode)
|
|
219
|
+
const surfaceId =
|
|
220
|
+
root.parentId === subtree.root.parentId ? surfaceAttachmentId(subtree.root) : null
|
|
221
|
+
const rejection = namedSurfaceRejection(root, surfaceId)
|
|
222
|
+
if (rejection) {
|
|
223
|
+
onReject?.(rejection)
|
|
224
|
+
return null
|
|
225
|
+
}
|
|
127
226
|
const descendants = subtree.descendants.map((node) => cleanPlacementMetadata(node))
|
|
128
227
|
const parentId = parentIdOf(root)
|
|
129
|
-
const cloned = cloneNodesInto([root, ...descendants], {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
228
|
+
const cloned = cloneNodesInto([root, ...descendants], { rootId, parentId })
|
|
229
|
+
const updates = surfaceAttachmentUpdates(rootId, null, null)
|
|
230
|
+
for (const update of surfaceAttachmentUpdates(cloned.rootId, parentId, surfaceId)) {
|
|
231
|
+
const attachments = { ...(update.data as { attachments: Record<string, string> }).attachments }
|
|
232
|
+
delete attachments[rootId]
|
|
233
|
+
const previous = updates.findIndex((entry) => entry.id === update.id)
|
|
234
|
+
const merged = { id: update.id, data: { attachments } }
|
|
235
|
+
if (previous === -1) updates.push(merged)
|
|
236
|
+
else updates[previous] = merged
|
|
237
|
+
}
|
|
133
238
|
|
|
239
|
+
const scope = useInteractionScope.getState()
|
|
240
|
+
const factoryCreated =
|
|
241
|
+
scope.ownedSubtree?.creation.rootId === rootId || scope.pendingSubtree?.rootId === rootId
|
|
134
242
|
const temporal = useScene.temporal.getState()
|
|
135
|
-
const wasTracking =
|
|
136
|
-
if (
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
.getState()
|
|
141
|
-
.
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
243
|
+
const wasTracking = temporal.isTracking
|
|
244
|
+
if (!factoryCreated && descendants.length === 0 && surfaceId === null) {
|
|
245
|
+
// Preserve the established root-only lifecycle for placements outside the subtree factory.
|
|
246
|
+
if (wasTracking) temporal.pause()
|
|
247
|
+
updateSurfaceNode(rootId, {}, null)
|
|
248
|
+
useScene.getState().deleteNode(rootId)
|
|
249
|
+
temporal.resume()
|
|
250
|
+
useScene.getState().applyNodeChanges({
|
|
251
|
+
create: cloned.nodes.map((node, index) =>
|
|
252
|
+
index === 0 && parentId ? { node, parentId } : { node },
|
|
253
|
+
),
|
|
254
|
+
update: updates,
|
|
255
|
+
})
|
|
256
|
+
if (!wasTracking) temporal.pause()
|
|
257
|
+
} else {
|
|
258
|
+
temporal.resume()
|
|
259
|
+
try {
|
|
260
|
+
// applyNodeChanges validates the complete proposed graph before publishing any part of it.
|
|
261
|
+
scene.applyNodeChanges({
|
|
262
|
+
delete: [rootId],
|
|
263
|
+
create: cloned.nodes.map((node, index) =>
|
|
264
|
+
index === 0 && parentId ? { node, parentId } : { node },
|
|
265
|
+
),
|
|
266
|
+
update: updates,
|
|
267
|
+
})
|
|
268
|
+
for (const node of [subtree.root, ...subtree.descendants]) scene.clearDirty(node.id)
|
|
269
|
+
} catch (error) {
|
|
270
|
+
// Zustand publishes before notifying subscribers. A subscriber error must restore
|
|
271
|
+
// the draft and its ownership/history, never masquerade as a placement refusal.
|
|
272
|
+
temporal.pause()
|
|
273
|
+
try {
|
|
274
|
+
if (useScene.getState() !== scene) useScene.setState(scene, true)
|
|
275
|
+
} finally {
|
|
276
|
+
useInteractionScope.setState(scope)
|
|
277
|
+
useScene.temporal.setState(temporal)
|
|
278
|
+
}
|
|
279
|
+
throw error
|
|
280
|
+
} finally {
|
|
281
|
+
if (!wasTracking) temporal.pause()
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
useInteractionScope.getState().finishSubtree(rootId)
|
|
285
|
+
if (usePlacementPreview.getState().node?.id === rootId) usePlacementPreview.getState().clear()
|
|
146
286
|
return cloned.rootId
|
|
147
287
|
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
type AnyNode,
|
|
4
|
+
type AnyNodeDefinition,
|
|
5
|
+
DoorNode,
|
|
6
|
+
nodeRegistry,
|
|
7
|
+
registerNode,
|
|
8
|
+
sceneRegistry,
|
|
9
|
+
} from '@pascal-app/core'
|
|
10
|
+
import * as THREE from 'three'
|
|
11
|
+
import type { ExportTextureUtils } from './export-texture-utils'
|
|
12
|
+
import {
|
|
13
|
+
exportSceneToGlb,
|
|
14
|
+
type GlbExport,
|
|
15
|
+
serializePreparedSceneToGlb,
|
|
16
|
+
withExportDeadline,
|
|
17
|
+
} from './glb-export'
|
|
18
|
+
|
|
19
|
+
// GLTFExporter's binary path runs through document.createElement('canvas'),
|
|
20
|
+
// canvas.toBlob and three FileReader reads (image bufferView, merged buffer,
|
|
21
|
+
// final GLB). Bun has none of those, so a minimal stand-in drives the exporter
|
|
22
|
+
// the way a browser does — including the failure the browser reports only via
|
|
23
|
+
// `result === null` inside `onloadend`.
|
|
24
|
+
type Globals = Record<string, unknown>
|
|
25
|
+
const globals = globalThis as unknown as Globals
|
|
26
|
+
const DOM_KEYS = ['document', 'FileReader', 'ImageData', 'HTMLCanvasElement'] as const
|
|
27
|
+
const previous = new Map<string, unknown>()
|
|
28
|
+
|
|
29
|
+
class FakeImageData {
|
|
30
|
+
constructor(
|
|
31
|
+
public data: Uint8ClampedArray,
|
|
32
|
+
public width: number,
|
|
33
|
+
public height: number,
|
|
34
|
+
) {}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
class FakeCanvas {
|
|
38
|
+
width = 0
|
|
39
|
+
height = 0
|
|
40
|
+
private pixels: Uint8ClampedArray | null = null
|
|
41
|
+
|
|
42
|
+
getContext() {
|
|
43
|
+
return {
|
|
44
|
+
translate: () => {},
|
|
45
|
+
scale: () => {},
|
|
46
|
+
drawImage: () => {},
|
|
47
|
+
fillRect: () => {},
|
|
48
|
+
createImageData: (width: number, height: number) =>
|
|
49
|
+
new FakeImageData(new Uint8ClampedArray(width * height * 4), width, height),
|
|
50
|
+
putImageData: (image: FakeImageData) => {
|
|
51
|
+
this.pixels = new Uint8ClampedArray(image.data)
|
|
52
|
+
},
|
|
53
|
+
getImageData: (_x: number, _y: number, width: number, height: number) =>
|
|
54
|
+
new FakeImageData(this.pixels ?? new Uint8ClampedArray(width * height * 4), width, height),
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
toBlob(callback: (blob: Blob) => void, mimeType: string) {
|
|
59
|
+
callback(new Blob([new Uint8Array([137, 80, 78, 71])], { type: mimeType }))
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
type FileReaderBehaviour = {
|
|
64
|
+
reads: number
|
|
65
|
+
/** 1-based index of the read whose result is `null`, as after a failed read. */
|
|
66
|
+
failAt: number
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function installExporterDom(behaviour: FileReaderBehaviour) {
|
|
70
|
+
for (const key of DOM_KEYS) previous.set(key, globals[key])
|
|
71
|
+
|
|
72
|
+
class FakeFileReader {
|
|
73
|
+
result: ArrayBuffer | null = null
|
|
74
|
+
onloadend: (() => void) | null = null
|
|
75
|
+
readAsArrayBuffer(blob: Blob) {
|
|
76
|
+
behaviour.reads += 1
|
|
77
|
+
const read = behaviour.reads
|
|
78
|
+
void blob.arrayBuffer().then((buffer) => {
|
|
79
|
+
this.result = read === behaviour.failAt ? null : buffer
|
|
80
|
+
// Browsers report an exception thrown inside the event handler to
|
|
81
|
+
// window.onerror; it never reaches the exporter's promise chain.
|
|
82
|
+
try {
|
|
83
|
+
this.onloadend?.()
|
|
84
|
+
} catch {}
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
globals.ImageData = FakeImageData
|
|
90
|
+
globals.HTMLCanvasElement = FakeCanvas
|
|
91
|
+
globals.FileReader = FakeFileReader
|
|
92
|
+
globals.document = {
|
|
93
|
+
createElement: (tagName: string) => {
|
|
94
|
+
if (tagName !== 'canvas') throw new Error(`Unexpected element request: ${tagName}`)
|
|
95
|
+
return new FakeCanvas()
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
afterEach(() => {
|
|
101
|
+
for (const key of DOM_KEYS) globals[key] = previous.get(key)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
function texturedRoot(texture: THREE.Texture): THREE.Group {
|
|
105
|
+
const root = new THREE.Group()
|
|
106
|
+
root.add(
|
|
107
|
+
new THREE.Mesh(
|
|
108
|
+
new THREE.BoxGeometry(1, 1, 1),
|
|
109
|
+
new THREE.MeshStandardMaterial({ map: texture }),
|
|
110
|
+
),
|
|
111
|
+
)
|
|
112
|
+
return root
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function texturedScene(texture: THREE.Texture): GlbExport {
|
|
116
|
+
return { scene: texturedRoot(texture), animations: [], warnings: [], dispose: () => {} }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function readableTexture(): THREE.DataTexture {
|
|
120
|
+
const texture = new THREE.DataTexture(new Uint8Array([255, 0, 0, 255]), 1, 1)
|
|
121
|
+
texture.needsUpdate = true
|
|
122
|
+
return texture
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function passthroughUtils(): ExportTextureUtils & { calls: THREE.Texture[] } {
|
|
126
|
+
const calls: THREE.Texture[] = []
|
|
127
|
+
return {
|
|
128
|
+
calls,
|
|
129
|
+
decompress: async (texture) => {
|
|
130
|
+
calls.push(texture)
|
|
131
|
+
return readableTexture()
|
|
132
|
+
},
|
|
133
|
+
dispose: async () => {},
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const GLB_MAGIC = 0x46546c67
|
|
138
|
+
|
|
139
|
+
describe('serializePreparedSceneToGlb', () => {
|
|
140
|
+
test('resolves a binary glTF and routes compressed textures through the shared decompressor', async () => {
|
|
141
|
+
installExporterDom({ reads: 0, failAt: Number.POSITIVE_INFINITY })
|
|
142
|
+
const compressed = new THREE.CompressedTexture([], 2, 2)
|
|
143
|
+
compressed.name = 'Color_ktx2'
|
|
144
|
+
const textureUtils = passthroughUtils()
|
|
145
|
+
|
|
146
|
+
const glb = await serializePreparedSceneToGlb(texturedScene(compressed), { textureUtils })
|
|
147
|
+
|
|
148
|
+
expect(glb).toBeInstanceOf(ArrayBuffer)
|
|
149
|
+
expect(new DataView(glb).getUint32(0, true)).toBe(GLB_MAGIC)
|
|
150
|
+
expect(new DataView(glb).getUint32(8, true)).toBe(glb.byteLength)
|
|
151
|
+
expect(textureUtils.calls.map((texture) => texture.name)).toEqual(['Color_ktx2'])
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
test('rejects instead of resolving null when the final GLB read fails', async () => {
|
|
155
|
+
installExporterDom({ reads: 0, failAt: 3 })
|
|
156
|
+
|
|
157
|
+
await expect(
|
|
158
|
+
serializePreparedSceneToGlb(texturedScene(readableTexture()), {
|
|
159
|
+
textureUtils: passthroughUtils(),
|
|
160
|
+
}),
|
|
161
|
+
).rejects.toThrow('GLB export produced no data')
|
|
162
|
+
})
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
describe('withExportDeadline', () => {
|
|
166
|
+
test('passes a settled value through and clears its timer', async () => {
|
|
167
|
+
await expect(withExportDeadline(Promise.resolve(7), 1_000, 'GLB')).resolves.toBe(7)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
test('names the format and the wait in the timeout error', async () => {
|
|
171
|
+
const pending = new Promise<never>(() => {})
|
|
172
|
+
await expect(withExportDeadline(pending, 10, 'USDZ')).rejects.toThrow(
|
|
173
|
+
/USDZ export timed out after \d+ s/,
|
|
174
|
+
)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
test('an infinite deadline returns the original promise', () => {
|
|
178
|
+
const pending = new Promise<never>(() => {})
|
|
179
|
+
expect(withExportDeadline(pending, Number.POSITIVE_INFINITY, 'GLB')).toBe(pending)
|
|
180
|
+
})
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
describe('exportSceneToGlb', () => {
|
|
184
|
+
afterEach(() => {
|
|
185
|
+
sceneRegistry.clear()
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
test('rejects at the deadline when a FileReader inside GLTFExporter fails silently', async () => {
|
|
189
|
+
// The image bufferView read is the first one; a null result throws inside
|
|
190
|
+
// onloadend, so GLTFExporter's pending list never drains.
|
|
191
|
+
installExporterDom({ reads: 0, failAt: 1 })
|
|
192
|
+
|
|
193
|
+
await expect(
|
|
194
|
+
exportSceneToGlb(
|
|
195
|
+
texturedRoot(readableTexture()),
|
|
196
|
+
{},
|
|
197
|
+
{
|
|
198
|
+
textureUtils: passthroughUtils(),
|
|
199
|
+
timeoutMs: 50,
|
|
200
|
+
},
|
|
201
|
+
),
|
|
202
|
+
).rejects.toThrow('GLB export timed out')
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
test('exports a live scene through a caller-owned decompressor without disposing it', async () => {
|
|
206
|
+
installExporterDom({ reads: 0, failAt: Number.POSITIVE_INFINITY })
|
|
207
|
+
const root = new THREE.Group()
|
|
208
|
+
root.add(
|
|
209
|
+
new THREE.Mesh(
|
|
210
|
+
new THREE.BoxGeometry(1, 1, 1),
|
|
211
|
+
new THREE.MeshStandardMaterial({ map: readableTexture() }),
|
|
212
|
+
),
|
|
213
|
+
)
|
|
214
|
+
const textureUtils = passthroughUtils()
|
|
215
|
+
let disposals = 0
|
|
216
|
+
textureUtils.dispose = async () => {
|
|
217
|
+
disposals += 1
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const glb = await exportSceneToGlb(root, {}, { textureUtils, timeoutMs: 5_000 })
|
|
221
|
+
|
|
222
|
+
expect(new DataView(glb).getUint32(0, true)).toBe(GLB_MAGIC)
|
|
223
|
+
expect(disposals).toBe(0)
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
test('rejects at the deadline when a plugin bake hook never settles', async () => {
|
|
227
|
+
installExporterDom({ reads: 0, failAt: Number.POSITIVE_INFINITY })
|
|
228
|
+
const restoreRegistry = nodeRegistry._snapshot()
|
|
229
|
+
try {
|
|
230
|
+
const kind = 'test:bake-never-settles'
|
|
231
|
+
registerNode({
|
|
232
|
+
kind,
|
|
233
|
+
schemaVersion: 1,
|
|
234
|
+
schema: DoorNode,
|
|
235
|
+
category: 'furnish',
|
|
236
|
+
defaults: () => ({}) as never,
|
|
237
|
+
capabilities: {},
|
|
238
|
+
bake: 'replace',
|
|
239
|
+
bakeGeometryAsync: () => new Promise<THREE.Object3D>(() => {}),
|
|
240
|
+
} as AnyNodeDefinition)
|
|
241
|
+
const source = new THREE.Group()
|
|
242
|
+
source.add(new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshStandardMaterial()))
|
|
243
|
+
const root = new THREE.Group()
|
|
244
|
+
root.add(source)
|
|
245
|
+
sceneRegistry.nodes.set('stuck_1', source)
|
|
246
|
+
const nodes = {
|
|
247
|
+
stuck_1: { id: 'stuck_1', type: kind, visible: true },
|
|
248
|
+
} as unknown as Record<string, AnyNode>
|
|
249
|
+
|
|
250
|
+
await expect(
|
|
251
|
+
exportSceneToGlb(root, nodes, { textureUtils: passthroughUtils(), timeoutMs: 50 }),
|
|
252
|
+
).rejects.toThrow('GLB export timed out')
|
|
253
|
+
} finally {
|
|
254
|
+
restoreRegistry()
|
|
255
|
+
}
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
test('preparation and serialisation share one decompressor', async () => {
|
|
259
|
+
installExporterDom({ reads: 0, failAt: Number.POSITIVE_INFINITY })
|
|
260
|
+
const map = new THREE.CompressedTexture([], 2, 2)
|
|
261
|
+
map.name = 'Color_ktx2'
|
|
262
|
+
const normalMap = new THREE.CompressedTexture([], 2, 2)
|
|
263
|
+
normalMap.name = 'NormalGL_ktx2'
|
|
264
|
+
const material = new THREE.MeshStandardMaterial({ map, normalMap })
|
|
265
|
+
// A non-unit normalScale forces the preparation pass to bake (and so
|
|
266
|
+
// decompress) the normal map before the exporter ever sees it.
|
|
267
|
+
material.normalScale.set(0.5, 0.5)
|
|
268
|
+
const root = new THREE.Group()
|
|
269
|
+
root.add(new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), material))
|
|
270
|
+
const textureUtils = passthroughUtils()
|
|
271
|
+
|
|
272
|
+
const glb = await exportSceneToGlb(root, {}, { textureUtils, timeoutMs: 5_000 })
|
|
273
|
+
|
|
274
|
+
expect(new DataView(glb).getUint32(0, true)).toBe(GLB_MAGIC)
|
|
275
|
+
expect(textureUtils.calls.map((texture) => texture.name).sort()).toEqual([
|
|
276
|
+
'Color_ktx2',
|
|
277
|
+
'NormalGL_ktx2',
|
|
278
|
+
])
|
|
279
|
+
})
|
|
280
|
+
})
|