@pascal-app/editor 1.0.0-beta.5 → 1.0.0
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/LICENSE +21 -0
- package/bunfig.toml +4 -0
- package/package.json +13 -7
- package/scripts/generate-print-golden-house.ts +89 -0
- package/src/components/editor/bake-exporter.tsx +1 -0
- package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
- package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
- package/src/components/editor/capture-camera-rig.tsx +59 -0
- package/src/components/editor/custom-camera-controls.tsx +20 -11
- package/src/components/editor/editor-layout-mobile.tsx +6 -6
- package/src/components/editor/editor-layout-v2.tsx +9 -2
- package/src/components/editor/export-manager.tsx +175 -33
- package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
- package/src/components/editor/first-person-controls.tsx +180 -12
- package/src/components/editor/floating-action-menu.tsx +42 -51
- package/src/components/editor/floorplan-panel.tsx +76 -12
- package/src/components/editor/grid.tsx +12 -7
- package/src/components/editor/group-actions.ts +34 -50
- package/src/components/editor/group-move-3d.ts +51 -21
- package/src/components/editor/group-transform-shared.test.ts +78 -0
- package/src/components/editor/group-transform-shared.ts +74 -7
- package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
- package/src/components/editor/handles/handle-arrow.tsx +109 -48
- package/src/components/editor/handles/linear-resize-drag.ts +67 -0
- package/src/components/editor/handles/resize-snap.test.ts +46 -0
- package/src/components/editor/handles/resize-snap.ts +10 -1
- package/src/components/editor/handles/use-handle-drag.ts +18 -1
- package/src/components/editor/index.tsx +140 -21
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/node-arrow-handles.tsx +47 -58
- package/src/components/editor/selection-manager.tsx +104 -42
- package/src/components/editor/snapshot-capture-overlay.tsx +298 -39
- package/src/components/editor/snapshot-capture.test.ts +422 -0
- package/src/components/editor/snapshot-capture.ts +145 -0
- package/src/components/editor/thumbnail-generator.tsx +284 -237
- package/src/components/editor/use-floorplan-background-placement.ts +53 -5
- package/src/components/editor/wall-move-side-handles.tsx +299 -7
- package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +180 -32
- package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
- package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
- package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
- package/src/components/systems/roof/roof-edit-system.tsx +10 -19
- package/src/components/systems/selection-affordance-manager.tsx +59 -9
- package/src/components/systems/selection-affordance-services.ts +19 -0
- package/src/components/systems/stair/stair-edit-system.tsx +5 -2
- package/src/components/tools/fence/fence-drafting.test.ts +100 -0
- package/src/components/tools/fence/fence-drafting.ts +6 -25
- package/src/components/tools/item/draft-creation.test.ts +12 -0
- package/src/components/tools/item/draft-creation.ts +10 -0
- package/src/components/tools/item/face-host-commit.test.ts +65 -0
- package/src/components/tools/item/face-host-commit.ts +56 -0
- package/src/components/tools/item/face-host-preview.test.ts +77 -0
- package/src/components/tools/item/face-host-preview.ts +82 -0
- package/src/components/tools/item/move-tool.tsx +4 -3
- package/src/components/tools/item/placement-math.test.ts +11 -1
- package/src/components/tools/item/placement-math.ts +0 -20
- package/src/components/tools/item/placement-strategies.test.ts +331 -4
- package/src/components/tools/item/placement-strategies.ts +135 -2
- package/src/components/tools/item/placement-surface.test.ts +61 -0
- package/src/components/tools/item/placement-surface.ts +24 -0
- package/src/components/tools/item/placement-types.ts +6 -0
- package/src/components/tools/item/test-face-host.ts +121 -0
- package/src/components/tools/item/use-draft-node.test.tsx +336 -0
- package/src/components/tools/item/use-draft-node.ts +26 -4
- package/src/components/tools/item/use-placement-coordinator.tsx +428 -153
- package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
- package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
- package/src/components/tools/registry/move-registry-node-tool.tsx +306 -110
- package/src/components/tools/registry-tool-context.tsx +30 -0
- package/src/components/tools/select/box-select-tool.tsx +3 -1
- package/src/components/tools/select/marquee-footprint.test.ts +53 -0
- package/src/components/tools/select/marquee-footprint.ts +29 -0
- package/src/components/tools/shared/placement-box.tsx +57 -7
- package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
- package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
- package/src/components/tools/shared/pointer-support-cap.ts +45 -8
- package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
- package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
- package/src/components/tools/stair/stair-tool.tsx +127 -74
- package/src/components/tools/tool-manager.tsx +25 -8
- package/src/components/tools/wall/wall-drafting.test.ts +320 -2
- package/src/components/tools/wall/wall-drafting.ts +23 -114
- package/src/components/ui/action-menu/control-modes.tsx +9 -6
- package/src/components/ui/action-menu/index.tsx +11 -1
- package/src/components/ui/command-palette/editor-commands.tsx +9 -12
- package/src/components/ui/command-palette/index.tsx +7 -2
- package/src/components/ui/controls/material-paint-panel.tsx +6 -4
- package/src/components/ui/controls/material-picker.tsx +4 -4
- package/src/components/ui/controls/scene-material-list.tsx +6 -5
- package/src/components/ui/controls/segmented-control.tsx +8 -1
- package/src/components/ui/controls/slider-control.tsx +19 -3
- package/src/components/ui/controls/toggle-control.tsx +18 -5
- package/src/components/ui/controls/tool-options-panel.tsx +126 -0
- package/src/components/ui/floating-level-selector.tsx +22 -5
- package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
- package/src/components/ui/helpers/helper-manager.tsx +47 -11
- package/src/components/ui/helpers/item-helper.tsx +1 -1
- package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
- package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
- package/src/components/ui/panels/homogeneous-selection.ts +47 -0
- package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
- package/src/components/ui/panels/multi-field-value.test.ts +209 -0
- package/src/components/ui/panels/multi-field-value.ts +163 -0
- package/src/components/ui/panels/multi-height-mode.tsx +185 -0
- package/src/components/ui/panels/multi-parametric-inspector.tsx +263 -0
- package/src/components/ui/panels/multi-selection-panel.tsx +48 -37
- package/src/components/ui/panels/node-display.ts +4 -0
- package/src/components/ui/panels/panel-manager.tsx +77 -3
- package/src/components/ui/panels/parametric-field-control.tsx +173 -0
- package/src/components/ui/panels/parametric-field-utils.ts +18 -0
- package/src/components/ui/panels/parametric-inspector.tsx +29 -162
- package/src/components/ui/panels/reference-panel.tsx +31 -1
- package/src/components/ui/primitives/shortcut-token.tsx +23 -1
- package/src/components/ui/scene-loader.tsx +35 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +390 -36
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +20 -23
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +140 -0
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +109 -0
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +116 -46
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
- package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +14 -6
- package/src/components/ui/sidebar/tab-bar.tsx +2 -0
- package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
- package/src/components/viewer/viewer-scene-header.tsx +2 -1
- package/src/components/viewer/viewer-stage.tsx +4 -1
- package/src/components/viewer-overlay.tsx +1 -22
- package/src/components/viewer-zone-system.tsx +6 -2
- package/src/components/walkthrough-hud.tsx +19 -10
- package/src/hooks/use-auto-save.test.ts +73 -1
- package/src/hooks/use-auto-save.ts +76 -5
- package/src/hooks/use-ceiling-events.test.ts +122 -0
- package/src/hooks/use-drag-action.ts +17 -3
- package/src/hooks/use-grid-events.test.ts +240 -0
- package/src/hooks/use-grid-events.ts +190 -11
- package/src/hooks/use-keyboard.test.ts +244 -0
- package/src/hooks/use-keyboard.ts +129 -62
- package/src/hooks/use-registered-tool-enabled.ts +20 -0
- package/src/hooks/use-save-shortcut.ts +33 -0
- package/src/index.tsx +82 -4
- package/src/lib/active-placement-surface.test.ts +16 -0
- package/src/lib/active-placement-surface.ts +14 -0
- package/src/lib/ceiling-surface-raycast.test.ts +64 -0
- package/src/lib/ceiling-surface-raycast.ts +12 -0
- package/src/lib/contextual-help-extension.ts +17 -0
- package/src/lib/continuation.test.ts +11 -0
- package/src/lib/continuation.ts +8 -1
- package/src/lib/direct-manipulation.test.ts +132 -0
- package/src/lib/direct-manipulation.ts +38 -1
- package/src/lib/editor-api.ts +6 -3
- package/src/lib/elevation-guides.test.ts +28 -1
- package/src/lib/elevation-guides.ts +22 -7
- package/src/lib/floating-menu-scale.test.ts +29 -0
- package/src/lib/floating-menu-scale.ts +14 -0
- package/src/lib/floorplan/apply-alignment.test.ts +47 -2
- package/src/lib/floorplan/apply-alignment.ts +10 -1
- package/src/lib/floorplan/floorplan-export.test.ts +397 -1
- package/src/lib/floorplan/floorplan-export.tsx +111 -16
- package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
- package/src/lib/floorplan/floorplan-readonly.ts +1 -0
- package/src/lib/floorplan-grid-event-point.test.ts +29 -0
- package/src/lib/floorplan-grid-event-point.ts +13 -0
- package/src/lib/glb-export.test.ts +1169 -21
- package/src/lib/glb-export.ts +629 -101
- package/src/lib/grid-event-presentation.ts +16 -0
- package/src/lib/history.test.ts +528 -147
- package/src/lib/history.ts +81 -7
- package/src/lib/host-tree-children.test.ts +27 -0
- package/src/lib/host-tree-children.ts +79 -0
- package/src/lib/interaction/hot-set.test.ts +10 -1
- package/src/lib/interaction/hot-set.ts +3 -1
- package/src/lib/interaction/overlay-policy.test.ts +33 -1
- package/src/lib/interaction/overlay-policy.ts +16 -0
- package/src/lib/interaction/registered-drafting.ts +36 -0
- package/src/lib/interaction/scope.ts +31 -0
- package/src/lib/level-print-export.test.ts +484 -0
- package/src/lib/level-print-export.ts +526 -0
- package/src/lib/local-project-presentation-persistence.test.ts +302 -0
- package/src/lib/local-project-presentation-persistence.ts +335 -0
- package/src/lib/material-paint.ts +2 -0
- package/src/lib/model-export.ts +29 -0
- package/src/lib/paint-preview-owner.ts +71 -0
- package/src/lib/planar-cursor-placement.test.ts +178 -1
- package/src/lib/planar-cursor-placement.ts +61 -5
- package/src/lib/plugin-panels.test.ts +92 -1
- package/src/lib/plugin-panels.ts +48 -0
- package/src/lib/portable-export.test.ts +366 -0
- package/src/lib/portable-export.ts +969 -0
- package/src/lib/print-3mf.test.ts +86 -0
- package/src/lib/print-3mf.ts +161 -0
- package/src/lib/print-content-scope.test.ts +107 -0
- package/src/lib/print-content-scope.ts +90 -0
- package/src/lib/print-export.test.ts +294 -0
- package/src/lib/print-export.ts +735 -0
- package/src/lib/print-feature-thickness.test.ts +147 -0
- package/src/lib/print-feature-thickness.ts +180 -0
- package/src/lib/print-golden-house.test-fixture.ts +352 -0
- package/src/lib/print-golden-house.test.ts +249 -0
- package/src/lib/print-roof-solids.test.ts +133 -0
- package/src/lib/print-roof-solids.ts +630 -0
- package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
- package/src/lib/print-shell-compiler-baseline.ts +225 -0
- package/src/lib/print-shell-compiler-manifold-core.ts +330 -0
- package/src/lib/print-shell-compiler-manifold-worker.ts +187 -0
- package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
- package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
- package/src/lib/print-shell-compiler-protocol.ts +30 -0
- package/src/lib/print-shell-compiler.ts +273 -0
- package/src/lib/print-wall-solids.test.ts +194 -0
- package/src/lib/print-wall-solids.ts +328 -0
- package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
- package/src/lib/rigid-plan-svg-transform.ts +24 -0
- package/src/lib/scene.ts +22 -8
- package/src/lib/selection-routing.test.ts +105 -1
- package/src/lib/selection-routing.ts +19 -0
- package/src/lib/sfx-bus.ts +4 -0
- package/src/lib/sfx-player.ts +8 -0
- package/src/lib/snapping-mode.test.ts +32 -4
- package/src/lib/snapping-mode.ts +11 -7
- package/src/lib/usdz-export.ts +38 -0
- package/src/lib/walkthrough-pointer-lock.ts +47 -0
- package/src/store/live-draft-preview-stores.test.ts +3 -0
- package/src/store/terrain-sculpt-mode.test.ts +3 -6
- package/src/store/tool-mode.test.ts +87 -0
- package/src/store/use-camera-hint-focus.ts +48 -0
- package/src/store/use-editor.tsx +299 -188
- package/src/store/use-floorplan-draft-preview.ts +9 -0
- package/src/store/use-interaction-scope.test.ts +23 -1
- package/src/store/use-interaction-scope.ts +63 -2
- package/src/store/use-placement-preview.ts +56 -3
- package/src/components/tools/roof/roof-tool.tsx +0 -656
- package/src/components/ui/helpers/roof-helper.tsx +0 -14
|
@@ -1,9 +1,32 @@
|
|
|
1
1
|
import { afterEach, describe, expect, test } from 'bun:test'
|
|
2
|
-
import {
|
|
3
|
-
|
|
2
|
+
import {
|
|
3
|
+
type AnyNode,
|
|
4
|
+
type AnyNodeDefinition,
|
|
5
|
+
DoorNode,
|
|
6
|
+
type GeometryContext,
|
|
7
|
+
loadPlugin,
|
|
8
|
+
nodeRegistry,
|
|
9
|
+
registerNode,
|
|
10
|
+
SiteNode,
|
|
11
|
+
sceneRegistry,
|
|
12
|
+
useScene,
|
|
13
|
+
} from '@pascal-app/core'
|
|
14
|
+
import {
|
|
15
|
+
buildDoorPreviewMesh,
|
|
16
|
+
markViewerPresentationTextureBorrowed,
|
|
17
|
+
type ViewerPresentationContribution,
|
|
18
|
+
viewerPresentationRegistry,
|
|
19
|
+
} from '@pascal-app/viewer'
|
|
4
20
|
import * as THREE from 'three'
|
|
5
21
|
import type { GLTFWriter } from 'three/examples/jsm/exporters/GLTFExporter.js'
|
|
6
|
-
import {
|
|
22
|
+
import { OBJExporter } from 'three/examples/jsm/exporters/OBJExporter.js'
|
|
23
|
+
import { STLExporter } from 'three/examples/jsm/exporters/STLExporter.js'
|
|
24
|
+
import { MeshStandardNodeMaterial } from 'three/webgpu'
|
|
25
|
+
import {
|
|
26
|
+
prepareSceneForExport,
|
|
27
|
+
prepareSceneForExportAsync,
|
|
28
|
+
writeTextureReferenceExtras,
|
|
29
|
+
} from './glb-export'
|
|
7
30
|
|
|
8
31
|
// The reference module reads the storage origin lazily on first use, so
|
|
9
32
|
// setting the env here (before any validation call) pins it for the file.
|
|
@@ -14,25 +37,15 @@ afterEach(() => {
|
|
|
14
37
|
sceneRegistry.clear()
|
|
15
38
|
})
|
|
16
39
|
|
|
17
|
-
function nodeMaterial(overrides: Record<string, unknown> = {}) {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
return {
|
|
21
|
-
isNodeMaterial: true,
|
|
22
|
-
name: 'painted',
|
|
23
|
-
color: new THREE.Color('#cc3300'),
|
|
40
|
+
function nodeMaterial(overrides: Record<string, unknown> = {}): THREE.Material {
|
|
41
|
+
const material = new MeshStandardNodeMaterial({
|
|
42
|
+
color: '#cc3300',
|
|
24
43
|
roughness: 0.3,
|
|
25
44
|
metalness: 0.7,
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
depthWrite: true,
|
|
31
|
-
depthTest: true,
|
|
32
|
-
vertexColors: false,
|
|
33
|
-
toneMapped: true,
|
|
34
|
-
...overrides,
|
|
35
|
-
} as unknown as THREE.Material
|
|
45
|
+
})
|
|
46
|
+
material.name = 'painted'
|
|
47
|
+
Object.assign(material, overrides)
|
|
48
|
+
return material
|
|
36
49
|
}
|
|
37
50
|
|
|
38
51
|
function meshWithNodeMaterial(material: THREE.Material): THREE.Mesh {
|
|
@@ -40,6 +53,35 @@ function meshWithNodeMaterial(material: THREE.Material): THREE.Mesh {
|
|
|
40
53
|
return new THREE.Mesh(geometry, material)
|
|
41
54
|
}
|
|
42
55
|
|
|
56
|
+
function sceneWithVisibleAndHiddenBoxes(): {
|
|
57
|
+
root: THREE.Group
|
|
58
|
+
nodes: Record<string, AnyNode>
|
|
59
|
+
} {
|
|
60
|
+
const root = new THREE.Group()
|
|
61
|
+
const nodes: Record<string, AnyNode> = {}
|
|
62
|
+
|
|
63
|
+
for (const [id, visible, x] of [
|
|
64
|
+
['item_visible', true, 0],
|
|
65
|
+
['item_hidden', false, 2],
|
|
66
|
+
] as const) {
|
|
67
|
+
const group = new THREE.Group()
|
|
68
|
+
const mesh = meshWithNodeMaterial(nodeMaterial())
|
|
69
|
+
mesh.position.x = x
|
|
70
|
+
group.add(mesh)
|
|
71
|
+
root.add(group)
|
|
72
|
+
sceneRegistry.nodes.set(id, group)
|
|
73
|
+
nodes[id] = {
|
|
74
|
+
object: 'node',
|
|
75
|
+
id,
|
|
76
|
+
type: 'item',
|
|
77
|
+
parentId: null,
|
|
78
|
+
visible,
|
|
79
|
+
} as unknown as AnyNode
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return { root, nodes }
|
|
83
|
+
}
|
|
84
|
+
|
|
43
85
|
describe('prepareSceneForExport', () => {
|
|
44
86
|
test('converts NodeMaterials to classic glTF-standard materials', () => {
|
|
45
87
|
const root = new THREE.Group()
|
|
@@ -57,6 +99,317 @@ describe('prepareSceneForExport', () => {
|
|
|
57
99
|
expect(material.color.getHexString()).toBe('cc3300')
|
|
58
100
|
})
|
|
59
101
|
|
|
102
|
+
test('replaces only the cloned registered subtree with bake-only geometry', () => {
|
|
103
|
+
const restoreRegistry = nodeRegistry._snapshot()
|
|
104
|
+
try {
|
|
105
|
+
let receivedParentId: string | null | undefined
|
|
106
|
+
registerNode({
|
|
107
|
+
kind: 'test:bake-geometry',
|
|
108
|
+
schemaVersion: 1,
|
|
109
|
+
schema: DoorNode,
|
|
110
|
+
category: 'utility',
|
|
111
|
+
defaults: () => ({}) as never,
|
|
112
|
+
capabilities: {},
|
|
113
|
+
bake: 'replace',
|
|
114
|
+
bakeGeometry: (_node, context) => {
|
|
115
|
+
receivedParentId = context.parent?.id
|
|
116
|
+
const group = new THREE.Group()
|
|
117
|
+
const instances = new THREE.InstancedMesh(
|
|
118
|
+
new THREE.BoxGeometry(0.1, 1, 0.1),
|
|
119
|
+
new THREE.MeshStandardMaterial({ color: '#228833' }),
|
|
120
|
+
1,
|
|
121
|
+
)
|
|
122
|
+
instances.name = 'baked-instance'
|
|
123
|
+
instances.setMatrixAt(0, new THREE.Matrix4().makeTranslation(4, 0, 2))
|
|
124
|
+
group.add(instances)
|
|
125
|
+
return group
|
|
126
|
+
},
|
|
127
|
+
} as AnyNodeDefinition)
|
|
128
|
+
|
|
129
|
+
const root = new THREE.Group()
|
|
130
|
+
const liveGroup = new THREE.Group()
|
|
131
|
+
liveGroup.position.set(2, 0, 3)
|
|
132
|
+
const liveMesh = meshWithNodeMaterial(nodeMaterial())
|
|
133
|
+
liveMesh.name = 'live-procedural-candidate'
|
|
134
|
+
liveGroup.add(liveMesh)
|
|
135
|
+
const before = meshWithNodeMaterial(nodeMaterial())
|
|
136
|
+
before.name = 'before-bake-replacement'
|
|
137
|
+
const after = meshWithNodeMaterial(nodeMaterial())
|
|
138
|
+
after.name = 'after-bake-replacement'
|
|
139
|
+
root.add(before, liveGroup, after)
|
|
140
|
+
|
|
141
|
+
const siteId = 'site_bake'
|
|
142
|
+
const grassId = 'grass_bake'
|
|
143
|
+
sceneRegistry.nodes.set(grassId, liveGroup)
|
|
144
|
+
const nodes = {
|
|
145
|
+
[siteId]: {
|
|
146
|
+
object: 'node',
|
|
147
|
+
id: siteId,
|
|
148
|
+
type: 'site',
|
|
149
|
+
parentId: null,
|
|
150
|
+
children: [grassId],
|
|
151
|
+
} as unknown as AnyNode,
|
|
152
|
+
[grassId]: {
|
|
153
|
+
object: 'node',
|
|
154
|
+
id: grassId,
|
|
155
|
+
type: 'test:bake-geometry',
|
|
156
|
+
parentId: siteId,
|
|
157
|
+
visible: true,
|
|
158
|
+
} as unknown as AnyNode,
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const { scene } = prepareSceneForExport(root, nodes)
|
|
162
|
+
const exported = scene.getObjectByName(grassId)
|
|
163
|
+
const baked = exported?.getObjectByName('baked-instance')
|
|
164
|
+
|
|
165
|
+
expect(receivedParentId).toBe(siteId)
|
|
166
|
+
expect(liveGroup.getObjectByName('live-procedural-candidate')).toBe(liveMesh)
|
|
167
|
+
expect(scene.getObjectByName('live-procedural-candidate')).toBeUndefined()
|
|
168
|
+
expect(exported?.position.toArray()).toEqual([2, 0, 3])
|
|
169
|
+
expect(scene.children.map((child) => child.name)).toEqual([
|
|
170
|
+
'before-bake-replacement',
|
|
171
|
+
grassId,
|
|
172
|
+
'after-bake-replacement',
|
|
173
|
+
])
|
|
174
|
+
expect((baked as THREE.InstancedMesh | undefined)?.isInstancedMesh).toBe(true)
|
|
175
|
+
expect((baked as THREE.InstancedMesh | undefined)?.count).toBe(1)
|
|
176
|
+
expect(((baked as THREE.InstancedMesh).material as THREE.Material).isMaterial).toBe(true)
|
|
177
|
+
} finally {
|
|
178
|
+
restoreRegistry()
|
|
179
|
+
}
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test.each([
|
|
183
|
+
'sync',
|
|
184
|
+
'portable-sync',
|
|
185
|
+
'portable-async',
|
|
186
|
+
] as const)('bakes Site bounds and same-kind sibling reservations through %s hooks', async (mode) => {
|
|
187
|
+
const restoreRegistry = nodeRegistry._snapshot()
|
|
188
|
+
try {
|
|
189
|
+
const kind = 'test:site-context-bake'
|
|
190
|
+
const nodeId = 'site_context_bake'
|
|
191
|
+
const site = SiteNode.parse({
|
|
192
|
+
polygon: {
|
|
193
|
+
type: 'polygon',
|
|
194
|
+
points: [
|
|
195
|
+
[0, 0],
|
|
196
|
+
[12, 0],
|
|
197
|
+
[12, 8],
|
|
198
|
+
[0, 8],
|
|
199
|
+
],
|
|
200
|
+
},
|
|
201
|
+
children: [nodeId, 'declared_sibling', 'linked_sibling', 'other_kind'],
|
|
202
|
+
})
|
|
203
|
+
const otherSite = SiteNode.parse({
|
|
204
|
+
polygon: {
|
|
205
|
+
type: 'polygon',
|
|
206
|
+
points: [
|
|
207
|
+
[0, 0],
|
|
208
|
+
[30, 0],
|
|
209
|
+
[30, 4],
|
|
210
|
+
[0, 4],
|
|
211
|
+
],
|
|
212
|
+
},
|
|
213
|
+
children: ['other_site_sibling'],
|
|
214
|
+
})
|
|
215
|
+
const buildGeometry = (context: GeometryContext) => {
|
|
216
|
+
const points = (context.parent as SiteNode | null)?.polygon?.points ?? [[0, 0]]
|
|
217
|
+
const xs = points.map(([x]) => x)
|
|
218
|
+
const zs = points.map(([, z]) => z)
|
|
219
|
+
const reservedWidth = context.siblings.reduce(
|
|
220
|
+
(width, sibling) => width + Number(sibling.metadata?.reservedWidth ?? 0),
|
|
221
|
+
0,
|
|
222
|
+
)
|
|
223
|
+
return new THREE.Mesh(
|
|
224
|
+
new THREE.BoxGeometry(
|
|
225
|
+
Math.max(...xs) - Math.min(...xs) - reservedWidth,
|
|
226
|
+
1,
|
|
227
|
+
Math.max(...zs) - Math.min(...zs),
|
|
228
|
+
),
|
|
229
|
+
new THREE.MeshStandardMaterial(),
|
|
230
|
+
)
|
|
231
|
+
}
|
|
232
|
+
registerNode({
|
|
233
|
+
kind,
|
|
234
|
+
schemaVersion: 1,
|
|
235
|
+
schema: DoorNode,
|
|
236
|
+
category: 'furnish',
|
|
237
|
+
defaults: () => ({}) as never,
|
|
238
|
+
capabilities: {},
|
|
239
|
+
bake: 'replace',
|
|
240
|
+
...(mode === 'portable-async'
|
|
241
|
+
? { bakeGeometryAsync: async (_node, context) => buildGeometry(context) }
|
|
242
|
+
: { bakeGeometry: (_node, context) => buildGeometry(context) }),
|
|
243
|
+
} as AnyNodeDefinition)
|
|
244
|
+
const nodes = {
|
|
245
|
+
[site.id]: site,
|
|
246
|
+
[otherSite.id]: otherSite,
|
|
247
|
+
[nodeId]: {
|
|
248
|
+
id: nodeId,
|
|
249
|
+
type: kind,
|
|
250
|
+
parentId: null,
|
|
251
|
+
visible: true,
|
|
252
|
+
metadata: { reservedWidth: 100 },
|
|
253
|
+
},
|
|
254
|
+
declared_sibling: {
|
|
255
|
+
id: 'declared_sibling',
|
|
256
|
+
type: kind,
|
|
257
|
+
parentId: null,
|
|
258
|
+
metadata: { reservedWidth: 2 },
|
|
259
|
+
},
|
|
260
|
+
pointer_sibling: {
|
|
261
|
+
id: 'pointer_sibling',
|
|
262
|
+
type: kind,
|
|
263
|
+
parentId: site.id,
|
|
264
|
+
metadata: { reservedWidth: 3 },
|
|
265
|
+
},
|
|
266
|
+
linked_sibling: {
|
|
267
|
+
id: 'linked_sibling',
|
|
268
|
+
type: kind,
|
|
269
|
+
parentId: site.id,
|
|
270
|
+
metadata: { reservedWidth: 1 },
|
|
271
|
+
},
|
|
272
|
+
other_kind: {
|
|
273
|
+
id: 'other_kind',
|
|
274
|
+
type: 'test:other-kind',
|
|
275
|
+
parentId: site.id,
|
|
276
|
+
metadata: { reservedWidth: 100 },
|
|
277
|
+
},
|
|
278
|
+
other_site_sibling: {
|
|
279
|
+
id: 'other_site_sibling',
|
|
280
|
+
type: kind,
|
|
281
|
+
parentId: otherSite.id,
|
|
282
|
+
metadata: { reservedWidth: 4 },
|
|
283
|
+
},
|
|
284
|
+
} as unknown as Record<string, AnyNode>
|
|
285
|
+
const root = new THREE.Group()
|
|
286
|
+
const source = new THREE.Group()
|
|
287
|
+
root.add(source)
|
|
288
|
+
sceneRegistry.nodes.set(nodeId, source)
|
|
289
|
+
const prepare = mode === 'sync' ? prepareSceneForExport : prepareSceneForExportAsync
|
|
290
|
+
const exportedSize = async () => {
|
|
291
|
+
const prepared = await prepare(root, nodes)
|
|
292
|
+
try {
|
|
293
|
+
const exported = prepared.scene.getObjectByName(nodeId)!
|
|
294
|
+
return new THREE.Box3().setFromObject(exported).getSize(new THREE.Vector3()).toArray()
|
|
295
|
+
} finally {
|
|
296
|
+
prepared.dispose()
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
expect(await exportedSize()).toEqual([6, 1, 8])
|
|
301
|
+
nodes[nodeId]!.parentId = otherSite.id
|
|
302
|
+
expect(await exportedSize()).toEqual([26, 1, 4])
|
|
303
|
+
nodes[nodeId]!.parentId = null
|
|
304
|
+
site.children = site.children.filter((id) => id !== nodeId)
|
|
305
|
+
expect(await exportedSize()).toEqual([0, 1, 0])
|
|
306
|
+
} finally {
|
|
307
|
+
restoreRegistry()
|
|
308
|
+
}
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
test('selective exports omit a procedural kind without changing the live scene or later exports', () => {
|
|
312
|
+
const restoreRegistry = nodeRegistry._snapshot()
|
|
313
|
+
try {
|
|
314
|
+
const kind = 'test:optional-ground-cover'
|
|
315
|
+
registerNode({
|
|
316
|
+
kind,
|
|
317
|
+
schemaVersion: 1,
|
|
318
|
+
schema: DoorNode,
|
|
319
|
+
category: 'furnish',
|
|
320
|
+
defaults: () => ({}) as never,
|
|
321
|
+
capabilities: {},
|
|
322
|
+
bake: 'replace',
|
|
323
|
+
bakeGeometry: () =>
|
|
324
|
+
new THREE.Mesh(
|
|
325
|
+
new THREE.BoxGeometry(1, 1, 1),
|
|
326
|
+
new THREE.MeshStandardMaterial({ color: '#228833' }),
|
|
327
|
+
),
|
|
328
|
+
} as AnyNodeDefinition)
|
|
329
|
+
const root = new THREE.Group()
|
|
330
|
+
const building = meshWithNodeMaterial(nodeMaterial())
|
|
331
|
+
const grass = new THREE.Group()
|
|
332
|
+
const water = meshWithNodeMaterial(nodeMaterial())
|
|
333
|
+
root.add(building, grass, water)
|
|
334
|
+
const nodes: Record<string, AnyNode> = {}
|
|
335
|
+
for (const [id, type, object] of [
|
|
336
|
+
['building_export', 'building', building],
|
|
337
|
+
['grass_export', kind, grass],
|
|
338
|
+
['water_export', 'test:water', water],
|
|
339
|
+
] as const) {
|
|
340
|
+
sceneRegistry.nodes.set(id, object)
|
|
341
|
+
nodes[id] = { id, type, visible: true } as unknown as AnyNode
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const complete = prepareSceneForExport(root, nodes)
|
|
345
|
+
const selected = prepareSceneForExport(root, nodes, {
|
|
346
|
+
excludedNodeTypes: [kind],
|
|
347
|
+
onlyVisible: false,
|
|
348
|
+
})
|
|
349
|
+
const stl = new STLExporter()
|
|
350
|
+
expect(stl.parse(complete.scene, { binary: true }).getUint32(80, true)).toBe(36)
|
|
351
|
+
expect(stl.parse(selected.scene, { binary: true }).getUint32(80, true)).toBe(24)
|
|
352
|
+
const obj = new OBJExporter().parse(selected.scene)
|
|
353
|
+
expect(obj).not.toContain('grass_export')
|
|
354
|
+
expect(obj).toContain('building_export')
|
|
355
|
+
expect(obj).toContain('water_export')
|
|
356
|
+
expect(selected.scene.getObjectByName('grass_export')).toBeUndefined()
|
|
357
|
+
expect(root.children).toEqual([building, grass, water])
|
|
358
|
+
expect(grass.visible).toBe(true)
|
|
359
|
+
expect(nodes.grass_export?.visible).toBe(true)
|
|
360
|
+
const later = prepareSceneForExport(root, nodes)
|
|
361
|
+
expect(stl.parse(later.scene, { binary: true }).getUint32(80, true)).toBe(36)
|
|
362
|
+
} finally {
|
|
363
|
+
restoreRegistry()
|
|
364
|
+
}
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
test('excluding a parent skips descendant bake failures and animation tracks', () => {
|
|
368
|
+
const restoreRegistry = nodeRegistry._snapshot()
|
|
369
|
+
try {
|
|
370
|
+
registerNode({
|
|
371
|
+
kind: 'test:unavailable-bake',
|
|
372
|
+
schemaVersion: 1,
|
|
373
|
+
schema: DoorNode,
|
|
374
|
+
category: 'furnish',
|
|
375
|
+
defaults: () => ({}) as never,
|
|
376
|
+
capabilities: {},
|
|
377
|
+
bakeGeometry: () => {
|
|
378
|
+
throw new Error('Procedural content unavailable')
|
|
379
|
+
},
|
|
380
|
+
} as AnyNodeDefinition)
|
|
381
|
+
const root = new THREE.Group()
|
|
382
|
+
const excluded = new THREE.Group()
|
|
383
|
+
const procedural = new THREE.Group()
|
|
384
|
+
const door = new THREE.Group()
|
|
385
|
+
const leaf = meshWithNodeMaterial(nodeMaterial())
|
|
386
|
+
leaf.userData.pascalSwingLeaf = { axis: 'y', openRotationY: Math.PI / 2 }
|
|
387
|
+
door.add(leaf)
|
|
388
|
+
excluded.add(procedural, door)
|
|
389
|
+
root.add(excluded, meshWithNodeMaterial(nodeMaterial()))
|
|
390
|
+
const nodes: Record<string, AnyNode> = {}
|
|
391
|
+
for (const [id, type, object, parentId] of [
|
|
392
|
+
['procedural_child', 'test:unavailable-bake', procedural, 'excluded_parent'],
|
|
393
|
+
['door_child', 'door', door, 'excluded_parent'],
|
|
394
|
+
['excluded_parent', 'test:optional-parent', excluded, null],
|
|
395
|
+
] as const) {
|
|
396
|
+
sceneRegistry.nodes.set(id, object)
|
|
397
|
+
nodes[id] = { id, type, parentId, visible: true } as unknown as AnyNode
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
expect(() => prepareSceneForExport(root, nodes)).toThrow('Procedural content unavailable')
|
|
401
|
+
const selected = prepareSceneForExport(root, nodes, {
|
|
402
|
+
excludedNodeTypes: ['test:optional-parent'],
|
|
403
|
+
})
|
|
404
|
+
expect(new STLExporter().parse(selected.scene, { binary: true }).getUint32(80, true)).toBe(12)
|
|
405
|
+
expect(selected.animations).toEqual([])
|
|
406
|
+
expect(selected.scene.getObjectByName('door_child')).toBeUndefined()
|
|
407
|
+
expect(excluded.children).toEqual([procedural, door])
|
|
408
|
+
} finally {
|
|
409
|
+
restoreRegistry()
|
|
410
|
+
}
|
|
411
|
+
})
|
|
412
|
+
|
|
60
413
|
test('shared NodeMaterial instances convert to a single shared material', () => {
|
|
61
414
|
const root = new THREE.Group()
|
|
62
415
|
const shared = nodeMaterial()
|
|
@@ -114,7 +467,11 @@ describe('prepareSceneForExport', () => {
|
|
|
114
467
|
expect(placeholder.flipY).toBe(stamped.flipY)
|
|
115
468
|
expect(placeholder.colorSpace).toBe(stamped.colorSpace)
|
|
116
469
|
expect(placeholder.userData.pascalTextureRef).toEqual(stamped.userData.pascalTextureRef)
|
|
117
|
-
expect(material.normalMap).
|
|
470
|
+
expect(material.normalMap).toBeInstanceOf(THREE.DataTexture)
|
|
471
|
+
expect(Array.from((material.normalMap as THREE.DataTexture).image.data as Uint8Array)).toEqual([
|
|
472
|
+
128, 128, 255, 255,
|
|
473
|
+
])
|
|
474
|
+
expect(material.normalMap?.userData.pascalTextureRef).toBeUndefined()
|
|
118
475
|
const sharedMaterial = (scene.children[1] as THREE.Mesh).material as THREE.MeshStandardMaterial
|
|
119
476
|
expect(sharedMaterial.map).toBe(placeholder)
|
|
120
477
|
})
|
|
@@ -173,6 +530,151 @@ describe('prepareSceneForExport', () => {
|
|
|
173
530
|
expect(meshes).toHaveLength(1)
|
|
174
531
|
})
|
|
175
532
|
|
|
533
|
+
test('excludes hidden scene nodes and descendants by default', () => {
|
|
534
|
+
const root = new THREE.Group()
|
|
535
|
+
const levelGroup = new THREE.Group()
|
|
536
|
+
const itemGroup = new THREE.Group()
|
|
537
|
+
itemGroup.add(meshWithNodeMaterial(nodeMaterial()))
|
|
538
|
+
levelGroup.add(itemGroup)
|
|
539
|
+
root.add(levelGroup)
|
|
540
|
+
|
|
541
|
+
const levelId = 'level_hidden'
|
|
542
|
+
const itemId = 'item_visible_child'
|
|
543
|
+
sceneRegistry.nodes.set(levelId, levelGroup)
|
|
544
|
+
sceneRegistry.nodes.set(itemId, itemGroup)
|
|
545
|
+
const nodes: Record<string, AnyNode> = {
|
|
546
|
+
[levelId]: {
|
|
547
|
+
object: 'node',
|
|
548
|
+
id: levelId,
|
|
549
|
+
type: 'level',
|
|
550
|
+
parentId: null,
|
|
551
|
+
visible: false,
|
|
552
|
+
} as unknown as AnyNode,
|
|
553
|
+
[itemId]: {
|
|
554
|
+
object: 'node',
|
|
555
|
+
id: itemId,
|
|
556
|
+
type: 'item',
|
|
557
|
+
parentId: levelId,
|
|
558
|
+
visible: true,
|
|
559
|
+
} as unknown as AnyNode,
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const { scene, animations } = prepareSceneForExport(root, nodes)
|
|
563
|
+
|
|
564
|
+
expect(scene.getObjectByName(levelId)).toBeUndefined()
|
|
565
|
+
expect(scene.getObjectByName(itemId)).toBeUndefined()
|
|
566
|
+
expect(animations).toHaveLength(0)
|
|
567
|
+
})
|
|
568
|
+
|
|
569
|
+
test('inherits hidden Site visibility for detached declared children and their descendants', async () => {
|
|
570
|
+
const restoreRegistry = nodeRegistry._snapshot()
|
|
571
|
+
try {
|
|
572
|
+
const kind = 'test:detached-site-visibility'
|
|
573
|
+
const childId = 'detached_site_child'
|
|
574
|
+
const descendantId = 'detached_site_descendant'
|
|
575
|
+
const explicitId = 'explicit_site_child'
|
|
576
|
+
const unownedId = 'unowned_site_child'
|
|
577
|
+
const hiddenSite = SiteNode.parse({
|
|
578
|
+
visible: false,
|
|
579
|
+
children: [childId, explicitId],
|
|
580
|
+
})
|
|
581
|
+
const visibleSite = SiteNode.parse({ visible: true })
|
|
582
|
+
registerNode({
|
|
583
|
+
kind,
|
|
584
|
+
schemaVersion: 1,
|
|
585
|
+
schema: DoorNode,
|
|
586
|
+
category: 'furnish',
|
|
587
|
+
defaults: () => ({}) as never,
|
|
588
|
+
capabilities: {},
|
|
589
|
+
bakeGeometryAsync: async () =>
|
|
590
|
+
new THREE.Mesh(new THREE.BoxGeometry(2, 1, 3), new THREE.MeshStandardMaterial()),
|
|
591
|
+
} as AnyNodeDefinition)
|
|
592
|
+
const nodes = {
|
|
593
|
+
[hiddenSite.id]: hiddenSite,
|
|
594
|
+
[visibleSite.id]: visibleSite,
|
|
595
|
+
[childId]: { id: childId, type: kind, parentId: null, visible: true },
|
|
596
|
+
[descendantId]: { id: descendantId, type: kind, parentId: childId, visible: true },
|
|
597
|
+
[explicitId]: { id: explicitId, type: kind, parentId: visibleSite.id, visible: true },
|
|
598
|
+
[unownedId]: { id: unownedId, type: kind, parentId: null, visible: true },
|
|
599
|
+
} as unknown as Record<string, AnyNode>
|
|
600
|
+
const allIds = [childId, descendantId, explicitId, unownedId]
|
|
601
|
+
const root = new THREE.Group()
|
|
602
|
+
for (const id of [hiddenSite.id, visibleSite.id, ...allIds]) {
|
|
603
|
+
const object = new THREE.Group()
|
|
604
|
+
root.add(object)
|
|
605
|
+
sceneRegistry.nodes.set(id, object)
|
|
606
|
+
}
|
|
607
|
+
const exportedIds = async (onlyVisible?: boolean) => {
|
|
608
|
+
const prepared = await prepareSceneForExportAsync(root, nodes, { onlyVisible })
|
|
609
|
+
try {
|
|
610
|
+
return allIds.filter((id) => prepared.scene.getObjectByName(id) !== undefined)
|
|
611
|
+
} finally {
|
|
612
|
+
prepared.dispose()
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
expect(await exportedIds()).toEqual([explicitId, unownedId])
|
|
617
|
+
expect(await exportedIds(false)).toEqual(allIds)
|
|
618
|
+
nodes[hiddenSite.id] = { ...hiddenSite, visible: true }
|
|
619
|
+
expect(await exportedIds()).toEqual(allIds)
|
|
620
|
+
nodes[hiddenSite.id] = hiddenSite
|
|
621
|
+
expect(await exportedIds()).toEqual([explicitId, unownedId])
|
|
622
|
+
} finally {
|
|
623
|
+
restoreRegistry()
|
|
624
|
+
}
|
|
625
|
+
})
|
|
626
|
+
|
|
627
|
+
test('can include hidden scene nodes when visible-only export is disabled', () => {
|
|
628
|
+
const root = new THREE.Group()
|
|
629
|
+
const itemGroup = new THREE.Group()
|
|
630
|
+
itemGroup.add(meshWithNodeMaterial(nodeMaterial()))
|
|
631
|
+
root.add(itemGroup)
|
|
632
|
+
|
|
633
|
+
const itemId = 'item_hidden'
|
|
634
|
+
sceneRegistry.nodes.set(itemId, itemGroup)
|
|
635
|
+
const nodes: Record<string, AnyNode> = {
|
|
636
|
+
[itemId]: {
|
|
637
|
+
object: 'node',
|
|
638
|
+
id: itemId,
|
|
639
|
+
type: 'item',
|
|
640
|
+
parentId: null,
|
|
641
|
+
visible: false,
|
|
642
|
+
} as unknown as AnyNode,
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
const { scene } = prepareSceneForExport(root, nodes, { onlyVisible: false })
|
|
646
|
+
|
|
647
|
+
expect(scene.getObjectByName(itemId)?.userData).toMatchObject({
|
|
648
|
+
pascalId: itemId,
|
|
649
|
+
kind: 'item',
|
|
650
|
+
})
|
|
651
|
+
})
|
|
652
|
+
|
|
653
|
+
test('traditional binary STL excludes hidden nodes by default and can include them', () => {
|
|
654
|
+
const { root, nodes } = sceneWithVisibleAndHiddenBoxes()
|
|
655
|
+
|
|
656
|
+
const visibleScene = prepareSceneForExport(root, nodes).scene
|
|
657
|
+
const completeScene = prepareSceneForExport(root, nodes, { onlyVisible: false }).scene
|
|
658
|
+
const visibleStl = new STLExporter().parse(visibleScene, { binary: true })
|
|
659
|
+
const completeStl = new STLExporter().parse(completeScene, { binary: true })
|
|
660
|
+
|
|
661
|
+
expect(visibleStl.getUint32(80, true)).toBe(12)
|
|
662
|
+
expect(completeStl.getUint32(80, true)).toBe(24)
|
|
663
|
+
})
|
|
664
|
+
|
|
665
|
+
test('traditional OBJ excludes hidden nodes by default and can include them', () => {
|
|
666
|
+
const { root, nodes } = sceneWithVisibleAndHiddenBoxes()
|
|
667
|
+
|
|
668
|
+
const visibleScene = prepareSceneForExport(root, nodes).scene
|
|
669
|
+
const completeScene = prepareSceneForExport(root, nodes, { onlyVisible: false }).scene
|
|
670
|
+
const visibleObj = new OBJExporter().parse(visibleScene)
|
|
671
|
+
const completeObj = new OBJExporter().parse(completeScene)
|
|
672
|
+
const vertexCount = (obj: string) => obj.match(/^v /gm)?.length ?? 0
|
|
673
|
+
|
|
674
|
+
expect(vertexCount(visibleObj)).toBe(24)
|
|
675
|
+
expect(vertexCount(completeObj)).toBe(48)
|
|
676
|
+
})
|
|
677
|
+
|
|
176
678
|
test('neutralises an invisible hitbox root but keeps its visible children', () => {
|
|
177
679
|
// Door/window roots are selection hitboxes: a box geometry with an invisible
|
|
178
680
|
// material (object stays visible). Left intact it would plug the wall opening.
|
|
@@ -553,4 +1055,650 @@ describe('prepareSceneForExport', () => {
|
|
|
553
1055
|
expect(panel!.quaternion.angleTo(new THREE.Quaternion())).toBeLessThan(1e-4)
|
|
554
1056
|
}
|
|
555
1057
|
})
|
|
1058
|
+
|
|
1059
|
+
describe('requireSynchronousBake', () => {
|
|
1060
|
+
test('rejects a retained async-only kind instead of returning its proxy', () => {
|
|
1061
|
+
const restoreRegistry = nodeRegistry._snapshot()
|
|
1062
|
+
try {
|
|
1063
|
+
const kind = 'test:async-only-synchronous-export'
|
|
1064
|
+
const nodeId = 'async_only_synchronous_export'
|
|
1065
|
+
registerNode({
|
|
1066
|
+
kind,
|
|
1067
|
+
schemaVersion: 1,
|
|
1068
|
+
schema: DoorNode,
|
|
1069
|
+
category: 'furnish',
|
|
1070
|
+
defaults: () => ({}) as never,
|
|
1071
|
+
capabilities: {},
|
|
1072
|
+
bake: 'replace',
|
|
1073
|
+
bakeGeometryAsync: async () =>
|
|
1074
|
+
new THREE.Mesh(new THREE.BoxGeometry(4, 5, 6), new THREE.MeshStandardMaterial()),
|
|
1075
|
+
} as AnyNodeDefinition)
|
|
1076
|
+
|
|
1077
|
+
const proxyGeometry = new THREE.BoxGeometry(0.25, 0.5, 0.75)
|
|
1078
|
+
const proxyMaterial = new THREE.MeshStandardMaterial()
|
|
1079
|
+
let sourceGeometryDisposals = 0
|
|
1080
|
+
let sourceMaterialDisposals = 0
|
|
1081
|
+
proxyGeometry.addEventListener('dispose', () => {
|
|
1082
|
+
sourceGeometryDisposals += 1
|
|
1083
|
+
})
|
|
1084
|
+
proxyMaterial.addEventListener('dispose', () => {
|
|
1085
|
+
sourceMaterialDisposals += 1
|
|
1086
|
+
})
|
|
1087
|
+
const proxy = new THREE.Mesh(proxyGeometry, proxyMaterial)
|
|
1088
|
+
proxy.name = 'async-only-proxy'
|
|
1089
|
+
const source = new THREE.Group()
|
|
1090
|
+
source.add(proxy)
|
|
1091
|
+
const root = new THREE.Group()
|
|
1092
|
+
root.add(source)
|
|
1093
|
+
sceneRegistry.nodes.set(nodeId, source)
|
|
1094
|
+
const nodes = {
|
|
1095
|
+
[nodeId]: { id: nodeId, type: kind, visible: true },
|
|
1096
|
+
} as unknown as Record<string, AnyNode>
|
|
1097
|
+
|
|
1098
|
+
const compatible = prepareSceneForExport(root, nodes)
|
|
1099
|
+
expect(compatible.scene.getObjectByName('async-only-proxy')).toBeDefined()
|
|
1100
|
+
compatible.dispose()
|
|
1101
|
+
|
|
1102
|
+
let rejection: unknown
|
|
1103
|
+
try {
|
|
1104
|
+
prepareSceneForExport(root, nodes, { requireSynchronousBake: true })
|
|
1105
|
+
} catch (error) {
|
|
1106
|
+
rejection = error
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
expect(rejection).toBeInstanceOf(Error)
|
|
1110
|
+
const message = rejection instanceof Error ? rejection.message : ''
|
|
1111
|
+
expect(message).toContain(kind)
|
|
1112
|
+
expect(message).toMatch(/\bGLB\b/)
|
|
1113
|
+
expect(message).toMatch(/\bUSDZ\b/)
|
|
1114
|
+
expect(message).toMatch(/exclud/i)
|
|
1115
|
+
expect(sourceGeometryDisposals).toBe(0)
|
|
1116
|
+
expect(sourceMaterialDisposals).toBe(0)
|
|
1117
|
+
expect(root.children).toEqual([source])
|
|
1118
|
+
expect(source.children).toEqual([proxy])
|
|
1119
|
+
} finally {
|
|
1120
|
+
restoreRegistry()
|
|
1121
|
+
}
|
|
1122
|
+
})
|
|
1123
|
+
|
|
1124
|
+
test('allows a valid model when every async-only kind is pruned', async () => {
|
|
1125
|
+
const restoreRegistry = nodeRegistry._snapshot()
|
|
1126
|
+
const { installedPlugins, hasExplicitPluginInstallState } = useScene.getState()
|
|
1127
|
+
try {
|
|
1128
|
+
const excludedKind = 'test:excluded-synchronous-export'
|
|
1129
|
+
const hiddenKind = 'test:hidden-synchronous-export'
|
|
1130
|
+
const descendantKind = 'test:descendant-synchronous-export'
|
|
1131
|
+
const disabledKind = 'test:disabled-synchronous-export'
|
|
1132
|
+
const disabledPluginId = 'test:disabled-synchronous-export-plugin'
|
|
1133
|
+
const asyncOnlyDefinition = (kind: string): AnyNodeDefinition =>
|
|
1134
|
+
({
|
|
1135
|
+
kind,
|
|
1136
|
+
schemaVersion: 1,
|
|
1137
|
+
schema: DoorNode,
|
|
1138
|
+
category: 'furnish',
|
|
1139
|
+
defaults: () => ({}) as never,
|
|
1140
|
+
capabilities: {},
|
|
1141
|
+
bake: 'replace',
|
|
1142
|
+
bakeGeometryAsync: async () => {
|
|
1143
|
+
throw new Error(`Pruned async-only builder ran for ${kind}`)
|
|
1144
|
+
},
|
|
1145
|
+
}) as AnyNodeDefinition
|
|
1146
|
+
|
|
1147
|
+
for (const kind of [excludedKind, hiddenKind, descendantKind]) {
|
|
1148
|
+
registerNode(asyncOnlyDefinition(kind))
|
|
1149
|
+
}
|
|
1150
|
+
await loadPlugin({
|
|
1151
|
+
id: disabledPluginId,
|
|
1152
|
+
apiVersion: 1,
|
|
1153
|
+
nodes: [asyncOnlyDefinition(disabledKind)],
|
|
1154
|
+
})
|
|
1155
|
+
useScene.getState().setInstalledPlugins([], { explicit: true })
|
|
1156
|
+
|
|
1157
|
+
const valid = new THREE.Mesh(
|
|
1158
|
+
new THREE.BoxGeometry(2, 3, 4),
|
|
1159
|
+
new THREE.MeshStandardMaterial(),
|
|
1160
|
+
)
|
|
1161
|
+
valid.name = 'valid-model'
|
|
1162
|
+
const excluded = new THREE.Group()
|
|
1163
|
+
const hidden = new THREE.Group()
|
|
1164
|
+
const disabled = new THREE.Group()
|
|
1165
|
+
const excludedParent = new THREE.Group()
|
|
1166
|
+
const descendant = new THREE.Group()
|
|
1167
|
+
excludedParent.add(descendant)
|
|
1168
|
+
const root = new THREE.Group()
|
|
1169
|
+
root.add(valid, excluded, hidden, disabled, excludedParent)
|
|
1170
|
+
|
|
1171
|
+
const excludedId = 'excluded_async_only'
|
|
1172
|
+
const hiddenId = 'hidden_async_only'
|
|
1173
|
+
const disabledId = 'disabled_async_only'
|
|
1174
|
+
const excludedParentId = 'excluded_async_only_parent'
|
|
1175
|
+
const descendantId = 'descendant_async_only'
|
|
1176
|
+
sceneRegistry.nodes.set(excludedId, excluded)
|
|
1177
|
+
sceneRegistry.nodes.set(hiddenId, hidden)
|
|
1178
|
+
sceneRegistry.nodes.set(disabledId, disabled)
|
|
1179
|
+
sceneRegistry.nodes.set(excludedParentId, excludedParent)
|
|
1180
|
+
sceneRegistry.nodes.set(descendantId, descendant)
|
|
1181
|
+
const nodes = {
|
|
1182
|
+
[excludedId]: { id: excludedId, type: excludedKind, visible: true },
|
|
1183
|
+
[hiddenId]: { id: hiddenId, type: hiddenKind, visible: false },
|
|
1184
|
+
[disabledId]: { id: disabledId, type: disabledKind, visible: true },
|
|
1185
|
+
[excludedParentId]: {
|
|
1186
|
+
id: excludedParentId,
|
|
1187
|
+
type: 'test:excluded-synchronous-export-parent',
|
|
1188
|
+
visible: true,
|
|
1189
|
+
children: [descendantId],
|
|
1190
|
+
},
|
|
1191
|
+
[descendantId]: {
|
|
1192
|
+
id: descendantId,
|
|
1193
|
+
type: descendantKind,
|
|
1194
|
+
parentId: excludedParentId,
|
|
1195
|
+
visible: true,
|
|
1196
|
+
},
|
|
1197
|
+
} as unknown as Record<string, AnyNode>
|
|
1198
|
+
|
|
1199
|
+
const prepared = prepareSceneForExport(root, nodes, {
|
|
1200
|
+
excludedNodeTypes: [excludedKind, 'test:excluded-synchronous-export-parent'],
|
|
1201
|
+
requireSynchronousBake: true,
|
|
1202
|
+
})
|
|
1203
|
+
|
|
1204
|
+
const validModel = prepared.scene.getObjectByName('valid-model')
|
|
1205
|
+
expect(validModel).toBeDefined()
|
|
1206
|
+
const size = new THREE.Box3().setFromObject(validModel!).getSize(new THREE.Vector3())
|
|
1207
|
+
expect(size.toArray()).toEqual([2, 3, 4])
|
|
1208
|
+
for (const id of [excludedId, hiddenId, disabledId, excludedParentId, descendantId]) {
|
|
1209
|
+
expect(prepared.scene.getObjectByName(id)).toBeUndefined()
|
|
1210
|
+
}
|
|
1211
|
+
prepared.dispose()
|
|
1212
|
+
} finally {
|
|
1213
|
+
useScene
|
|
1214
|
+
.getState()
|
|
1215
|
+
.setInstalledPlugins(installedPlugins, { explicit: hasExplicitPluginInstallState })
|
|
1216
|
+
restoreRegistry()
|
|
1217
|
+
}
|
|
1218
|
+
})
|
|
1219
|
+
|
|
1220
|
+
test('uses synchronous geometry when a retained kind provides both hooks', () => {
|
|
1221
|
+
const restoreRegistry = nodeRegistry._snapshot()
|
|
1222
|
+
try {
|
|
1223
|
+
const kind = 'test:dual-hook-synchronous-export'
|
|
1224
|
+
const nodeId = 'dual_hook_synchronous_export'
|
|
1225
|
+
registerNode({
|
|
1226
|
+
kind,
|
|
1227
|
+
schemaVersion: 1,
|
|
1228
|
+
schema: DoorNode,
|
|
1229
|
+
category: 'furnish',
|
|
1230
|
+
defaults: () => ({}) as never,
|
|
1231
|
+
capabilities: {},
|
|
1232
|
+
bake: 'replace',
|
|
1233
|
+
bakeGeometry: () =>
|
|
1234
|
+
new THREE.Mesh(new THREE.BoxGeometry(2, 3, 4), new THREE.MeshStandardMaterial()),
|
|
1235
|
+
bakeGeometryAsync: async () =>
|
|
1236
|
+
new THREE.Mesh(new THREE.BoxGeometry(8, 8, 8), new THREE.MeshStandardMaterial()),
|
|
1237
|
+
} as AnyNodeDefinition)
|
|
1238
|
+
|
|
1239
|
+
const source = new THREE.Group()
|
|
1240
|
+
source.add(
|
|
1241
|
+
new THREE.Mesh(new THREE.BoxGeometry(0.25, 0.5, 0.75), new THREE.MeshStandardMaterial()),
|
|
1242
|
+
)
|
|
1243
|
+
const root = new THREE.Group()
|
|
1244
|
+
root.add(source)
|
|
1245
|
+
sceneRegistry.nodes.set(nodeId, source)
|
|
1246
|
+
const nodes = {
|
|
1247
|
+
[nodeId]: { id: nodeId, type: kind, visible: true },
|
|
1248
|
+
} as unknown as Record<string, AnyNode>
|
|
1249
|
+
|
|
1250
|
+
const prepared = prepareSceneForExport(root, nodes, {
|
|
1251
|
+
requireSynchronousBake: true,
|
|
1252
|
+
})
|
|
1253
|
+
|
|
1254
|
+
const exported = prepared.scene.getObjectByName(nodeId)
|
|
1255
|
+
expect(exported).toBeDefined()
|
|
1256
|
+
const size = new THREE.Box3().setFromObject(exported!).getSize(new THREE.Vector3())
|
|
1257
|
+
expect(size.toArray()).toEqual([2, 3, 4])
|
|
1258
|
+
expect(root.children).toEqual([source])
|
|
1259
|
+
prepared.dispose()
|
|
1260
|
+
} finally {
|
|
1261
|
+
restoreRegistry()
|
|
1262
|
+
}
|
|
1263
|
+
})
|
|
1264
|
+
})
|
|
1265
|
+
|
|
1266
|
+
test('awaits async bake geometry with full semantic context after selection', async () => {
|
|
1267
|
+
const restoreRegistry = nodeRegistry._snapshot()
|
|
1268
|
+
try {
|
|
1269
|
+
const retainedKind = 'test:async-context-bake'
|
|
1270
|
+
let asyncCalls = 0
|
|
1271
|
+
let syncCalls = 0
|
|
1272
|
+
registerNode({
|
|
1273
|
+
kind: retainedKind,
|
|
1274
|
+
schemaVersion: 1,
|
|
1275
|
+
schema: DoorNode,
|
|
1276
|
+
category: 'furnish',
|
|
1277
|
+
defaults: () => ({}) as never,
|
|
1278
|
+
capabilities: {},
|
|
1279
|
+
bake: 'replace',
|
|
1280
|
+
bakeGeometry: () => {
|
|
1281
|
+
syncCalls += 1
|
|
1282
|
+
throw new Error('sync builder must not run when async geometry is available')
|
|
1283
|
+
},
|
|
1284
|
+
bakeGeometryAsync: async (_node, context) => {
|
|
1285
|
+
asyncCalls += 1
|
|
1286
|
+
await Promise.resolve()
|
|
1287
|
+
const obstacle = context.resolve<{ metadata?: { acceptanceWidth?: number } }>(
|
|
1288
|
+
'door_obstacle_async_context',
|
|
1289
|
+
)
|
|
1290
|
+
return new THREE.Mesh(
|
|
1291
|
+
new THREE.BoxGeometry(obstacle?.metadata?.acceptanceWidth ?? 0, 1, 1),
|
|
1292
|
+
new THREE.MeshStandardMaterial(),
|
|
1293
|
+
)
|
|
1294
|
+
},
|
|
1295
|
+
} as AnyNodeDefinition)
|
|
1296
|
+
const root = new THREE.Group()
|
|
1297
|
+
const source = new THREE.Group()
|
|
1298
|
+
const obstacle = new THREE.Group()
|
|
1299
|
+
root.add(source, obstacle)
|
|
1300
|
+
sceneRegistry.nodes.set('async_context_node', source)
|
|
1301
|
+
sceneRegistry.nodes.set('door_obstacle_async_context', obstacle)
|
|
1302
|
+
const nodes = {
|
|
1303
|
+
async_context_node: {
|
|
1304
|
+
id: 'async_context_node',
|
|
1305
|
+
type: retainedKind,
|
|
1306
|
+
visible: true,
|
|
1307
|
+
},
|
|
1308
|
+
door_obstacle_async_context: {
|
|
1309
|
+
id: 'door_obstacle_async_context',
|
|
1310
|
+
type: 'test:excluded-obstacle',
|
|
1311
|
+
visible: true,
|
|
1312
|
+
metadata: { acceptanceWidth: 2 },
|
|
1313
|
+
},
|
|
1314
|
+
} as unknown as Record<string, AnyNode>
|
|
1315
|
+
|
|
1316
|
+
const prepared = await prepareSceneForExportAsync(root, nodes, {
|
|
1317
|
+
excludedNodeTypes: ['test:excluded-obstacle'],
|
|
1318
|
+
})
|
|
1319
|
+
const baked = prepared.scene.children.find((child) => child instanceof THREE.Mesh)
|
|
1320
|
+
expect(asyncCalls).toBe(1)
|
|
1321
|
+
expect(syncCalls).toBe(0)
|
|
1322
|
+
expect(baked).toBeInstanceOf(THREE.Mesh)
|
|
1323
|
+
if (baked instanceof THREE.Mesh) {
|
|
1324
|
+
baked.geometry.computeBoundingBox()
|
|
1325
|
+
expect(baked.geometry.boundingBox?.getSize(new THREE.Vector3()).x).toBeCloseTo(2)
|
|
1326
|
+
}
|
|
1327
|
+
expect(prepared.animations).toEqual([])
|
|
1328
|
+
expect(root.children).toEqual([source, obstacle])
|
|
1329
|
+
prepared.dispose()
|
|
1330
|
+
} finally {
|
|
1331
|
+
restoreRegistry()
|
|
1332
|
+
}
|
|
1333
|
+
})
|
|
1334
|
+
|
|
1335
|
+
test('does not invoke async builders for excluded or invisible nodes', async () => {
|
|
1336
|
+
const restoreRegistry = nodeRegistry._snapshot()
|
|
1337
|
+
try {
|
|
1338
|
+
let calls = 0
|
|
1339
|
+
for (const kind of ['test:excluded-async-bake', 'test:hidden-async-bake']) {
|
|
1340
|
+
registerNode({
|
|
1341
|
+
kind,
|
|
1342
|
+
schemaVersion: 1,
|
|
1343
|
+
schema: DoorNode,
|
|
1344
|
+
category: 'furnish',
|
|
1345
|
+
defaults: () => ({}) as never,
|
|
1346
|
+
capabilities: {},
|
|
1347
|
+
bake: 'replace',
|
|
1348
|
+
bakeGeometryAsync: async () => {
|
|
1349
|
+
calls += 1
|
|
1350
|
+
throw new Error('unselected async builder ran')
|
|
1351
|
+
},
|
|
1352
|
+
} as AnyNodeDefinition)
|
|
1353
|
+
}
|
|
1354
|
+
const root = new THREE.Group()
|
|
1355
|
+
const excluded = new THREE.Group()
|
|
1356
|
+
const hidden = new THREE.Group()
|
|
1357
|
+
const retained = meshWithNodeMaterial(nodeMaterial())
|
|
1358
|
+
root.add(excluded, hidden, retained)
|
|
1359
|
+
sceneRegistry.nodes.set('excluded_async_node', excluded)
|
|
1360
|
+
sceneRegistry.nodes.set('hidden_async_node', hidden)
|
|
1361
|
+
const nodes = {
|
|
1362
|
+
excluded_async_node: {
|
|
1363
|
+
id: 'excluded_async_node',
|
|
1364
|
+
type: 'test:excluded-async-bake',
|
|
1365
|
+
visible: true,
|
|
1366
|
+
},
|
|
1367
|
+
hidden_async_node: {
|
|
1368
|
+
id: 'hidden_async_node',
|
|
1369
|
+
type: 'test:hidden-async-bake',
|
|
1370
|
+
visible: false,
|
|
1371
|
+
},
|
|
1372
|
+
} as unknown as Record<string, AnyNode>
|
|
1373
|
+
|
|
1374
|
+
const prepared = await prepareSceneForExportAsync(root, nodes, {
|
|
1375
|
+
excludedNodeTypes: ['test:excluded-async-bake'],
|
|
1376
|
+
onlyVisible: true,
|
|
1377
|
+
})
|
|
1378
|
+
expect(calls).toBe(0)
|
|
1379
|
+
expect(prepared.scene.children).toHaveLength(1)
|
|
1380
|
+
prepared.dispose()
|
|
1381
|
+
} finally {
|
|
1382
|
+
restoreRegistry()
|
|
1383
|
+
}
|
|
1384
|
+
})
|
|
1385
|
+
|
|
1386
|
+
test('disposes only export-owned resources when a later async builder fails', async () => {
|
|
1387
|
+
const restoreRegistry = nodeRegistry._snapshot()
|
|
1388
|
+
try {
|
|
1389
|
+
const ownedGeometry = new THREE.BoxGeometry()
|
|
1390
|
+
const ownedMaterial = new THREE.MeshStandardMaterial()
|
|
1391
|
+
const sourceGeometry = new THREE.BoxGeometry()
|
|
1392
|
+
const sourceMaterial = new THREE.MeshStandardMaterial()
|
|
1393
|
+
let ownedGeometryDisposals = 0
|
|
1394
|
+
let ownedMaterialDisposals = 0
|
|
1395
|
+
let sourceGeometryDisposals = 0
|
|
1396
|
+
let sourceMaterialDisposals = 0
|
|
1397
|
+
ownedGeometry.addEventListener('dispose', () => {
|
|
1398
|
+
ownedGeometryDisposals += 1
|
|
1399
|
+
})
|
|
1400
|
+
ownedMaterial.addEventListener('dispose', () => {
|
|
1401
|
+
ownedMaterialDisposals += 1
|
|
1402
|
+
})
|
|
1403
|
+
sourceGeometry.addEventListener('dispose', () => {
|
|
1404
|
+
sourceGeometryDisposals += 1
|
|
1405
|
+
})
|
|
1406
|
+
sourceMaterial.addEventListener('dispose', () => {
|
|
1407
|
+
sourceMaterialDisposals += 1
|
|
1408
|
+
})
|
|
1409
|
+
registerNode({
|
|
1410
|
+
kind: 'test:first-owned-async-bake',
|
|
1411
|
+
schemaVersion: 1,
|
|
1412
|
+
schema: DoorNode,
|
|
1413
|
+
category: 'furnish',
|
|
1414
|
+
defaults: () => ({}) as never,
|
|
1415
|
+
capabilities: {},
|
|
1416
|
+
bake: 'replace',
|
|
1417
|
+
bakeGeometryAsync: async () => new THREE.Mesh(ownedGeometry, ownedMaterial),
|
|
1418
|
+
} as AnyNodeDefinition)
|
|
1419
|
+
registerNode({
|
|
1420
|
+
kind: 'test:later-failing-async-bake',
|
|
1421
|
+
schemaVersion: 1,
|
|
1422
|
+
schema: DoorNode,
|
|
1423
|
+
category: 'furnish',
|
|
1424
|
+
defaults: () => ({}) as never,
|
|
1425
|
+
capabilities: {},
|
|
1426
|
+
bake: 'replace',
|
|
1427
|
+
bakeGeometryAsync: async () => {
|
|
1428
|
+
throw new Error('acceptance injected async failure')
|
|
1429
|
+
},
|
|
1430
|
+
} as AnyNodeDefinition)
|
|
1431
|
+
const root = new THREE.Group()
|
|
1432
|
+
const first = new THREE.Group()
|
|
1433
|
+
const failing = new THREE.Group()
|
|
1434
|
+
const source = new THREE.Mesh(sourceGeometry, sourceMaterial)
|
|
1435
|
+
root.add(first, failing, source)
|
|
1436
|
+
sceneRegistry.nodes.set('first_owned_async_node', first)
|
|
1437
|
+
sceneRegistry.nodes.set('later_failing_async_node', failing)
|
|
1438
|
+
const nodes = {
|
|
1439
|
+
first_owned_async_node: {
|
|
1440
|
+
id: 'first_owned_async_node',
|
|
1441
|
+
type: 'test:first-owned-async-bake',
|
|
1442
|
+
visible: true,
|
|
1443
|
+
},
|
|
1444
|
+
later_failing_async_node: {
|
|
1445
|
+
id: 'later_failing_async_node',
|
|
1446
|
+
type: 'test:later-failing-async-bake',
|
|
1447
|
+
visible: true,
|
|
1448
|
+
},
|
|
1449
|
+
} as unknown as Record<string, AnyNode>
|
|
1450
|
+
|
|
1451
|
+
await expect(prepareSceneForExportAsync(root, nodes)).rejects.toThrow(
|
|
1452
|
+
'acceptance injected async failure',
|
|
1453
|
+
)
|
|
1454
|
+
expect(ownedGeometryDisposals).toBe(1)
|
|
1455
|
+
expect(ownedMaterialDisposals).toBe(1)
|
|
1456
|
+
expect(sourceGeometryDisposals).toBe(0)
|
|
1457
|
+
expect(sourceMaterialDisposals).toBe(0)
|
|
1458
|
+
expect(root.children).toEqual([first, failing, source])
|
|
1459
|
+
} finally {
|
|
1460
|
+
restoreRegistry()
|
|
1461
|
+
}
|
|
1462
|
+
})
|
|
1463
|
+
|
|
1464
|
+
test('includes a registered static presentation only when explicitly selected', async () => {
|
|
1465
|
+
const priorPresentations = viewerPresentationRegistry.getSnapshot()
|
|
1466
|
+
viewerPresentationRegistry.reset()
|
|
1467
|
+
try {
|
|
1468
|
+
const contribution: ViewerPresentationContribution = {
|
|
1469
|
+
id: 'test:static-export-presentation',
|
|
1470
|
+
component: async () => ({ default: () => null }),
|
|
1471
|
+
staticExport: {
|
|
1472
|
+
label: 'Acceptance surroundings',
|
|
1473
|
+
build: async ({ nodes, onlyVisible, excludedNodeTypes }) => {
|
|
1474
|
+
const semantic = nodes.semantic_context_node as
|
|
1475
|
+
| { metadata?: { acceptanceDimensions?: [number, number, number] } }
|
|
1476
|
+
| undefined
|
|
1477
|
+
const dimensions = semantic?.metadata?.acceptanceDimensions ?? [0, 0, 0]
|
|
1478
|
+
return new THREE.Mesh(
|
|
1479
|
+
new THREE.BoxGeometry(
|
|
1480
|
+
dimensions[0],
|
|
1481
|
+
onlyVisible ? 0 : dimensions[1],
|
|
1482
|
+
excludedNodeTypes.includes('test:excluded-context') ? dimensions[2] : 0,
|
|
1483
|
+
),
|
|
1484
|
+
new THREE.MeshStandardMaterial({ color: '#4a6b3d' }),
|
|
1485
|
+
)
|
|
1486
|
+
},
|
|
1487
|
+
},
|
|
1488
|
+
}
|
|
1489
|
+
viewerPresentationRegistry.register(contribution)
|
|
1490
|
+
const root = new THREE.Group()
|
|
1491
|
+
root.add(meshWithNodeMaterial(nodeMaterial()))
|
|
1492
|
+
const nodes = {
|
|
1493
|
+
semantic_context_node: {
|
|
1494
|
+
id: 'semantic_context_node',
|
|
1495
|
+
type: 'test:semantic-context',
|
|
1496
|
+
visible: true,
|
|
1497
|
+
metadata: { acceptanceDimensions: [3, 1, 2] },
|
|
1498
|
+
},
|
|
1499
|
+
} as unknown as Record<string, AnyNode>
|
|
1500
|
+
|
|
1501
|
+
const defaultArtifact = await prepareSceneForExportAsync(root, nodes, {
|
|
1502
|
+
onlyVisible: false,
|
|
1503
|
+
})
|
|
1504
|
+
expect(
|
|
1505
|
+
defaultArtifact.scene.getObjectByProperty('name', 'test:static-export-presentation'),
|
|
1506
|
+
).toBeUndefined()
|
|
1507
|
+
defaultArtifact.dispose()
|
|
1508
|
+
|
|
1509
|
+
const selectedArtifact = await prepareSceneForExportAsync(root, nodes, {
|
|
1510
|
+
excludedNodeTypes: ['test:excluded-context'],
|
|
1511
|
+
includedPresentationIds: [contribution.id],
|
|
1512
|
+
onlyVisible: false,
|
|
1513
|
+
})
|
|
1514
|
+
const presentation = selectedArtifact.scene.getObjectByProperty('name', contribution.id)
|
|
1515
|
+
expect(presentation?.userData).toMatchObject({
|
|
1516
|
+
label: 'Acceptance surroundings',
|
|
1517
|
+
pascalPresentationId: contribution.id,
|
|
1518
|
+
})
|
|
1519
|
+
const bounds = new THREE.Box3().setFromObject(presentation!)
|
|
1520
|
+
expect(bounds.getSize(new THREE.Vector3()).toArray()).toEqual([3, 1, 2])
|
|
1521
|
+
selectedArtifact.dispose()
|
|
1522
|
+
} finally {
|
|
1523
|
+
viewerPresentationRegistry.reset()
|
|
1524
|
+
for (const contribution of priorPresentations) {
|
|
1525
|
+
viewerPresentationRegistry.register(contribution)
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
})
|
|
1529
|
+
|
|
1530
|
+
test('preserves marked borrowed presentation textures and disposes owned maps', async () => {
|
|
1531
|
+
await withCanvasCapture(async (canvasPixels) => {
|
|
1532
|
+
const priorPresentations = viewerPresentationRegistry.getSnapshot()
|
|
1533
|
+
viewerPresentationRegistry.reset()
|
|
1534
|
+
const borrowed = new THREE.DataTexture(new Uint8Array([20, 40, 60, 255]), 1, 1)
|
|
1535
|
+
const owned = new THREE.DataTexture(new Uint8Array([80, 100, 120, 255]), 1, 1)
|
|
1536
|
+
const borrowedOnFailure = new THREE.DataTexture(new Uint8Array([140, 160, 180, 255]), 1, 1)
|
|
1537
|
+
const ownedOnFailure = new THREE.DataTexture(new Uint8Array([200, 220, 240, 255]), 1, 1)
|
|
1538
|
+
markViewerPresentationTextureBorrowed(borrowed)
|
|
1539
|
+
markViewerPresentationTextureBorrowed(borrowedOnFailure)
|
|
1540
|
+
const disposals = {
|
|
1541
|
+
borrowed: 0,
|
|
1542
|
+
owned: 0,
|
|
1543
|
+
borrowedOnFailure: 0,
|
|
1544
|
+
ownedOnFailure: 0,
|
|
1545
|
+
}
|
|
1546
|
+
for (const [texture, key] of [
|
|
1547
|
+
[borrowed, 'borrowed'],
|
|
1548
|
+
[owned, 'owned'],
|
|
1549
|
+
[borrowedOnFailure, 'borrowedOnFailure'],
|
|
1550
|
+
[ownedOnFailure, 'ownedOnFailure'],
|
|
1551
|
+
] as const) {
|
|
1552
|
+
texture.addEventListener('dispose', () => {
|
|
1553
|
+
disposals[key] += 1
|
|
1554
|
+
})
|
|
1555
|
+
}
|
|
1556
|
+
const textureContribution = (
|
|
1557
|
+
id: string,
|
|
1558
|
+
texture: THREE.Texture,
|
|
1559
|
+
): ViewerPresentationContribution => ({
|
|
1560
|
+
id,
|
|
1561
|
+
component: async () => ({ default: () => null }),
|
|
1562
|
+
staticExport: {
|
|
1563
|
+
label: id,
|
|
1564
|
+
build: () =>
|
|
1565
|
+
new THREE.Mesh(
|
|
1566
|
+
new THREE.PlaneGeometry(1, 1),
|
|
1567
|
+
new THREE.MeshStandardMaterial({ map: texture }),
|
|
1568
|
+
),
|
|
1569
|
+
},
|
|
1570
|
+
})
|
|
1571
|
+
try {
|
|
1572
|
+
viewerPresentationRegistry.register(
|
|
1573
|
+
textureContribution('test:borrowed-presentation-texture', borrowed),
|
|
1574
|
+
)
|
|
1575
|
+
viewerPresentationRegistry.register(
|
|
1576
|
+
textureContribution('test:owned-presentation-texture', owned),
|
|
1577
|
+
)
|
|
1578
|
+
const success = await prepareSceneForExportAsync(
|
|
1579
|
+
new THREE.Group(),
|
|
1580
|
+
{},
|
|
1581
|
+
{
|
|
1582
|
+
includedPresentationIds: [
|
|
1583
|
+
'test:borrowed-presentation-texture',
|
|
1584
|
+
'test:owned-presentation-texture',
|
|
1585
|
+
],
|
|
1586
|
+
},
|
|
1587
|
+
)
|
|
1588
|
+
const generatedTextures: THREE.Texture[] = []
|
|
1589
|
+
success.scene.traverse((object) => {
|
|
1590
|
+
if (!(object as THREE.Mesh).isMesh) return
|
|
1591
|
+
const mesh = object as THREE.Mesh
|
|
1592
|
+
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
|
|
1593
|
+
for (const material of materials) {
|
|
1594
|
+
const map = (material as THREE.MeshStandardMaterial).map
|
|
1595
|
+
if (map) generatedTextures.push(map)
|
|
1596
|
+
}
|
|
1597
|
+
})
|
|
1598
|
+
expect(generatedTextures).toHaveLength(2)
|
|
1599
|
+
expect(
|
|
1600
|
+
generatedTextures.every((texture) => (texture as THREE.CanvasTexture).isCanvasTexture),
|
|
1601
|
+
).toBe(true)
|
|
1602
|
+
expect(generatedTextures.map(canvasPixels).sort()).toEqual(
|
|
1603
|
+
[
|
|
1604
|
+
[20, 40, 60, 255],
|
|
1605
|
+
[80, 100, 120, 255],
|
|
1606
|
+
].sort(),
|
|
1607
|
+
)
|
|
1608
|
+
let generatedTextureDisposals = 0
|
|
1609
|
+
for (const texture of generatedTextures) {
|
|
1610
|
+
texture.addEventListener('dispose', () => {
|
|
1611
|
+
generatedTextureDisposals += 1
|
|
1612
|
+
})
|
|
1613
|
+
}
|
|
1614
|
+
success.dispose()
|
|
1615
|
+
expect(generatedTextureDisposals).toBe(2)
|
|
1616
|
+
expect(disposals).toMatchObject({ borrowed: 0, owned: 1 })
|
|
1617
|
+
expect(Array.from(borrowed.image.data as Uint8Array)).toEqual([20, 40, 60, 255])
|
|
1618
|
+
|
|
1619
|
+
viewerPresentationRegistry.register(
|
|
1620
|
+
textureContribution('test:borrowed-presentation-texture-failure', borrowedOnFailure),
|
|
1621
|
+
)
|
|
1622
|
+
viewerPresentationRegistry.register(
|
|
1623
|
+
textureContribution('test:owned-presentation-texture-failure', ownedOnFailure),
|
|
1624
|
+
)
|
|
1625
|
+
viewerPresentationRegistry.register({
|
|
1626
|
+
id: 'test:failing-presentation-after-textures',
|
|
1627
|
+
component: async () => ({ default: () => null }),
|
|
1628
|
+
staticExport: {
|
|
1629
|
+
label: 'Injected failure after texture ownership',
|
|
1630
|
+
build: () => {
|
|
1631
|
+
throw new Error('acceptance presentation texture failure')
|
|
1632
|
+
},
|
|
1633
|
+
},
|
|
1634
|
+
})
|
|
1635
|
+
await expect(
|
|
1636
|
+
prepareSceneForExportAsync(
|
|
1637
|
+
new THREE.Group(),
|
|
1638
|
+
{},
|
|
1639
|
+
{
|
|
1640
|
+
includedPresentationIds: [
|
|
1641
|
+
'test:borrowed-presentation-texture-failure',
|
|
1642
|
+
'test:owned-presentation-texture-failure',
|
|
1643
|
+
'test:failing-presentation-after-textures',
|
|
1644
|
+
],
|
|
1645
|
+
},
|
|
1646
|
+
),
|
|
1647
|
+
).rejects.toThrow('acceptance presentation texture failure')
|
|
1648
|
+
expect(disposals).toEqual({
|
|
1649
|
+
borrowed: 0,
|
|
1650
|
+
owned: 1,
|
|
1651
|
+
borrowedOnFailure: 0,
|
|
1652
|
+
ownedOnFailure: 1,
|
|
1653
|
+
})
|
|
1654
|
+
expect(Array.from(borrowedOnFailure.image.data as Uint8Array)).toEqual([140, 160, 180, 255])
|
|
1655
|
+
} finally {
|
|
1656
|
+
viewerPresentationRegistry.reset()
|
|
1657
|
+
for (const contribution of priorPresentations) {
|
|
1658
|
+
viewerPresentationRegistry.register(contribution)
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
})
|
|
1662
|
+
})
|
|
556
1663
|
})
|
|
1664
|
+
|
|
1665
|
+
async function withCanvasCapture(
|
|
1666
|
+
run: (pixels: (texture: THREE.Texture) => number[]) => Promise<void>,
|
|
1667
|
+
): Promise<void> {
|
|
1668
|
+
const globals = globalThis as unknown as { document?: Document }
|
|
1669
|
+
const previousDocument = globals.document
|
|
1670
|
+
const pixelsByCanvas = new WeakMap<HTMLCanvasElement, Uint8ClampedArray>()
|
|
1671
|
+
globals.document = {
|
|
1672
|
+
createElement: (tagName: string) => {
|
|
1673
|
+
if (tagName !== 'canvas') throw new Error(`Unexpected element request: ${tagName}`)
|
|
1674
|
+
const canvas = {
|
|
1675
|
+
width: 0,
|
|
1676
|
+
height: 0,
|
|
1677
|
+
getContext: () => ({
|
|
1678
|
+
createImageData: (width: number, height: number) =>
|
|
1679
|
+
({
|
|
1680
|
+
colorSpace: 'srgb',
|
|
1681
|
+
data: new Uint8ClampedArray(width * height * 4),
|
|
1682
|
+
height,
|
|
1683
|
+
width,
|
|
1684
|
+
}) as ImageData,
|
|
1685
|
+
putImageData: (image: ImageData) => {
|
|
1686
|
+
pixelsByCanvas.set(canvas, new Uint8ClampedArray(image.data))
|
|
1687
|
+
},
|
|
1688
|
+
}),
|
|
1689
|
+
} as unknown as HTMLCanvasElement
|
|
1690
|
+
return canvas
|
|
1691
|
+
},
|
|
1692
|
+
} as unknown as Document
|
|
1693
|
+
|
|
1694
|
+
try {
|
|
1695
|
+
await run((texture) => {
|
|
1696
|
+
const pixels = pixelsByCanvas.get(texture.image as HTMLCanvasElement)
|
|
1697
|
+
if (!pixels) throw new Error('Generated canvas texture has no captured pixels')
|
|
1698
|
+
return Array.from(pixels)
|
|
1699
|
+
})
|
|
1700
|
+
} finally {
|
|
1701
|
+
if (previousDocument) globals.document = previousDocument
|
|
1702
|
+
else delete globals.document
|
|
1703
|
+
}
|
|
1704
|
+
}
|