@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
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
prepareSceneForExportAsync,
|
|
28
28
|
writeTextureReferenceExtras,
|
|
29
29
|
} from './glb-export'
|
|
30
|
+
import { createUsdzScene } from './portable-export'
|
|
30
31
|
|
|
31
32
|
// The reference module reads the storage origin lazily on first use, so
|
|
32
33
|
// setting the env here (before any validation call) pins it for the file.
|
|
@@ -99,6 +100,29 @@ describe('prepareSceneForExport', () => {
|
|
|
99
100
|
expect(material.color.getHexString()).toBe('cc3300')
|
|
100
101
|
})
|
|
101
102
|
|
|
103
|
+
test('clones userData that holds runtime resources and functions', () => {
|
|
104
|
+
const root = new THREE.Group()
|
|
105
|
+
root.name = 'scene-renderer'
|
|
106
|
+
const effectMaterial = new THREE.MeshStandardMaterial()
|
|
107
|
+
effectMaterial.addEventListener('dispose', () => {})
|
|
108
|
+
const pool = meshWithNodeMaterial(nodeMaterial())
|
|
109
|
+
pool.userData = { waterEffect: { material: effectMaterial, onFrame: () => {} } }
|
|
110
|
+
const overlay = meshWithNodeMaterial(nodeMaterial())
|
|
111
|
+
overlay.userData = { pascalExport: 'strip', mesh: pool }
|
|
112
|
+
root.add(pool, overlay)
|
|
113
|
+
expect(() => structuredClone(pool.userData)).toThrow()
|
|
114
|
+
|
|
115
|
+
const { scene } = prepareSceneForExport(root, {})
|
|
116
|
+
|
|
117
|
+
const meshes: THREE.Mesh[] = []
|
|
118
|
+
scene.traverse((object) => {
|
|
119
|
+
if ((object as THREE.Mesh).isMesh) meshes.push(object as THREE.Mesh)
|
|
120
|
+
})
|
|
121
|
+
expect(meshes).toHaveLength(1)
|
|
122
|
+
expect(meshes[0]?.userData.waterEffect).toBeUndefined()
|
|
123
|
+
expect(pool.userData.waterEffect.material).toBe(effectMaterial)
|
|
124
|
+
})
|
|
125
|
+
|
|
102
126
|
test('replaces only the cloned registered subtree with bake-only geometry', () => {
|
|
103
127
|
const restoreRegistry = nodeRegistry._snapshot()
|
|
104
128
|
try {
|
|
@@ -1685,6 +1709,21 @@ async function withCanvasCapture(
|
|
|
1685
1709
|
putImageData: (image: ImageData) => {
|
|
1686
1710
|
pixelsByCanvas.set(canvas, new Uint8ClampedArray(image.data))
|
|
1687
1711
|
},
|
|
1712
|
+
fillRect: (_x: number, _y: number, width: number, height: number) => {
|
|
1713
|
+
pixelsByCanvas.set(canvas, new Uint8ClampedArray(width * height * 4).fill(255))
|
|
1714
|
+
},
|
|
1715
|
+
drawImage: (image: HTMLCanvasElement) => {
|
|
1716
|
+
const pixels = pixelsByCanvas.get(image)
|
|
1717
|
+
if (!pixels) throw new Error('Source canvas has no captured pixels')
|
|
1718
|
+
pixelsByCanvas.set(canvas, new Uint8ClampedArray(pixels))
|
|
1719
|
+
},
|
|
1720
|
+
getImageData: (_x: number, _y: number, width: number, height: number) =>
|
|
1721
|
+
({
|
|
1722
|
+
colorSpace: 'srgb',
|
|
1723
|
+
data: pixelsByCanvas.get(canvas) ?? new Uint8ClampedArray(width * height * 4),
|
|
1724
|
+
height,
|
|
1725
|
+
width,
|
|
1726
|
+
}) as ImageData,
|
|
1688
1727
|
}),
|
|
1689
1728
|
} as unknown as HTMLCanvasElement
|
|
1690
1729
|
return canvas
|
|
@@ -1702,3 +1741,170 @@ async function withCanvasCapture(
|
|
|
1702
1741
|
else delete globals.document
|
|
1703
1742
|
}
|
|
1704
1743
|
}
|
|
1744
|
+
|
|
1745
|
+
describe('normal maps in async export preparation', () => {
|
|
1746
|
+
test('decompresses a compressed normal map through the provided decompressor before baking its scale', async () => {
|
|
1747
|
+
await withCanvasCapture(async () => {
|
|
1748
|
+
const root = new THREE.Group()
|
|
1749
|
+
const compressed = new THREE.CompressedTexture([], 2, 2)
|
|
1750
|
+
compressed.name = 'NormalGL_3a132c5f'
|
|
1751
|
+
const material = nodeMaterial({ normalMap: compressed }) as THREE.MeshStandardMaterial
|
|
1752
|
+
material.normalScale.set(0.5, 0.5)
|
|
1753
|
+
root.add(meshWithNodeMaterial(material))
|
|
1754
|
+
let calls = 0
|
|
1755
|
+
|
|
1756
|
+
const prepared = await prepareSceneForExportAsync(
|
|
1757
|
+
root,
|
|
1758
|
+
{},
|
|
1759
|
+
{
|
|
1760
|
+
decompressTexture: async (texture) => {
|
|
1761
|
+
calls += 1
|
|
1762
|
+
expect(texture.name).toBe('NormalGL_3a132c5f')
|
|
1763
|
+
return new THREE.DataTexture(
|
|
1764
|
+
new Uint8Array(
|
|
1765
|
+
[128, 128, 255, 255].concat(
|
|
1766
|
+
[128, 128, 255, 255],
|
|
1767
|
+
[128, 128, 255, 255],
|
|
1768
|
+
[128, 128, 255, 255],
|
|
1769
|
+
),
|
|
1770
|
+
),
|
|
1771
|
+
2,
|
|
1772
|
+
2,
|
|
1773
|
+
)
|
|
1774
|
+
},
|
|
1775
|
+
},
|
|
1776
|
+
)
|
|
1777
|
+
|
|
1778
|
+
const exported = (prepared.scene.children[0] as THREE.Mesh)
|
|
1779
|
+
.material as THREE.MeshStandardMaterial
|
|
1780
|
+
expect(calls).toBe(1)
|
|
1781
|
+
expect((exported.normalMap as { isCanvasTexture?: boolean }).isCanvasTexture).toBe(true)
|
|
1782
|
+
expect(exported.normalScale.toArray()).toEqual([1, 1])
|
|
1783
|
+
expect(material.normalMap).toBe(compressed)
|
|
1784
|
+
expect(material.normalScale.toArray()).toEqual([0.5, 0.5])
|
|
1785
|
+
})
|
|
1786
|
+
})
|
|
1787
|
+
|
|
1788
|
+
test('reference mode keeps a stamped normal-map placeholder and its scale for the packer', async () => {
|
|
1789
|
+
await withCanvasCapture(async () => {
|
|
1790
|
+
const root = new THREE.Group()
|
|
1791
|
+
const stamped = new THREE.CompressedTexture([], 4, 4)
|
|
1792
|
+
stamped.userData.pascalTextureRef = {
|
|
1793
|
+
v: 1,
|
|
1794
|
+
kind: 'library-material',
|
|
1795
|
+
src: `${STORAGE_ORIGIN}/storage/v1/object/public/materials/user/material/oak_normal_512.ktx2`,
|
|
1796
|
+
map: 'normal',
|
|
1797
|
+
colorSpace: 'linear',
|
|
1798
|
+
}
|
|
1799
|
+
const material = nodeMaterial({ normalMap: stamped }) as THREE.MeshStandardMaterial
|
|
1800
|
+
material.normalScale.set(1, -1)
|
|
1801
|
+
root.add(meshWithNodeMaterial(material))
|
|
1802
|
+
|
|
1803
|
+
const prepared = await prepareSceneForExportAsync(
|
|
1804
|
+
root,
|
|
1805
|
+
{},
|
|
1806
|
+
{
|
|
1807
|
+
textures: 'reference',
|
|
1808
|
+
purpose: 'viewer',
|
|
1809
|
+
decompressTexture: async () => {
|
|
1810
|
+
throw new Error('by-reference placeholders must not be decompressed')
|
|
1811
|
+
},
|
|
1812
|
+
},
|
|
1813
|
+
)
|
|
1814
|
+
|
|
1815
|
+
const exported = (prepared.scene.children[0] as THREE.Mesh)
|
|
1816
|
+
.material as THREE.MeshStandardMaterial
|
|
1817
|
+
expect(exported.normalMap?.userData.pascalTextureRef).toEqual(
|
|
1818
|
+
stamped.userData.pascalTextureRef,
|
|
1819
|
+
)
|
|
1820
|
+
expect(exported.normalScale.toArray()).toEqual([1, -1])
|
|
1821
|
+
})
|
|
1822
|
+
})
|
|
1823
|
+
})
|
|
1824
|
+
|
|
1825
|
+
describe('portable glass', () => {
|
|
1826
|
+
test('see-through untextured surfaces become transmission glass in portable exports only', async () => {
|
|
1827
|
+
const root = new THREE.Group()
|
|
1828
|
+
const glass = new MeshStandardNodeMaterial({
|
|
1829
|
+
color: '#3d9ed4',
|
|
1830
|
+
transparent: true,
|
|
1831
|
+
opacity: 0.3,
|
|
1832
|
+
roughness: 0.1,
|
|
1833
|
+
})
|
|
1834
|
+
const tintedPlastic = new MeshStandardNodeMaterial({
|
|
1835
|
+
color: '#3d9ed4',
|
|
1836
|
+
transparent: true,
|
|
1837
|
+
opacity: 0.8,
|
|
1838
|
+
})
|
|
1839
|
+
root.add(meshWithNodeMaterial(glass), meshWithNodeMaterial(tintedPlastic))
|
|
1840
|
+
|
|
1841
|
+
const portable = await prepareSceneForExportAsync(root, {})
|
|
1842
|
+
const [exportedGlass, exportedPlastic] = portable.scene.children.map(
|
|
1843
|
+
(child) => (child as THREE.Mesh).material as THREE.MeshPhysicalMaterial,
|
|
1844
|
+
)
|
|
1845
|
+
expect(exportedGlass!.isMeshPhysicalMaterial).toBe(true)
|
|
1846
|
+
expect(exportedGlass!.transmission).toBe(1)
|
|
1847
|
+
expect(exportedGlass!.transparent).toBe(false)
|
|
1848
|
+
expect(exportedGlass!.opacity).toBe(1)
|
|
1849
|
+
expect(exportedGlass!.roughness).toBeCloseTo(0.1)
|
|
1850
|
+
// 30% authored opacity keeps 30% of the (linear) tint.
|
|
1851
|
+
const tint = new THREE.Color('#3d9ed4')
|
|
1852
|
+
expect(exportedGlass!.color.r).toBeCloseTo(tint.r + (1 - tint.r) * 0.7, 4)
|
|
1853
|
+
expect(exportedGlass!.color.b).toBeCloseTo(tint.b + (1 - tint.b) * 0.7, 4)
|
|
1854
|
+
expect(exportedPlastic!.isMeshPhysicalMaterial).toBeUndefined()
|
|
1855
|
+
expect(exportedPlastic!.transparent).toBe(true)
|
|
1856
|
+
|
|
1857
|
+
const viewer = prepareSceneForExport(root, {}, { purpose: 'viewer' })
|
|
1858
|
+
const kept = (viewer.scene.children[0] as THREE.Mesh).material as THREE.MeshStandardMaterial
|
|
1859
|
+
expect((kept as { isMeshPhysicalMaterial?: boolean }).isMeshPhysicalMaterial).toBeUndefined()
|
|
1860
|
+
expect(kept.transparent).toBe(true)
|
|
1861
|
+
expect(kept.opacity).toBeCloseTo(0.3)
|
|
1862
|
+
|
|
1863
|
+
// USDZ has no transmission: the same prep hands glass its opacity back.
|
|
1864
|
+
const usdz = createUsdzScene(portable.scene)
|
|
1865
|
+
const usdzGlass = (usdz.children[0] as THREE.Mesh).material as THREE.MeshPhysicalMaterial
|
|
1866
|
+
expect(usdzGlass.transmission).toBe(0)
|
|
1867
|
+
expect(usdzGlass.transparent).toBe(true)
|
|
1868
|
+
expect(usdzGlass.opacity).toBeCloseTo(0.3)
|
|
1869
|
+
expect(exportedGlass!.transmission).toBe(1)
|
|
1870
|
+
})
|
|
1871
|
+
})
|
|
1872
|
+
|
|
1873
|
+
describe('portable clips', () => {
|
|
1874
|
+
function swingDoorScene() {
|
|
1875
|
+
const root = new THREE.Group()
|
|
1876
|
+
const doorGroup = new THREE.Group()
|
|
1877
|
+
const leaf = new THREE.Group()
|
|
1878
|
+
leaf.userData.pascalSwingLeaf = { axis: 'y', openRotationY: Math.PI / 2 }
|
|
1879
|
+
leaf.add(meshWithNodeMaterial(nodeMaterial()))
|
|
1880
|
+
doorGroup.add(leaf)
|
|
1881
|
+
root.add(doorGroup)
|
|
1882
|
+
const doorId = 'door_portable'
|
|
1883
|
+
sceneRegistry.nodes.set(doorId, doorGroup)
|
|
1884
|
+
const nodes: Record<string, AnyNode> = {
|
|
1885
|
+
[doorId]: { object: 'node', id: doorId, type: 'door', name: 'Door' } as unknown as AnyNode,
|
|
1886
|
+
}
|
|
1887
|
+
return { root, nodes, doorId }
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
test('GLB downloads keep door clips when asked, USDZ passes none', async () => {
|
|
1891
|
+
const { root, nodes, doorId } = swingDoorScene()
|
|
1892
|
+
|
|
1893
|
+
const glb = await prepareSceneForExportAsync(root, nodes, { animations: 'keep' })
|
|
1894
|
+
expect(glb.animations).toHaveLength(1)
|
|
1895
|
+
expect(glb.animations[0]!.name).toBe(`${doorId}: open`)
|
|
1896
|
+
const track = glb.animations[0]!.tracks[0]!
|
|
1897
|
+
const targetUuid = track.name.split('.')[0]
|
|
1898
|
+
expect(glb.scene.getObjectByProperty('uuid', targetUuid)).toBeDefined()
|
|
1899
|
+
expect(glb.scene.getObjectByProperty('name', doorId)?.userData.clips).toEqual([
|
|
1900
|
+
`${doorId}: open`,
|
|
1901
|
+
])
|
|
1902
|
+
|
|
1903
|
+
const usdz = await prepareSceneForExportAsync(root, nodes, { animations: 'none' })
|
|
1904
|
+
expect(usdz.animations).toEqual([])
|
|
1905
|
+
expect(usdz.scene.getObjectByProperty('name', doorId)?.userData.clips).toBeUndefined()
|
|
1906
|
+
|
|
1907
|
+
const legacy = await prepareSceneForExportAsync(root, nodes)
|
|
1908
|
+
expect(legacy.animations).toEqual([])
|
|
1909
|
+
})
|
|
1910
|
+
})
|
package/src/lib/glb-export.ts
CHANGED
|
@@ -35,9 +35,13 @@ import {
|
|
|
35
35
|
type GLTFExporterPlugin,
|
|
36
36
|
type GLTFWriter,
|
|
37
37
|
} from 'three/examples/jsm/exporters/GLTFExporter.js'
|
|
38
|
-
import
|
|
38
|
+
import { createExportTextureUtils, type ExportTextureUtils } from './export-texture-utils'
|
|
39
|
+
import { cloneExportUserData } from './export-user-data'
|
|
39
40
|
import {
|
|
41
|
+
type CompressedTextureDecompressor,
|
|
42
|
+
decompressCanonicalNormalMaps,
|
|
40
43
|
disposeExportResources,
|
|
44
|
+
GLASS_OPACITY_USERDATA,
|
|
41
45
|
normalizePortableScene,
|
|
42
46
|
normalizeViewerArtifactMaterials,
|
|
43
47
|
} from './portable-export'
|
|
@@ -69,12 +73,61 @@ export type GlbExportOptions = {
|
|
|
69
73
|
excludedNodeTypes?: readonly string[]
|
|
70
74
|
/** Selected static viewer-presentation contributions; omitted means none. */
|
|
71
75
|
includedPresentationIds?: readonly string[]
|
|
72
|
-
/** Portable
|
|
76
|
+
/** Portable materials/geometry normalisation vs the baked viewer artifact. */
|
|
73
77
|
purpose?: 'portable' | 'viewer'
|
|
78
|
+
/**
|
|
79
|
+
* Door/window open clips. Defaults to `keep` for the viewer bake and for GLB
|
|
80
|
+
* downloads (Blender turns them into actions); USDZ and print pass `none`
|
|
81
|
+
* because those formats freeze geometry and cannot play them.
|
|
82
|
+
*/
|
|
83
|
+
animations?: 'keep' | 'none'
|
|
74
84
|
/** Called for actual lossy portable conversions discovered during preparation. */
|
|
75
85
|
onWarning?: (warning: string) => void
|
|
76
86
|
/** Reject retained node kinds whose export geometry can only be baked asynchronously. */
|
|
77
87
|
requireSynchronousBake?: boolean
|
|
88
|
+
/** GPU decompressor for compressed normal maps that must be baked; defaults to `textureUtils`. */
|
|
89
|
+
decompressTexture?: CompressedTextureDecompressor
|
|
90
|
+
/**
|
|
91
|
+
* Shared GPU decompressor for every compressed texture in the export. The
|
|
92
|
+
* export entry points create one per export and dispose it; supplying your
|
|
93
|
+
* own keeps ownership with you.
|
|
94
|
+
*/
|
|
95
|
+
textureUtils?: ExportTextureUtils
|
|
96
|
+
/**
|
|
97
|
+
* Wall-clock deadline for the whole export (preparation and serialisation),
|
|
98
|
+
* after which it rejects instead of leaving the caller waiting. Defaults to
|
|
99
|
+
* `DEFAULT_MODEL_EXPORT_TIMEOUT_MS`; `Infinity` disables it.
|
|
100
|
+
*/
|
|
101
|
+
timeoutMs?: number
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export const DEFAULT_MODEL_EXPORT_TIMEOUT_MS = 180_000
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* three's exporters finish inside `FileReader.onloadend` and `canvas.toBlob`
|
|
108
|
+
* callbacks that carry no error path: a failed read or a callback the browser
|
|
109
|
+
* never invokes calls neither `onDone` nor `onError`, so without a deadline the
|
|
110
|
+
* returned promise stays pending forever and the export UI is stuck.
|
|
111
|
+
*/
|
|
112
|
+
export function withExportDeadline<Result>(
|
|
113
|
+
promise: Promise<Result>,
|
|
114
|
+
timeoutMs: number,
|
|
115
|
+
format: string,
|
|
116
|
+
onTimeout?: () => void,
|
|
117
|
+
): Promise<Result> {
|
|
118
|
+
if (!Number.isFinite(timeoutMs)) return promise
|
|
119
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
120
|
+
const deadline = new Promise<never>((_, reject) => {
|
|
121
|
+
timer = setTimeout(() => {
|
|
122
|
+
onTimeout?.()
|
|
123
|
+
reject(
|
|
124
|
+
new Error(
|
|
125
|
+
`${format} export timed out after ${Math.max(1, Math.round(timeoutMs / 1000))} s. Try again with “Visible nodes only” to shrink the scene, or reload the page if it keeps failing.`,
|
|
126
|
+
),
|
|
127
|
+
)
|
|
128
|
+
}, timeoutMs)
|
|
129
|
+
})
|
|
130
|
+
return Promise.race([promise, deadline]).finally(() => clearTimeout(timer))
|
|
78
131
|
}
|
|
79
132
|
|
|
80
133
|
/** Resolve after the next couple of animation frames, giving React/R3F time to
|
|
@@ -144,15 +197,43 @@ export async function exportSceneToGlb(
|
|
|
144
197
|
options: GlbExportOptions = {},
|
|
145
198
|
): Promise<ArrayBuffer> {
|
|
146
199
|
const textureMode = options.textures ?? 'embed'
|
|
147
|
-
const
|
|
148
|
-
|
|
200
|
+
const textureUtils = options.textureUtils ?? createExportTextureUtils()
|
|
201
|
+
// Preparation awaits plugin bake hooks and presentation builders, so the
|
|
202
|
+
// deadline has to cover it as well as the serialisation.
|
|
203
|
+
const preparation = preparePortableSceneFromViewer(sceneGroup, nodes, {
|
|
204
|
+
...options,
|
|
205
|
+
textureUtils,
|
|
206
|
+
animations: options.animations ?? 'keep',
|
|
207
|
+
})
|
|
208
|
+
// Assigned inside the raced closure, so a plain `let` narrows to `never` in
|
|
209
|
+
// the `finally`.
|
|
210
|
+
const run: { prepared: GlbExport | null; abandoned: boolean } = {
|
|
211
|
+
prepared: null,
|
|
212
|
+
abandoned: false,
|
|
213
|
+
}
|
|
149
214
|
try {
|
|
150
|
-
return await
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
215
|
+
return await withExportDeadline(
|
|
216
|
+
(async () => {
|
|
217
|
+
run.prepared = await preparation
|
|
218
|
+
// The race has already rejected; don't serialise a file nobody will get.
|
|
219
|
+
if (run.abandoned) throw new Error('GLB export abandoned')
|
|
220
|
+
for (const warning of run.prepared.warnings) options.onWarning?.(warning)
|
|
221
|
+
return serializePreparedSceneToGlb(run.prepared, {
|
|
222
|
+
textures: textureMode,
|
|
223
|
+
onlyVisible: options.onlyVisible,
|
|
224
|
+
textureUtils,
|
|
225
|
+
})
|
|
226
|
+
})(),
|
|
227
|
+
options.timeoutMs ?? DEFAULT_MODEL_EXPORT_TIMEOUT_MS,
|
|
228
|
+
'GLB',
|
|
229
|
+
() => {
|
|
230
|
+
run.abandoned = true
|
|
231
|
+
},
|
|
232
|
+
)
|
|
154
233
|
} finally {
|
|
155
|
-
prepared.dispose()
|
|
234
|
+
if (run.prepared) run.prepared.dispose()
|
|
235
|
+
else preparation.then((late) => late.dispose()).catch(() => {})
|
|
236
|
+
if (!options.textureUtils) await textureUtils.dispose()
|
|
156
237
|
}
|
|
157
238
|
}
|
|
158
239
|
/**
|
|
@@ -179,24 +260,30 @@ export async function preparePortableSceneFromViewer(
|
|
|
179
260
|
return completeSceneExportPreparation(preparation)
|
|
180
261
|
}
|
|
181
262
|
|
|
263
|
+
/** Serialise a prepared scene; callers own the deadline (see `exportSceneToGlb`). */
|
|
182
264
|
export function serializePreparedSceneToGlb(
|
|
183
265
|
prepared: GlbExport,
|
|
184
|
-
options: Pick<GlbExportOptions, 'textures' | 'onlyVisible'>
|
|
266
|
+
options: Pick<GlbExportOptions, 'textures' | 'onlyVisible'> & {
|
|
267
|
+
textureUtils: ExportTextureUtils
|
|
268
|
+
},
|
|
185
269
|
): Promise<ArrayBuffer> {
|
|
186
270
|
const exporter = new GLTFExporter()
|
|
187
271
|
if ((options.textures ?? 'embed') === 'reference') {
|
|
188
272
|
exporter.register(textureReferencePlugin)
|
|
189
273
|
}
|
|
190
|
-
exporter.setTextureUtils(
|
|
274
|
+
exporter.setTextureUtils(options.textureUtils)
|
|
191
275
|
|
|
192
276
|
return new Promise<ArrayBuffer>((resolve, reject) => {
|
|
193
277
|
exporter.parse(
|
|
194
278
|
prepared.scene,
|
|
195
279
|
(gltf) => {
|
|
196
|
-
|
|
280
|
+
// A failed final FileReader hands GLTFExporter's onDone `null`, which
|
|
281
|
+
// `new Blob([null])` would happily turn into a four-byte download.
|
|
282
|
+
if (gltf instanceof ArrayBuffer) resolve(gltf)
|
|
283
|
+
else reject(new Error('GLB export produced no data'))
|
|
197
284
|
},
|
|
198
285
|
(error) => {
|
|
199
|
-
reject(error)
|
|
286
|
+
reject(error instanceof Error ? error : new Error(String(error)))
|
|
200
287
|
},
|
|
201
288
|
{
|
|
202
289
|
binary: true,
|
|
@@ -342,22 +429,47 @@ function startSceneExportPreparation(
|
|
|
342
429
|
}
|
|
343
430
|
}
|
|
344
431
|
|
|
432
|
+
/** Own a throwaway decompressor only when the caller supplied neither form. */
|
|
433
|
+
function resolveNormalMapDecompressor(options: GlbExportOptions): {
|
|
434
|
+
decompress: CompressedTextureDecompressor
|
|
435
|
+
dispose: () => Promise<void>
|
|
436
|
+
} {
|
|
437
|
+
if (options.decompressTexture) {
|
|
438
|
+
return { decompress: options.decompressTexture, dispose: async () => {} }
|
|
439
|
+
}
|
|
440
|
+
const utils = options.textureUtils ?? createExportTextureUtils()
|
|
441
|
+
return {
|
|
442
|
+
decompress: (texture) => utils.decompress(texture),
|
|
443
|
+
dispose: options.textureUtils ? async () => {} : () => utils.dispose(),
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
345
447
|
async function completeSceneExportPreparation(
|
|
346
448
|
preparation: SceneExportPreparation,
|
|
347
449
|
): Promise<GlbExport> {
|
|
450
|
+
const decompressor = resolveNormalMapDecompressor(preparation.options)
|
|
348
451
|
try {
|
|
349
452
|
await replaceBakeGeometryAsync(preparation)
|
|
350
453
|
await appendSelectedPresentations(preparation)
|
|
351
454
|
const prepared = finishSceneExportPreparation(preparation)
|
|
455
|
+
const { options } = preparation
|
|
456
|
+
const byReference = (options.textures ?? 'embed') === 'reference'
|
|
457
|
+
const normalizeOptions = {
|
|
458
|
+
preserveNormalMap: (texture: THREE.Texture) =>
|
|
459
|
+
byReference && getPascalTextureRef(texture) !== null,
|
|
460
|
+
}
|
|
461
|
+
await decompressCanonicalNormalMaps(prepared.scene, decompressor.decompress, normalizeOptions)
|
|
352
462
|
prepared.warnings.push(
|
|
353
|
-
...(
|
|
354
|
-
? normalizeViewerArtifactMaterials(prepared.scene)
|
|
355
|
-
: normalizePortableScene(prepared.scene)),
|
|
463
|
+
...(options.purpose === 'viewer'
|
|
464
|
+
? normalizeViewerArtifactMaterials(prepared.scene, normalizeOptions)
|
|
465
|
+
: normalizePortableScene(prepared.scene, normalizeOptions)),
|
|
356
466
|
)
|
|
357
467
|
return prepared
|
|
358
468
|
} catch (error) {
|
|
359
469
|
disposeExportResources(preparation.scene)
|
|
360
470
|
throw error
|
|
471
|
+
} finally {
|
|
472
|
+
await decompressor.dispose()
|
|
361
473
|
}
|
|
362
474
|
}
|
|
363
475
|
|
|
@@ -374,10 +486,12 @@ function finishSceneExportPreparation(preparation: SceneExportPreparation): GlbE
|
|
|
374
486
|
convertMaterials(scene, options.textures ?? 'embed', options.purpose ?? 'viewer')
|
|
375
487
|
|
|
376
488
|
const retainedCloneByOriginal = retainedClones(scene, cloneByOriginal)
|
|
377
|
-
const
|
|
378
|
-
options.
|
|
379
|
-
|
|
380
|
-
|
|
489
|
+
const keepClips = options.animations
|
|
490
|
+
? options.animations === 'keep'
|
|
491
|
+
: options.purpose === 'viewer'
|
|
492
|
+
const animation = keepClips
|
|
493
|
+
? bakeAnimationClips(retainedCloneByOriginal, nodes, registryEntries)
|
|
494
|
+
: { clips: [], clipNamesByNode: new Map<string, string[]>() }
|
|
381
495
|
stampIdentity(scene, retainedCloneByOriginal, nodes, animation.clipNamesByNode, registryEntries)
|
|
382
496
|
|
|
383
497
|
let disposed = false
|
|
@@ -495,7 +609,7 @@ function ownBorrowedPresentationTextures(root: THREE.Object3D): void {
|
|
|
495
609
|
let ownedTexture = ownedTextures.get(sourceTexture)
|
|
496
610
|
if (!ownedTexture) {
|
|
497
611
|
ownedTexture = sourceTexture.clone()
|
|
498
|
-
ownedTexture.userData =
|
|
612
|
+
ownedTexture.userData = cloneExportUserData(sourceTexture.userData)
|
|
499
613
|
ownedTexture.needsUpdate = true
|
|
500
614
|
ownedTextures.set(sourceTexture, ownedTexture)
|
|
501
615
|
}
|
|
@@ -670,7 +784,7 @@ function cloneSceneForExport(
|
|
|
670
784
|
if (excludedObjects.has(source)) return new THREE.Group()
|
|
671
785
|
|
|
672
786
|
const clone = source.clone(false)
|
|
673
|
-
clone.userData =
|
|
787
|
+
clone.userData = cloneExportUserData(source.userData)
|
|
674
788
|
const renderable = source as THREE.Mesh
|
|
675
789
|
const renderableClone = clone as THREE.Mesh
|
|
676
790
|
if (renderable.geometry) {
|
|
@@ -693,7 +807,7 @@ function cloneSceneForExport(
|
|
|
693
807
|
let textureClone = cache.textures.get(texture)
|
|
694
808
|
if (!textureClone) {
|
|
695
809
|
textureClone = texture.clone()
|
|
696
|
-
textureClone.userData =
|
|
810
|
+
textureClone.userData = cloneExportUserData(texture.userData)
|
|
697
811
|
textureClone.needsUpdate = true
|
|
698
812
|
cache.textures.set(texture, textureClone)
|
|
699
813
|
}
|
|
@@ -1047,7 +1161,8 @@ function convertMaterial(
|
|
|
1047
1161
|
if (cached) return cached
|
|
1048
1162
|
|
|
1049
1163
|
const src = material as THREE.Material & Record<string, unknown>
|
|
1050
|
-
const
|
|
1164
|
+
const glass = purpose === 'portable' && isPortableGlass(material)
|
|
1165
|
+
const target = glass ? new THREE.MeshPhysicalMaterial() : new THREE.MeshStandardMaterial()
|
|
1051
1166
|
|
|
1052
1167
|
target.name = material.name
|
|
1053
1168
|
if (src.color instanceof THREE.Color) target.color.copy(src.color)
|
|
@@ -1083,12 +1198,44 @@ function convertMaterial(
|
|
|
1083
1198
|
}
|
|
1084
1199
|
}
|
|
1085
1200
|
|
|
1201
|
+
if (glass) applyPortableGlass(target as THREE.MeshPhysicalMaterial, material)
|
|
1086
1202
|
if (textureMode === 'reference') replaceReferencedTextures(target, placeholderCache)
|
|
1087
1203
|
|
|
1088
1204
|
cache.set(material, target)
|
|
1089
1205
|
return target
|
|
1090
1206
|
}
|
|
1091
1207
|
|
|
1208
|
+
/** Mirrors the viewer's own rule (`maybeApplyGlassFresnel`): an untextured
|
|
1209
|
+
* see-through surface below this opacity is glass, not tinted plastic. */
|
|
1210
|
+
const GLASS_OPACITY_THRESHOLD = 0.6
|
|
1211
|
+
|
|
1212
|
+
function isPortableGlass(material: THREE.Material): boolean {
|
|
1213
|
+
const src = material as THREE.Material & Record<string, unknown>
|
|
1214
|
+
return (
|
|
1215
|
+
material.transparent &&
|
|
1216
|
+
material.opacity < GLASS_OPACITY_THRESHOLD &&
|
|
1217
|
+
!(src.map instanceof THREE.Texture)
|
|
1218
|
+
)
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
/**
|
|
1222
|
+
* The viewer sells glass with a fresnel-driven opacity node, which glTF cannot
|
|
1223
|
+
* carry; a plain alpha blend lands in every other tool as a blue film. Real
|
|
1224
|
+
* transmission (KHR_materials_transmission + ior) is what Blender, Unity and
|
|
1225
|
+
* Unreal all render as glass. The authored opacity becomes the tint strength.
|
|
1226
|
+
*/
|
|
1227
|
+
function applyPortableGlass(target: THREE.MeshPhysicalMaterial, source: THREE.Material) {
|
|
1228
|
+
target.color.lerp(new THREE.Color(0xffffff), 1 - source.opacity)
|
|
1229
|
+
target.transmission = 1
|
|
1230
|
+
target.ior = 1.5
|
|
1231
|
+
target.roughness = Math.min(target.roughness, 0.15)
|
|
1232
|
+
target.metalness = 0
|
|
1233
|
+
target.transparent = false
|
|
1234
|
+
target.opacity = 1
|
|
1235
|
+
target.depthWrite = true
|
|
1236
|
+
target.userData[GLASS_OPACITY_USERDATA] = source.opacity
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1092
1239
|
function replaceReferencedTextures(
|
|
1093
1240
|
material: THREE.Material,
|
|
1094
1241
|
placeholderCache: Map<THREE.Texture, THREE.Texture>,
|
|
@@ -5,7 +5,9 @@ import {
|
|
|
5
5
|
BuildingNode,
|
|
6
6
|
LevelNode,
|
|
7
7
|
SpawnNode,
|
|
8
|
+
UnitNode,
|
|
8
9
|
WallNode,
|
|
10
|
+
ZoneNode,
|
|
9
11
|
} from '@pascal-app/core/schema'
|
|
10
12
|
import { buildLevelDuplicateCreateOps } from './level-duplication'
|
|
11
13
|
|
|
@@ -69,3 +71,74 @@ describe('buildLevelDuplicateCreateOps', () => {
|
|
|
69
71
|
expect(copiedLevel?.children).toEqual([])
|
|
70
72
|
})
|
|
71
73
|
})
|
|
74
|
+
|
|
75
|
+
describe('unit duplication', () => {
|
|
76
|
+
for (const preset of [
|
|
77
|
+
'everything',
|
|
78
|
+
'structure',
|
|
79
|
+
'structure-materials',
|
|
80
|
+
'structure-furniture',
|
|
81
|
+
] as const) {
|
|
82
|
+
test(`duplicates only units wholly on the source level with ${preset}`, () => {
|
|
83
|
+
const building = BuildingNode.parse({})
|
|
84
|
+
const level = LevelNode.parse({ parentId: building.id, level: 0 })
|
|
85
|
+
const upper = LevelNode.parse({ parentId: building.id, level: 1 })
|
|
86
|
+
const kitchen = ZoneNode.parse({ parentId: level.id, name: 'Kitchen', polygon: [] })
|
|
87
|
+
const living = ZoneNode.parse({ parentId: level.id, name: 'Living', polygon: [] })
|
|
88
|
+
const bedroom = ZoneNode.parse({ parentId: upper.id, name: 'Bedroom', polygon: [] })
|
|
89
|
+
const single = UnitNode.parse({
|
|
90
|
+
parentId: building.id,
|
|
91
|
+
name: 'Suite',
|
|
92
|
+
kind: 'hotel-room',
|
|
93
|
+
color: '#123456',
|
|
94
|
+
members: [kitchen.id, living.id],
|
|
95
|
+
})
|
|
96
|
+
const duplex = UnitNode.parse({
|
|
97
|
+
parentId: building.id,
|
|
98
|
+
name: 'Duplex',
|
|
99
|
+
members: [living.id, bedroom.id],
|
|
100
|
+
})
|
|
101
|
+
const empty = UnitNode.parse({ parentId: building.id })
|
|
102
|
+
const dangling = UnitNode.parse({
|
|
103
|
+
parentId: building.id,
|
|
104
|
+
members: [kitchen.id, 'zone_missing'],
|
|
105
|
+
})
|
|
106
|
+
level.children = [kitchen.id, living.id]
|
|
107
|
+
upper.children = [bedroom.id]
|
|
108
|
+
building.children = [level.id, upper.id, single.id, duplex.id, empty.id, dangling.id]
|
|
109
|
+
const nodes: Record<AnyNodeId, AnyNode> = Object.fromEntries(
|
|
110
|
+
[building, level, upper, kitchen, living, bedroom, single, duplex, empty, dangling].map(
|
|
111
|
+
(node) => [node.id, node],
|
|
112
|
+
),
|
|
113
|
+
)
|
|
114
|
+
const before = structuredClone(nodes)
|
|
115
|
+
const { createOps, newLevelId, shiftedLevels } = buildLevelDuplicateCreateOps({
|
|
116
|
+
nodes,
|
|
117
|
+
level,
|
|
118
|
+
levels: [level, upper],
|
|
119
|
+
preset,
|
|
120
|
+
})
|
|
121
|
+
const unitOps = createOps.filter((op) => op.node.type === 'unit')
|
|
122
|
+
expect(unitOps).toHaveLength(1)
|
|
123
|
+
const op = unitOps[0]!
|
|
124
|
+
expect(op.parentId).toBe(building.id)
|
|
125
|
+
expect(op.node.type).toBe('unit')
|
|
126
|
+
if (op.node.type !== 'unit') return
|
|
127
|
+
expect(op.node.id).not.toBe(single.id)
|
|
128
|
+
expect(op.node.name).toBe('Suite copy')
|
|
129
|
+
expect(op.node.kind).toBe(single.kind)
|
|
130
|
+
expect(op.node.color).toBe(single.color)
|
|
131
|
+
expect(op.node.parentId).toBe(building.id)
|
|
132
|
+
expect(op.node.members).toEqual(
|
|
133
|
+
createOps.flatMap((entry) => (entry.node.type === 'zone' ? [entry.node.id] : [])),
|
|
134
|
+
)
|
|
135
|
+
expect(
|
|
136
|
+
createOps
|
|
137
|
+
.filter((entry) => entry.node.type === 'zone')
|
|
138
|
+
.every((entry) => entry.parentId === newLevelId),
|
|
139
|
+
).toBe(true)
|
|
140
|
+
expect(shiftedLevels).toEqual([{ id: upper.id, level: 2 }])
|
|
141
|
+
expect(nodes).toEqual(before)
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
})
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { cloneLevelSubtree } from '@pascal-app/core/clone-scene-graph'
|
|
2
|
-
import type
|
|
2
|
+
import { type AnyNode, type AnyNodeId, type LevelNode, UnitNode } from '@pascal-app/core/schema'
|
|
3
3
|
|
|
4
4
|
export type LevelDuplicatePreset =
|
|
5
5
|
| 'everything'
|
|
@@ -117,7 +117,7 @@ export function buildLevelDuplicateCreateOps({
|
|
|
117
117
|
levels: LevelNode[]
|
|
118
118
|
preset: LevelDuplicatePreset
|
|
119
119
|
}) {
|
|
120
|
-
const { clonedNodes, newLevelId } = cloneLevelSubtree(nodes, level.id)
|
|
120
|
+
const { clonedNodes, newLevelId, idMap } = cloneLevelSubtree(nodes, level.id)
|
|
121
121
|
const parentBuildingId =
|
|
122
122
|
(level.parentId as AnyNodeId | null) ?? findLevelBuildingId(nodes, level.id)
|
|
123
123
|
const nextLevelNumber = level.level + 1
|
|
@@ -145,6 +145,36 @@ export function buildLevelDuplicateCreateOps({
|
|
|
145
145
|
} as AnyNode
|
|
146
146
|
})
|
|
147
147
|
|
|
148
|
+
if (parentBuildingId && nodes[parentBuildingId]?.type === 'building') {
|
|
149
|
+
for (const unit of Object.values(nodes)) {
|
|
150
|
+
if (
|
|
151
|
+
unit.type !== 'unit' ||
|
|
152
|
+
unit.parentId !== parentBuildingId ||
|
|
153
|
+
unit.members.length === 0 ||
|
|
154
|
+
!unit.members.every((id) => {
|
|
155
|
+
const zone = nodes[id]
|
|
156
|
+
const remapped = idMap.get(id)
|
|
157
|
+
return (
|
|
158
|
+
zone?.type === 'zone' &&
|
|
159
|
+
zone.parentId === level.id &&
|
|
160
|
+
remapped !== undefined &&
|
|
161
|
+
keptIds.has(remapped as AnyNodeId)
|
|
162
|
+
)
|
|
163
|
+
})
|
|
164
|
+
)
|
|
165
|
+
continue
|
|
166
|
+
cleanedNodes.push(
|
|
167
|
+
UnitNode.parse({
|
|
168
|
+
...unit,
|
|
169
|
+
id: undefined,
|
|
170
|
+
parentId: parentBuildingId,
|
|
171
|
+
name: `${unit.name} copy`,
|
|
172
|
+
members: unit.members.map((id) => idMap.get(id)),
|
|
173
|
+
}),
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
148
178
|
return {
|
|
149
179
|
createOps: cleanedNodes.map((node) => ({
|
|
150
180
|
node:
|