@pascal-app/editor 1.0.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -6
- package/src/components/editor/bake-exporter.tsx +4 -0
- package/src/components/editor/bake-thumbnail.tsx +4 -1
- package/src/components/editor/floating-action-menu.tsx +14 -1
- package/src/components/editor/floorplan-panel.tsx +23 -3
- package/src/components/editor/handles/handle-placement.test.ts +111 -0
- package/src/components/editor/handles/handle-placement.ts +69 -0
- package/src/components/editor/handles/linear-resize-drag.ts +13 -2
- package/src/components/editor/handles/use-handle-drag.ts +5 -2
- package/src/components/editor/index.tsx +12 -4
- package/src/components/editor/node-arrow-handles.tsx +21 -62
- package/src/components/editor/selection-manager.tsx +49 -0
- package/src/components/editor/thumbnail-generator.tsx +11 -2
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +46 -3
- package/src/components/procedural-items/inspector.tsx +223 -0
- package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
- package/src/components/tools/item/item-surface-placement.test.ts +175 -0
- package/src/components/tools/item/item-surface-strategy-resolver.test.ts +210 -0
- package/src/components/tools/item/placement-strategies.ts +169 -169
- package/src/components/tools/item/placement-types.ts +4 -0
- package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
- package/src/components/tools/item/use-draft-node.ts +95 -45
- package/src/components/tools/item/use-placement-coordinator.tsx +247 -106
- package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
- package/src/components/tools/registry/item-surface-move.test.ts +501 -0
- package/src/components/tools/registry/item-surface-move.ts +441 -0
- package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
- package/src/components/tools/registry/move-registry-node-tool.tsx +215 -70
- package/src/components/tools/shared/pointer-support-cap.ts +11 -9
- package/src/components/tools/shared/shelf-stickiness.ts +54 -0
- package/src/components/tools/shared/surface-hit.test.ts +56 -0
- package/src/components/tools/shared/surface-hit.ts +23 -0
- package/src/components/tools/shared/surface-rejection.test.tsx +106 -0
- package/src/components/tools/shared/surface-rejection.tsx +29 -0
- package/src/components/tools/zone/zone-tool.tsx +13 -4
- package/src/components/ui/command-palette/editor-commands.tsx +54 -1
- package/src/components/ui/controls/metric-control.tsx +29 -13
- package/src/components/ui/controls/slider-control.tsx +25 -18
- package/src/components/ui/floating-level-selector.tsx +81 -5
- package/src/components/ui/panels/multi-parametric-inspector.tsx +6 -3
- package/src/components/ui/panels/parametric-field-control.tsx +1 -1
- package/src/components/ui/panels/parametric-inspector.tsx +6 -2
- package/src/components/ui/panels/reference-panel.tsx +3 -3
- package/src/components/ui/primitives/checkbox.tsx +34 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +120 -3
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +31 -4
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +29 -10
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +106 -4
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +6 -1
- package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
- package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
- package/src/components/ui/snap-target-badge.tsx +2 -0
- package/src/components/viewer/viewer-scene-header.tsx +3 -0
- package/src/components/viewer/viewer-units-panel.tsx +116 -0
- package/src/hooks/use-ceiling-events.test.ts +24 -2
- package/src/hooks/use-ceiling-events.ts +5 -1
- package/src/hooks/use-keyboard.ts +3 -1
- package/src/index.tsx +5 -0
- package/src/lib/export-texture-utils.test.ts +167 -0
- package/src/lib/export-texture-utils.ts +121 -0
- package/src/lib/export-user-data.test.ts +48 -0
- package/src/lib/export-user-data.ts +69 -0
- package/src/lib/floorplan/floorplan-export.test.ts +139 -1
- package/src/lib/floorplan/floorplan-export.tsx +9 -3
- package/src/lib/floorplan/floorplan-readonly.ts +4 -0
- package/src/lib/fresh-planar-placement.ts +13 -5
- package/src/lib/glb-export-serialize.test.ts +280 -0
- package/src/lib/glb-export.test.ts +206 -0
- package/src/lib/glb-export.ts +171 -24
- package/src/lib/level-duplication.test.ts +73 -0
- package/src/lib/level-duplication.ts +32 -2
- package/src/lib/linear-display.test.ts +59 -0
- package/src/lib/linear-display.ts +39 -0
- package/src/lib/local-guide-image.test.ts +24 -0
- package/src/lib/local-guide-image.ts +34 -1
- package/src/lib/node-action-movement.ts +21 -0
- package/src/lib/portable-export.test.ts +89 -0
- package/src/lib/portable-export.ts +84 -11
- package/src/lib/print-shell-compiler-manifold-core.ts +59 -10
- package/src/lib/print-shell-compiler-manifold-worker.ts +14 -1
- package/src/lib/print-shell-compiler-manifold.worker.ts +1 -1
- package/src/lib/print-shell-compiler-protocol.ts +8 -0
- package/src/lib/scene-clipboard.ts +1 -1
- package/src/lib/send-to-app.test.ts +108 -0
- package/src/lib/send-to-app.ts +121 -0
- package/src/lib/sfx/movement-tick.ts +37 -0
- package/src/lib/surface-attachment.ts +77 -0
- package/src/lib/units.ts +254 -0
- package/src/lib/usdz-export.ts +43 -16
- package/src/lib/use-linear-display.ts +8 -32
- package/src/store/use-handle-group.ts +4 -0
- package/src/store/use-send-to-app.ts +21 -0
|
@@ -1,25 +1,30 @@
|
|
|
1
|
-
import { describe, expect, test } from 'bun:test'
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test'
|
|
2
2
|
import {
|
|
3
3
|
type AnyNode,
|
|
4
4
|
type AnyNodeDefinition,
|
|
5
|
+
BuildingNode,
|
|
5
6
|
type FloorplanGeometry,
|
|
6
7
|
type GeometryContext,
|
|
8
|
+
LevelNode,
|
|
7
9
|
loadPlugin,
|
|
8
10
|
type NodeCategory,
|
|
9
11
|
nodeRegistry,
|
|
10
12
|
registerNode,
|
|
11
13
|
} from '@pascal-app/core'
|
|
14
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
12
15
|
import PDFDocument from 'pdfkit'
|
|
13
16
|
import { z } from 'zod'
|
|
14
17
|
import { splitFloorplanOverlay } from '../../components/editor-2d/renderers/floorplan-registry-layer'
|
|
15
18
|
import { DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY } from './annotation-visibility'
|
|
16
19
|
import {
|
|
17
20
|
collectFloorplanGeometry,
|
|
21
|
+
collectFloorplanSchedules,
|
|
18
22
|
filterFloorplanExportOverlay,
|
|
19
23
|
fitPlanToBox,
|
|
20
24
|
isFloorplanExportAnnotationGeometry,
|
|
21
25
|
isFloorplanNodeInExportScope,
|
|
22
26
|
partitionFloorplanExportOverlay,
|
|
27
|
+
resolveExportLevels,
|
|
23
28
|
resolveFloorplanExportAnnotationVisibility,
|
|
24
29
|
resolveFloorplanExportNodeGeometry,
|
|
25
30
|
resolveFloorplanExportPlacement,
|
|
@@ -424,6 +429,81 @@ describe('isFloorplanNodeInExportScope', () => {
|
|
|
424
429
|
})
|
|
425
430
|
})
|
|
426
431
|
|
|
432
|
+
describe('collectFloorplanSchedules', () => {
|
|
433
|
+
test('omits non-structure schedule contributors under structure scope', () => {
|
|
434
|
+
const restoreRegistry = nodeRegistry._snapshot()
|
|
435
|
+
const structureKind = 'test:structure-schedule'
|
|
436
|
+
const siteKind = 'test:site-schedule'
|
|
437
|
+
const levelId = 'level_schedules' as AnyNode['id']
|
|
438
|
+
const structureNodeId = 'structure_scheduled' as AnyNode['id']
|
|
439
|
+
const siteNodeId = 'site_scheduled' as AnyNode['id']
|
|
440
|
+
|
|
441
|
+
const scheduleFor = (title: string) => ({
|
|
442
|
+
id: title.toLowerCase(),
|
|
443
|
+
title,
|
|
444
|
+
columns: [{ key: 'id', label: 'ID' }],
|
|
445
|
+
rows: [{ id: 'row', cells: { id: '1' } }],
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
try {
|
|
449
|
+
nodeRegistry._reset()
|
|
450
|
+
registerNode({
|
|
451
|
+
kind: structureKind,
|
|
452
|
+
schemaVersion: 1,
|
|
453
|
+
schema: z.object({ type: z.literal(structureKind) }) as never,
|
|
454
|
+
category: 'structure',
|
|
455
|
+
defaults: () => ({}) as never,
|
|
456
|
+
capabilities: {},
|
|
457
|
+
extensions: {
|
|
458
|
+
'pascal:editor/floorplan': {
|
|
459
|
+
schedule: () => scheduleFor('Doors'),
|
|
460
|
+
},
|
|
461
|
+
},
|
|
462
|
+
} as AnyNodeDefinition)
|
|
463
|
+
registerNode({
|
|
464
|
+
kind: siteKind,
|
|
465
|
+
schemaVersion: 1,
|
|
466
|
+
schema: z.object({ type: z.literal(siteKind) }) as never,
|
|
467
|
+
category: 'site',
|
|
468
|
+
defaults: () => ({}) as never,
|
|
469
|
+
capabilities: {},
|
|
470
|
+
extensions: {
|
|
471
|
+
'pascal:editor/floorplan': {
|
|
472
|
+
schedule: () => scheduleFor('Rooms'),
|
|
473
|
+
},
|
|
474
|
+
},
|
|
475
|
+
} as AnyNodeDefinition)
|
|
476
|
+
|
|
477
|
+
const nodes = {
|
|
478
|
+
[levelId]: {
|
|
479
|
+
id: levelId,
|
|
480
|
+
type: 'level',
|
|
481
|
+
visible: true,
|
|
482
|
+
children: [structureNodeId, siteNodeId],
|
|
483
|
+
},
|
|
484
|
+
[structureNodeId]: {
|
|
485
|
+
id: structureNodeId,
|
|
486
|
+
type: structureKind,
|
|
487
|
+
visible: true,
|
|
488
|
+
},
|
|
489
|
+
[siteNodeId]: {
|
|
490
|
+
id: siteNodeId,
|
|
491
|
+
type: siteKind,
|
|
492
|
+
visible: true,
|
|
493
|
+
},
|
|
494
|
+
} as unknown as Record<string, AnyNode>
|
|
495
|
+
|
|
496
|
+
const full = collectFloorplanSchedules(nodes, levelId, 'metric', 'full')
|
|
497
|
+
expect(full.map((schedule) => schedule.title).sort()).toEqual(['Doors', 'Rooms'])
|
|
498
|
+
|
|
499
|
+
const structure = collectFloorplanSchedules(nodes, levelId, 'metric', 'structure')
|
|
500
|
+
expect(structure.map((schedule) => schedule.title)).toEqual(['Doors'])
|
|
501
|
+
} finally {
|
|
502
|
+
restoreRegistry()
|
|
503
|
+
}
|
|
504
|
+
})
|
|
505
|
+
})
|
|
506
|
+
|
|
427
507
|
describe('collectFloorplanGeometry', () => {
|
|
428
508
|
test('collects the active Site once below architecture with semantic context and projection', async () => {
|
|
429
509
|
const restoreRegistry = nodeRegistry._snapshot()
|
|
@@ -713,3 +793,61 @@ describe('collectFloorplanGeometry', () => {
|
|
|
713
793
|
}
|
|
714
794
|
})
|
|
715
795
|
})
|
|
796
|
+
|
|
797
|
+
describe('resolveExportLevels', () => {
|
|
798
|
+
const ground = LevelNode.parse({ id: 'level_ground', parentId: 'building_a', level: 0 })
|
|
799
|
+
const upper = LevelNode.parse({ id: 'level_upper', parentId: 'building_a', level: 1 })
|
|
800
|
+
const roof = LevelNode.parse({
|
|
801
|
+
id: 'level_roof',
|
|
802
|
+
parentId: 'building_a',
|
|
803
|
+
level: 2,
|
|
804
|
+
metadata: { role: 'roof', referenceLevelId: upper.id },
|
|
805
|
+
})
|
|
806
|
+
const attic = LevelNode.parse({
|
|
807
|
+
id: 'level_attic',
|
|
808
|
+
parentId: 'building_a',
|
|
809
|
+
level: 3,
|
|
810
|
+
metadata: { role: 'attic' },
|
|
811
|
+
})
|
|
812
|
+
const building = BuildingNode.parse({
|
|
813
|
+
id: 'building_a',
|
|
814
|
+
children: [ground.id, upper.id, roof.id, attic.id],
|
|
815
|
+
})
|
|
816
|
+
const nodes: Record<string, AnyNode> = Object.fromEntries(
|
|
817
|
+
[building, ground, upper, roof, attic].map((node) => [node.id, node]),
|
|
818
|
+
)
|
|
819
|
+
|
|
820
|
+
// The viewer store is a process-wide singleton, so an earlier test file can
|
|
821
|
+
// leak a selection into these tests; restore it instead of leaving ours.
|
|
822
|
+
const previousSelection = useViewer.getState().selection
|
|
823
|
+
|
|
824
|
+
const selectLevel = (levelId: string | null) => {
|
|
825
|
+
useViewer.setState({
|
|
826
|
+
selection: { ...previousSelection, buildingId: building.id, levelId },
|
|
827
|
+
} as never)
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
afterEach(() => {
|
|
831
|
+
useViewer.setState({ selection: previousSelection } as never)
|
|
832
|
+
})
|
|
833
|
+
|
|
834
|
+
test('skips a dedicated roof support level', () => {
|
|
835
|
+
selectLevel(ground.id)
|
|
836
|
+
|
|
837
|
+
expect(resolveExportLevels(nodes)).toEqual([
|
|
838
|
+
{ id: ground.id, label: 'Level 0' },
|
|
839
|
+
{ id: upper.id, label: 'Level 1' },
|
|
840
|
+
{ id: attic.id, label: 'Level 3' },
|
|
841
|
+
])
|
|
842
|
+
})
|
|
843
|
+
|
|
844
|
+
test('skips the roof level when it is the selected level', () => {
|
|
845
|
+
selectLevel(roof.id)
|
|
846
|
+
|
|
847
|
+
expect(resolveExportLevels(nodes)).toEqual([
|
|
848
|
+
{ id: ground.id, label: 'Level 0' },
|
|
849
|
+
{ id: upper.id, label: 'Level 1' },
|
|
850
|
+
{ id: attic.id, label: 'Level 3' },
|
|
851
|
+
])
|
|
852
|
+
})
|
|
853
|
+
})
|
|
@@ -219,7 +219,7 @@ export async function exportFloorplanPdf(scope: FloorplanExportScope): Promise<v
|
|
|
219
219
|
wallDimensionReference,
|
|
220
220
|
installedPlugins,
|
|
221
221
|
)
|
|
222
|
-
const schedules = collectFloorplanSchedules(nodes, level.id, unit)
|
|
222
|
+
const schedules = collectFloorplanSchedules(nodes, level.id, unit, scope)
|
|
223
223
|
if (geometries.length === 0 && schedules.length === 0) continue
|
|
224
224
|
const layout = resolveFloorplanPageLayout(A4_LANDSCAPE_WIDTH_PT, A4_LANDSCAPE_HEIGHT_PT)
|
|
225
225
|
|
|
@@ -305,6 +305,7 @@ export function collectFloorplanSchedules(
|
|
|
305
305
|
nodes: Record<string, AnyNode>,
|
|
306
306
|
levelId: AnyNodeId,
|
|
307
307
|
unit: 'metric' | 'imperial',
|
|
308
|
+
scope: FloorplanExportScope = 'full',
|
|
308
309
|
): FloorplanSchedule[] {
|
|
309
310
|
const siblingsByType = new Map<string, AnyNode[]>()
|
|
310
311
|
const visit = (id: AnyNodeId) => {
|
|
@@ -324,6 +325,9 @@ export function collectFloorplanSchedules(
|
|
|
324
325
|
for (const [kind, definition] of nodeRegistry.entries()) {
|
|
325
326
|
const scheduleContribution = getFloorplanNodeExtension(definition)?.schedule
|
|
326
327
|
if (!scheduleContribution) continue
|
|
328
|
+
// Same scope gate as geometry: a structure-only PDF must not list rooms
|
|
329
|
+
// or other site-category contributors whose plan geometry was excluded.
|
|
330
|
+
if (!isFloorplanNodeInExportScope(definition, scope)) continue
|
|
327
331
|
const siblings = siblingsByType.get(kind) ?? []
|
|
328
332
|
const schedule = scheduleContribution({ siblings, nodes, levelId, unit })
|
|
329
333
|
if (schedule && schedule.rows.length > 0) schedules.push(schedule)
|
|
@@ -1021,9 +1025,10 @@ function combineGeometryList(
|
|
|
1021
1025
|
* Levels to export, ordered bottom-to-top. The active building (the building
|
|
1022
1026
|
* owning the selected level, or the first one found) contributes all of its
|
|
1023
1027
|
* level children; if there is no building wrapper we fall back to the single
|
|
1024
|
-
* resolved level.
|
|
1028
|
+
* resolved level. Roof support levels are excluded: they are not occupied
|
|
1029
|
+
* stories, so they do not get a floor-plan page.
|
|
1025
1030
|
*/
|
|
1026
|
-
function resolveExportLevels(nodes: Record<string, AnyNode>): ExportLevel[] {
|
|
1031
|
+
export function resolveExportLevels(nodes: Record<string, AnyNode>): ExportLevel[] {
|
|
1027
1032
|
const selected = useViewer.getState().selection.levelId as AnyNodeId | null | undefined
|
|
1028
1033
|
const activeLevelId = selected && nodes[selected] ? selected : firstLevelId(nodes)
|
|
1029
1034
|
if (!activeLevelId) return []
|
|
@@ -1038,6 +1043,7 @@ function resolveExportLevels(nodes: Record<string, AnyNode>): ExportLevel[] {
|
|
|
1038
1043
|
levelNodes = node ? [node] : []
|
|
1039
1044
|
}
|
|
1040
1045
|
|
|
1046
|
+
levelNodes = levelNodes.filter((n) => n.metadata.role !== 'roof')
|
|
1041
1047
|
levelNodes.sort((a, b) => levelIndexOf(a) - levelIndexOf(b))
|
|
1042
1048
|
return levelNodes.map((n) => ({ id: n.id as AnyNodeId, label: levelLabelOf(n) }))
|
|
1043
1049
|
}
|
|
@@ -14,6 +14,8 @@ export type FloorplanViewState = {
|
|
|
14
14
|
highlighted: boolean
|
|
15
15
|
hovered: boolean
|
|
16
16
|
moving: boolean
|
|
17
|
+
focusedUnitId?: string
|
|
18
|
+
focusedUnitMemberIds?: readonly string[]
|
|
17
19
|
palette: FloorplanPalette | undefined
|
|
18
20
|
}
|
|
19
21
|
|
|
@@ -66,6 +68,8 @@ export function buildFloorplanContext(
|
|
|
66
68
|
highlighted: viewState.highlighted,
|
|
67
69
|
hovered: viewState.hovered,
|
|
68
70
|
moving: viewState.moving,
|
|
71
|
+
focusedUnitId: viewState.focusedUnitId,
|
|
72
|
+
focusedUnitMemberIds: viewState.focusedUnitMemberIds,
|
|
69
73
|
palette: viewState.palette,
|
|
70
74
|
}
|
|
71
75
|
: undefined,
|
|
@@ -8,6 +8,11 @@ import {
|
|
|
8
8
|
useScene,
|
|
9
9
|
} from '@pascal-app/core'
|
|
10
10
|
import { getPlacementMetadataRecord, stripPlacementMetadataFlags } from './placement-metadata'
|
|
11
|
+
import {
|
|
12
|
+
surfaceAttachmentId,
|
|
13
|
+
surfaceAttachmentUpdates,
|
|
14
|
+
updateSurfaceNode,
|
|
15
|
+
} from './surface-attachment'
|
|
11
16
|
|
|
12
17
|
function cleanPlacementMetadata<N extends AnyNode>(node: N): N {
|
|
13
18
|
return {
|
|
@@ -131,16 +136,19 @@ export function commitFreshPlacementSubtree(
|
|
|
131
136
|
parentId,
|
|
132
137
|
})
|
|
133
138
|
|
|
139
|
+
const surfaceId = surfaceAttachmentId(subtree.root)
|
|
134
140
|
const temporal = useScene.temporal.getState()
|
|
135
141
|
const wasTracking = (temporal as { isTracking?: boolean }).isTracking !== false
|
|
136
142
|
if (wasTracking) temporal.pause()
|
|
143
|
+
updateSurfaceNode(rootId, {}, null)
|
|
137
144
|
useScene.getState().deleteNode(rootId)
|
|
138
145
|
temporal.resume()
|
|
139
|
-
useScene
|
|
140
|
-
.
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
)
|
|
146
|
+
useScene.getState().applyNodeChanges({
|
|
147
|
+
create: cloned.nodes.map((node, index) =>
|
|
148
|
+
index === 0 && parentId ? { node, parentId } : { node },
|
|
149
|
+
),
|
|
150
|
+
update: surfaceAttachmentUpdates(cloned.rootId, parentId, surfaceId),
|
|
151
|
+
})
|
|
144
152
|
if (!wasTracking) temporal.pause()
|
|
145
153
|
|
|
146
154
|
return cloned.rootId
|
|
@@ -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
|
+
})
|