@pascal-app/editor 1.0.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -6
- package/src/components/editor/bake-exporter.tsx +4 -0
- package/src/components/editor/bake-thumbnail.tsx +4 -1
- package/src/components/editor/floating-action-menu.tsx +14 -1
- package/src/components/editor/floorplan-panel.tsx +23 -3
- package/src/components/editor/handles/handle-placement.test.ts +111 -0
- package/src/components/editor/handles/handle-placement.ts +69 -0
- package/src/components/editor/handles/linear-resize-drag.ts +13 -2
- package/src/components/editor/handles/use-handle-drag.ts +5 -2
- package/src/components/editor/index.tsx +12 -4
- package/src/components/editor/node-arrow-handles.tsx +21 -62
- package/src/components/editor/selection-manager.tsx +49 -0
- package/src/components/editor/thumbnail-generator.tsx +11 -2
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +46 -3
- package/src/components/procedural-items/inspector.tsx +223 -0
- package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
- package/src/components/tools/item/item-surface-placement.test.ts +175 -0
- package/src/components/tools/item/item-surface-strategy-resolver.test.ts +210 -0
- package/src/components/tools/item/placement-strategies.ts +169 -169
- package/src/components/tools/item/placement-types.ts +4 -0
- package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
- package/src/components/tools/item/use-draft-node.ts +95 -45
- package/src/components/tools/item/use-placement-coordinator.tsx +247 -106
- package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
- package/src/components/tools/registry/item-surface-move.test.ts +501 -0
- package/src/components/tools/registry/item-surface-move.ts +441 -0
- package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
- package/src/components/tools/registry/move-registry-node-tool.tsx +215 -70
- package/src/components/tools/shared/pointer-support-cap.ts +11 -9
- package/src/components/tools/shared/shelf-stickiness.ts +54 -0
- package/src/components/tools/shared/surface-hit.test.ts +56 -0
- package/src/components/tools/shared/surface-hit.ts +23 -0
- package/src/components/tools/shared/surface-rejection.test.tsx +106 -0
- package/src/components/tools/shared/surface-rejection.tsx +29 -0
- package/src/components/tools/zone/zone-tool.tsx +13 -4
- package/src/components/ui/command-palette/editor-commands.tsx +54 -1
- package/src/components/ui/controls/metric-control.tsx +29 -13
- package/src/components/ui/controls/slider-control.tsx +25 -18
- package/src/components/ui/floating-level-selector.tsx +81 -5
- package/src/components/ui/panels/multi-parametric-inspector.tsx +6 -3
- package/src/components/ui/panels/parametric-field-control.tsx +1 -1
- package/src/components/ui/panels/parametric-inspector.tsx +6 -2
- package/src/components/ui/panels/reference-panel.tsx +3 -3
- package/src/components/ui/primitives/checkbox.tsx +34 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +120 -3
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +31 -4
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +29 -10
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +106 -4
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +6 -1
- package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
- package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
- package/src/components/ui/snap-target-badge.tsx +2 -0
- package/src/components/viewer/viewer-scene-header.tsx +3 -0
- package/src/components/viewer/viewer-units-panel.tsx +116 -0
- package/src/hooks/use-ceiling-events.test.ts +24 -2
- package/src/hooks/use-ceiling-events.ts +5 -1
- package/src/hooks/use-keyboard.ts +3 -1
- package/src/index.tsx +5 -0
- package/src/lib/export-texture-utils.test.ts +167 -0
- package/src/lib/export-texture-utils.ts +121 -0
- package/src/lib/export-user-data.test.ts +48 -0
- package/src/lib/export-user-data.ts +69 -0
- package/src/lib/floorplan/floorplan-export.test.ts +139 -1
- package/src/lib/floorplan/floorplan-export.tsx +9 -3
- package/src/lib/floorplan/floorplan-readonly.ts +4 -0
- package/src/lib/fresh-planar-placement.ts +13 -5
- package/src/lib/glb-export-serialize.test.ts +280 -0
- package/src/lib/glb-export.test.ts +206 -0
- package/src/lib/glb-export.ts +171 -24
- package/src/lib/level-duplication.test.ts +73 -0
- package/src/lib/level-duplication.ts +32 -2
- package/src/lib/linear-display.test.ts +59 -0
- package/src/lib/linear-display.ts +39 -0
- package/src/lib/local-guide-image.test.ts +24 -0
- package/src/lib/local-guide-image.ts +34 -1
- package/src/lib/node-action-movement.ts +21 -0
- package/src/lib/portable-export.test.ts +89 -0
- package/src/lib/portable-export.ts +84 -11
- package/src/lib/print-shell-compiler-manifold-core.ts +59 -10
- package/src/lib/print-shell-compiler-manifold-worker.ts +14 -1
- package/src/lib/print-shell-compiler-manifold.worker.ts +1 -1
- package/src/lib/print-shell-compiler-protocol.ts +8 -0
- package/src/lib/scene-clipboard.ts +1 -1
- package/src/lib/send-to-app.test.ts +108 -0
- package/src/lib/send-to-app.ts +121 -0
- package/src/lib/sfx/movement-tick.ts +37 -0
- package/src/lib/surface-attachment.ts +77 -0
- package/src/lib/units.ts +254 -0
- package/src/lib/usdz-export.ts +43 -16
- package/src/lib/use-linear-display.ts +8 -32
- package/src/store/use-handle-group.ts +4 -0
- package/src/store/use-send-to-app.ts +21 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { getLinearDisplay } from './linear-display'
|
|
3
|
+
|
|
4
|
+
describe('linear property control units', () => {
|
|
5
|
+
test('keeps meter storage while displaying and accepting millimeters', () => {
|
|
6
|
+
const display = getLinearDisplay('m', 'metric', 'millimeters', 2, 0.05)
|
|
7
|
+
expect(display.displayUnit).toBe('mm')
|
|
8
|
+
expect(display.parseUnit).toBe('mm')
|
|
9
|
+
expect(display.toDisplay(2.5)).toBe(2500)
|
|
10
|
+
expect(display.toStored(1800)).toBe(1.8)
|
|
11
|
+
expect(display.toStored(-250)).toBe(-0.25)
|
|
12
|
+
expect(display.precision).toBe(0)
|
|
13
|
+
expect(display.step).toBe(50)
|
|
14
|
+
expect(display.toStored(display.step)).toBe(0.05)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test('retains sub-millimeter precision when the field supports it', () => {
|
|
18
|
+
const display = getLinearDisplay('m', 'metric', 'millimeters', 4, 0.0001)
|
|
19
|
+
expect(display.precision).toBe(1)
|
|
20
|
+
expect(display.step).toBe(0.1)
|
|
21
|
+
expect(display.toDisplay(0.0125)).toBe(12.5)
|
|
22
|
+
expect(display.roundStored(0.01254)).toBe(0.0125)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('imperial preference takes precedence over the saved metric notation', () => {
|
|
26
|
+
const display = getLinearDisplay('m', 'imperial', 'millimeters', 2, 0.05)
|
|
27
|
+
expect(display.displayUnit).toBe('ft')
|
|
28
|
+
expect(display.parseUnit).toBe('ft')
|
|
29
|
+
expect(display.toDisplay(0.9144)).toBeCloseTo(3, 10)
|
|
30
|
+
expect(display.toStored(7)).toBeCloseTo(2.1336, 10)
|
|
31
|
+
expect(display.precision).toBe(2)
|
|
32
|
+
expect(display.step).toBe(0.05)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('switching notation round-trips the same stored dimensions', () => {
|
|
36
|
+
for (const stored of [0, 0.01, 0.125, 0.9, 2.1, -0.25]) {
|
|
37
|
+
for (const [unit, notation] of [
|
|
38
|
+
['metric', 'meters'],
|
|
39
|
+
['metric', 'millimeters'],
|
|
40
|
+
['imperial', 'meters'],
|
|
41
|
+
] as const) {
|
|
42
|
+
const display = getLinearDisplay('m', unit, notation, 2, 0.01)
|
|
43
|
+
expect(display.toStored(display.toDisplay(stored))).toBeCloseTo(stored, 12)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('leaves non-meter fields and their gesture steps unchanged', () => {
|
|
49
|
+
for (const unit of ['°', 'rad', '%', '', 'in', 'cm']) {
|
|
50
|
+
const display = getLinearDisplay(unit, 'metric', 'millimeters', 2, 0.05)
|
|
51
|
+
expect(display.displayUnit).toBe(unit)
|
|
52
|
+
expect(display.parseUnit).toBeUndefined()
|
|
53
|
+
expect(display.toDisplay(12.5)).toBe(12.5)
|
|
54
|
+
expect(display.toStored(12.5)).toBe(12.5)
|
|
55
|
+
expect(display.precision).toBe(2)
|
|
56
|
+
expect(display.step).toBe(0.05)
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
})
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getLinearUnitLabel,
|
|
3
|
+
type LinearUnit,
|
|
4
|
+
linearUnitToMeters,
|
|
5
|
+
type MetricNotation,
|
|
6
|
+
metersToLinearUnit,
|
|
7
|
+
} from './measurements'
|
|
8
|
+
|
|
9
|
+
export function getLinearDisplay(
|
|
10
|
+
unit: string,
|
|
11
|
+
viewerUnit: LinearUnit,
|
|
12
|
+
metricNotation: MetricNotation,
|
|
13
|
+
precision: number,
|
|
14
|
+
step: number,
|
|
15
|
+
) {
|
|
16
|
+
const isImperial = unit === 'm' && viewerUnit === 'imperial'
|
|
17
|
+
const isMillimeters = unit === 'm' && viewerUnit === 'metric' && metricNotation === 'millimeters'
|
|
18
|
+
const displayUnit = isImperial ? getLinearUnitLabel('imperial') : isMillimeters ? 'mm' : unit
|
|
19
|
+
const displayPrecision = isMillimeters ? Math.max(0, precision - 3) : precision
|
|
20
|
+
const displayStep = isMillimeters ? step * 1000 : step
|
|
21
|
+
const parseUnit = isImperial ? 'ft' : isMillimeters ? 'mm' : undefined
|
|
22
|
+
const toDisplay = (stored: number) =>
|
|
23
|
+
isImperial ? metersToLinearUnit(stored, 'imperial') : isMillimeters ? stored * 1000 : stored
|
|
24
|
+
const toStored = (display: number) =>
|
|
25
|
+
isImperial ? linearUnitToMeters(display, 'imperial') : isMillimeters ? display / 1000 : display
|
|
26
|
+
const roundStored = (stored: number) =>
|
|
27
|
+
toStored(Number.parseFloat(toDisplay(stored).toFixed(displayPrecision)))
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
isImperial,
|
|
31
|
+
displayUnit,
|
|
32
|
+
parseUnit,
|
|
33
|
+
precision: displayPrecision,
|
|
34
|
+
step: displayStep,
|
|
35
|
+
toDisplay,
|
|
36
|
+
toStored,
|
|
37
|
+
roundStored,
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { expect, mock, test } from 'bun:test'
|
|
2
|
+
import * as core from '@pascal-app/core'
|
|
3
|
+
|
|
4
|
+
const saveAsset = mock(async () => 'asset://stored-scan')
|
|
5
|
+
|
|
6
|
+
mock.module('@pascal-app/core', () => ({ ...core, saveAsset }))
|
|
7
|
+
|
|
8
|
+
const { createLocalScan } = await import('./local-guide-image')
|
|
9
|
+
|
|
10
|
+
test('createLocalScan stores and attaches a scan asset to its level', async () => {
|
|
11
|
+
const createNode = mock()
|
|
12
|
+
const file = new File(['scan data'], 'living-room.glb', { type: 'model/gltf-binary' })
|
|
13
|
+
|
|
14
|
+
const { scan, url } = await createLocalScan({ createNode, file, levelId: 'level_ground' })
|
|
15
|
+
|
|
16
|
+
expect(saveAsset).toHaveBeenCalledWith(file)
|
|
17
|
+
expect(scan).toMatchObject({
|
|
18
|
+
name: 'living-room',
|
|
19
|
+
type: 'scan',
|
|
20
|
+
url: 'asset://stored-scan',
|
|
21
|
+
})
|
|
22
|
+
expect(url).toBe('asset://stored-scan')
|
|
23
|
+
expect(createNode).toHaveBeenCalledWith(scan, 'level_ground')
|
|
24
|
+
})
|
|
@@ -2,13 +2,23 @@ import {
|
|
|
2
2
|
type AnyNodeId,
|
|
3
3
|
GuideNode,
|
|
4
4
|
type GuideNode as GuideNodeType,
|
|
5
|
+
ScanNode,
|
|
6
|
+
type ScanNode as ScanNodeType,
|
|
5
7
|
saveAsset,
|
|
6
8
|
} from '@pascal-app/core'
|
|
7
9
|
|
|
8
10
|
export function getGuideImageName(filename: string) {
|
|
11
|
+
return getAssetName(filename, 'Guide image')
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function getScanName(filename: string) {
|
|
15
|
+
return getAssetName(filename, 'Scan')
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getAssetName(filename: string, fallback: string) {
|
|
9
19
|
const trimmed = filename.trim()
|
|
10
20
|
if (!trimmed) {
|
|
11
|
-
return
|
|
21
|
+
return fallback
|
|
12
22
|
}
|
|
13
23
|
|
|
14
24
|
const dotIndex = trimmed.lastIndexOf('.')
|
|
@@ -40,3 +50,26 @@ export async function createLocalGuideImage({
|
|
|
40
50
|
createNode(guide, levelId as AnyNodeId)
|
|
41
51
|
return guide
|
|
42
52
|
}
|
|
53
|
+
|
|
54
|
+
export async function createLocalScan({
|
|
55
|
+
createNode,
|
|
56
|
+
file,
|
|
57
|
+
levelId,
|
|
58
|
+
}: {
|
|
59
|
+
createNode: (node: ScanNodeType, parentId: AnyNodeId) => void
|
|
60
|
+
file: File
|
|
61
|
+
levelId: string
|
|
62
|
+
}) {
|
|
63
|
+
const assetUrl = await saveAsset(file)
|
|
64
|
+
const scan = ScanNode.parse({
|
|
65
|
+
name: getScanName(file.name),
|
|
66
|
+
url: assetUrl,
|
|
67
|
+
position: [0, 0, 0],
|
|
68
|
+
rotation: [0, 0, 0],
|
|
69
|
+
scale: 1,
|
|
70
|
+
opacity: 100,
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
createNode(scan, levelId as AnyNodeId)
|
|
74
|
+
return { scan, url: assetUrl }
|
|
75
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type AnyNode, createSceneApi, isMovable, nodeRegistry, useScene } from '@pascal-app/core'
|
|
2
|
+
|
|
3
|
+
export function registryMoveDisabled(node: AnyNode): boolean {
|
|
4
|
+
const def = nodeRegistry.get(node.type)
|
|
5
|
+
return Boolean(
|
|
6
|
+
def?.capabilities.movable &&
|
|
7
|
+
!isMovable(node) &&
|
|
8
|
+
!def.floorplanMoveTarget &&
|
|
9
|
+
!def.affordanceTools?.move,
|
|
10
|
+
)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function duplicateWithoutMove(node: AnyNode) {
|
|
14
|
+
const api = createSceneApi(useScene)
|
|
15
|
+
const subtree = api.getSubtree(node.id)
|
|
16
|
+
if (!subtree || !node.parentId) return null
|
|
17
|
+
return api.cloneNodesInto([subtree.root, ...subtree.descendants], {
|
|
18
|
+
rootId: node.id,
|
|
19
|
+
parentId: node.parentId as AnyNode['id'],
|
|
20
|
+
})
|
|
21
|
+
}
|
|
@@ -5,6 +5,8 @@ import {
|
|
|
5
5
|
BoxGeometry,
|
|
6
6
|
BufferGeometry,
|
|
7
7
|
Color,
|
|
8
|
+
CompressedTexture,
|
|
9
|
+
DataTexture,
|
|
8
10
|
DoubleSide,
|
|
9
11
|
Float32BufferAttribute,
|
|
10
12
|
FrontSide,
|
|
@@ -15,11 +17,13 @@ import {
|
|
|
15
17
|
Mesh,
|
|
16
18
|
MeshStandardMaterial,
|
|
17
19
|
Quaternion,
|
|
20
|
+
Texture,
|
|
18
21
|
Vector3,
|
|
19
22
|
} from 'three'
|
|
20
23
|
import { USDZExporter } from 'three/examples/jsm/exporters/USDZExporter.js'
|
|
21
24
|
import {
|
|
22
25
|
createUsdzScene,
|
|
26
|
+
decompressCanonicalNormalMaps,
|
|
23
27
|
expandInstancedMeshes,
|
|
24
28
|
freezeDeformedMeshes,
|
|
25
29
|
normalizePortableScene,
|
|
@@ -345,6 +349,17 @@ function withCanvasCapture(run: (capture: () => ImageData) => void): void {
|
|
|
345
349
|
putImageData: (image: ImageData) => {
|
|
346
350
|
captured = image
|
|
347
351
|
},
|
|
352
|
+
drawImage: () => {
|
|
353
|
+
if (!captured) throw new Error('Source canvas has no captured pixels')
|
|
354
|
+
},
|
|
355
|
+
getImageData: (_x: number, _y: number, width: number, height: number) =>
|
|
356
|
+
captured ??
|
|
357
|
+
({
|
|
358
|
+
colorSpace: 'srgb',
|
|
359
|
+
data: new Uint8ClampedArray(width * height * 4),
|
|
360
|
+
height,
|
|
361
|
+
width,
|
|
362
|
+
} as ImageData),
|
|
348
363
|
}),
|
|
349
364
|
} as unknown as HTMLCanvasElement
|
|
350
365
|
globals.document = {
|
|
@@ -364,3 +379,77 @@ function withCanvasCapture(run: (capture: () => ImageData) => void): void {
|
|
|
364
379
|
else delete globals.document
|
|
365
380
|
}
|
|
366
381
|
}
|
|
382
|
+
|
|
383
|
+
describe('portable normal maps', () => {
|
|
384
|
+
function flatNormalPixels(size: number): Uint8Array {
|
|
385
|
+
const pixels = new Uint8Array(size * size * 4)
|
|
386
|
+
for (let index = 0; index < pixels.length; index += 4) pixels.set([128, 128, 255, 255], index)
|
|
387
|
+
return pixels
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
test('decompresses a shared compressed normal map once before canonicalising it', async () => {
|
|
391
|
+
const compressed = new CompressedTexture([], 2, 2)
|
|
392
|
+
compressed.name = 'NormalGL_test'
|
|
393
|
+
compressed.repeat.set(2, 2)
|
|
394
|
+
const first = new MeshStandardMaterial({ normalMap: compressed })
|
|
395
|
+
first.normalScale.set(0.5, 0.5)
|
|
396
|
+
const second = new MeshStandardMaterial({ normalMap: compressed })
|
|
397
|
+
second.normalScale.set(0.5, -0.5)
|
|
398
|
+
const root = new Group()
|
|
399
|
+
root.add(new Mesh(new BoxGeometry(), first), new Mesh(new BoxGeometry(), second))
|
|
400
|
+
|
|
401
|
+
let calls = 0
|
|
402
|
+
await decompressCanonicalNormalMaps(root, async () => {
|
|
403
|
+
calls += 1
|
|
404
|
+
return new DataTexture(flatNormalPixels(2), 2, 2)
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
expect(calls).toBe(1)
|
|
408
|
+
expect(first.normalMap).toBe(second.normalMap)
|
|
409
|
+
expect(first.normalMap?.repeat.toArray()).toEqual([2, 2])
|
|
410
|
+
expect(first.normalMap?.name).toBe('NormalGL_test')
|
|
411
|
+
|
|
412
|
+
withCanvasCapture(() => {
|
|
413
|
+
normalizePortableScene(root)
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
expect((first.normalMap as { isCanvasTexture?: boolean }).isCanvasTexture).toBe(true)
|
|
417
|
+
expect(first.normalMap).not.toBe(second.normalMap)
|
|
418
|
+
expect(first.normalScale.toArray()).toEqual([1, 1])
|
|
419
|
+
expect(second.normalScale.toArray()).toEqual([1, 1])
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
test('a compressed normal map that was not decompressed still fails loudly', () => {
|
|
423
|
+
const material = new MeshStandardMaterial({ normalMap: new CompressedTexture([], 2, 2) })
|
|
424
|
+
material.normalScale.set(0.5, 0.5)
|
|
425
|
+
const root = new Group()
|
|
426
|
+
root.add(new Mesh(new BoxGeometry(), material))
|
|
427
|
+
withCanvasCapture(() => {
|
|
428
|
+
expect(() => normalizePortableScene(root)).toThrow('must be baked before portable export')
|
|
429
|
+
})
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
test('preserved normal maps keep their texture and normal scale', async () => {
|
|
433
|
+
const placeholder = new Texture()
|
|
434
|
+
placeholder.image = { width: 1, height: 1 }
|
|
435
|
+
const material = new MeshStandardMaterial({ normalMap: placeholder })
|
|
436
|
+
material.normalScale.set(1, -1)
|
|
437
|
+
const root = new Group()
|
|
438
|
+
root.add(new Mesh(new BoxGeometry(), material))
|
|
439
|
+
const options = { preserveNormalMap: (texture: Texture) => texture === placeholder }
|
|
440
|
+
|
|
441
|
+
await decompressCanonicalNormalMaps(
|
|
442
|
+
root,
|
|
443
|
+
async () => {
|
|
444
|
+
throw new Error('preserved maps must not be decompressed')
|
|
445
|
+
},
|
|
446
|
+
options,
|
|
447
|
+
)
|
|
448
|
+
withCanvasCapture(() => {
|
|
449
|
+
normalizePortableScene(root, options)
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
expect(material.normalMap).toBe(placeholder)
|
|
453
|
+
expect(material.normalScale.toArray()).toEqual([1, -1])
|
|
454
|
+
})
|
|
455
|
+
})
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as THREE from 'three'
|
|
2
2
|
import { StaticGeometryGenerator } from 'three-mesh-bvh'
|
|
3
|
+
import { cloneExportUserData } from './export-user-data'
|
|
3
4
|
|
|
4
5
|
const VERTEX_COLOR_UV_CHANNEL = 0
|
|
5
6
|
const VERTEX_COLOR_TILE_SIZE = 8
|
|
@@ -64,7 +65,7 @@ function copyObjectState(source: THREE.Object3D, target: THREE.Object3D): void {
|
|
|
64
65
|
target.layers.mask = source.layers.mask
|
|
65
66
|
target.renderOrder = source.renderOrder
|
|
66
67
|
target.frustumCulled = source.frustumCulled
|
|
67
|
-
target.userData =
|
|
68
|
+
target.userData = cloneExportUserData(source.userData)
|
|
68
69
|
}
|
|
69
70
|
|
|
70
71
|
function replaceObject(source: THREE.Object3D, replacement: THREE.Object3D): void {
|
|
@@ -554,7 +555,7 @@ function cloneTextureSettings(source: THREE.Texture, target: THREE.Texture): voi
|
|
|
554
555
|
target.flipY = source.flipY
|
|
555
556
|
target.unpackAlignment = source.unpackAlignment
|
|
556
557
|
target.colorSpace = THREE.NoColorSpace
|
|
557
|
-
target.userData =
|
|
558
|
+
target.userData = cloneExportUserData(source.userData)
|
|
558
559
|
target.needsUpdate = true
|
|
559
560
|
}
|
|
560
561
|
function materializeDataTextures(root: THREE.Object3D): void {
|
|
@@ -671,19 +672,68 @@ function canonicalNormalTexture(source: THREE.Texture, scale: THREE.Vector2): TH
|
|
|
671
672
|
return texture
|
|
672
673
|
}
|
|
673
674
|
|
|
674
|
-
|
|
675
|
+
export type PortableNormalizeOptions = {
|
|
676
|
+
/**
|
|
677
|
+
* Leave this normal map and its material's `normalScale` untouched. By-reference
|
|
678
|
+
* placeholders get their real bytes re-attached later, so baking the scale into
|
|
679
|
+
* placeholder pixels would drop both the reference and the strength.
|
|
680
|
+
*/
|
|
681
|
+
preserveNormalMap?: (texture: THREE.Texture) => boolean
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
export type CompressedTextureDecompressor = (
|
|
685
|
+
texture: THREE.CompressedTexture,
|
|
686
|
+
) => Promise<THREE.Texture>
|
|
687
|
+
|
|
688
|
+
function canonicalNormalMaterials(
|
|
689
|
+
root: THREE.Object3D,
|
|
690
|
+
options: PortableNormalizeOptions,
|
|
691
|
+
): THREE.MeshStandardMaterial[] {
|
|
675
692
|
const materials = new Set<THREE.Material>()
|
|
676
693
|
root.traverse((object) => {
|
|
677
694
|
if (!(object as THREE.Mesh).isMesh) return
|
|
678
695
|
for (const material of materialsOf(object as THREE.Mesh)) materials.add(material)
|
|
679
696
|
})
|
|
680
|
-
|
|
681
|
-
const cache = new Map<THREE.Texture, Map<string, THREE.CanvasTexture>>()
|
|
697
|
+
const result: THREE.MeshStandardMaterial[] = []
|
|
682
698
|
for (const material of materials) {
|
|
683
699
|
const standard = material as THREE.MeshStandardMaterial
|
|
684
700
|
if (!standard.normalMap || !standard.normalScale) continue
|
|
685
701
|
if (standard.normalScale.x === 1 && standard.normalScale.y === 1) continue
|
|
686
|
-
|
|
702
|
+
if (options.preserveNormalMap?.(standard.normalMap)) continue
|
|
703
|
+
result.push(standard)
|
|
704
|
+
}
|
|
705
|
+
return result
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* Canonicalizing a normal map reads its pixels on the CPU, which compressed
|
|
710
|
+
* (KTX2) textures cannot provide — decompress the ones that will be baked
|
|
711
|
+
* first. Runs before `normalizePortableScene` / `normalizeViewerArtifactMaterials`.
|
|
712
|
+
*/
|
|
713
|
+
export async function decompressCanonicalNormalMaps(
|
|
714
|
+
root: THREE.Object3D,
|
|
715
|
+
decompress: CompressedTextureDecompressor,
|
|
716
|
+
options: PortableNormalizeOptions = {},
|
|
717
|
+
): Promise<void> {
|
|
718
|
+
const readable = new Map<THREE.Texture, THREE.Texture>()
|
|
719
|
+
for (const standard of canonicalNormalMaterials(root, options)) {
|
|
720
|
+
const source = standard.normalMap as THREE.CompressedTexture
|
|
721
|
+
if (!source.isCompressedTexture) continue
|
|
722
|
+
let texture = readable.get(source)
|
|
723
|
+
if (!texture) {
|
|
724
|
+
texture = await decompress(source)
|
|
725
|
+
cloneTextureSettings(source, texture)
|
|
726
|
+
readable.set(source, texture)
|
|
727
|
+
rememberDiscarded(root, source)
|
|
728
|
+
}
|
|
729
|
+
standard.normalMap = texture
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function canonicalizeNormalMaps(root: THREE.Object3D, options: PortableNormalizeOptions): void {
|
|
734
|
+
const cache = new Map<THREE.Texture, Map<string, THREE.CanvasTexture>>()
|
|
735
|
+
for (const standard of canonicalNormalMaterials(root, options)) {
|
|
736
|
+
const source = standard.normalMap!
|
|
687
737
|
const key = `${standard.normalScale.x}:${standard.normalScale.y}`
|
|
688
738
|
let variants = cache.get(source)
|
|
689
739
|
if (!variants) {
|
|
@@ -817,7 +867,10 @@ function clampPortableMaterialColors(root: THREE.Object3D): boolean {
|
|
|
817
867
|
return clipped
|
|
818
868
|
}
|
|
819
869
|
|
|
820
|
-
export function normalizePortableScene(
|
|
870
|
+
export function normalizePortableScene(
|
|
871
|
+
root: THREE.Object3D,
|
|
872
|
+
options: PortableNormalizeOptions = {},
|
|
873
|
+
): string[] {
|
|
821
874
|
expandInstancedMeshes(root)
|
|
822
875
|
root.updateMatrixWorld(true)
|
|
823
876
|
freezeDeformedMeshes(root)
|
|
@@ -826,7 +879,7 @@ export function normalizePortableScene(root: THREE.Object3D): string[] {
|
|
|
826
879
|
const clampedVertexColors = bakeVertexColors(root)
|
|
827
880
|
const clampedHdr = clampedMaterialColors || clampedVertexColors
|
|
828
881
|
canonicalizeAlphaMaps(root)
|
|
829
|
-
canonicalizeNormalMaps(root)
|
|
882
|
+
canonicalizeNormalMaps(root, options)
|
|
830
883
|
bakeDoubleSidedMeshes(root)
|
|
831
884
|
root.updateMatrixWorld(true)
|
|
832
885
|
return clampedHdr
|
|
@@ -839,12 +892,15 @@ export function normalizePortableScene(root: THREE.Object3D): string[] {
|
|
|
839
892
|
* Canonicalize baked static material details without freezing authored item
|
|
840
893
|
* deformation that existing saved-viewer animation clips still target.
|
|
841
894
|
*/
|
|
842
|
-
export function normalizeViewerArtifactMaterials(
|
|
895
|
+
export function normalizeViewerArtifactMaterials(
|
|
896
|
+
root: THREE.Object3D,
|
|
897
|
+
options: PortableNormalizeOptions = {},
|
|
898
|
+
): string[] {
|
|
843
899
|
const clampedMaterialColors = clampPortableMaterialColors(root)
|
|
844
900
|
materializeDataTextures(root)
|
|
845
901
|
const clampedVertexColors = bakeVertexColors(root, true)
|
|
846
902
|
canonicalizeAlphaMaps(root)
|
|
847
|
-
canonicalizeNormalMaps(root)
|
|
903
|
+
canonicalizeNormalMaps(root, options)
|
|
848
904
|
return clampedMaterialColors || clampedVertexColors
|
|
849
905
|
? [
|
|
850
906
|
'Some rendered colors were outside the portable range (including colors brighter than the portable range) and were clipped to 0–1; the brightest areas may lose contrast.',
|
|
@@ -852,9 +908,26 @@ export function normalizeViewerArtifactMaterials(root: THREE.Object3D): string[]
|
|
|
852
908
|
: []
|
|
853
909
|
}
|
|
854
910
|
|
|
911
|
+
/** Where the portable GLB conversion parks the authored opacity of glass it
|
|
912
|
+
* rewrote as transmission, so formats without transmission can fall back. */
|
|
913
|
+
export const GLASS_OPACITY_USERDATA = 'pascalGlassOpacity'
|
|
914
|
+
|
|
855
915
|
function cloneMaterialForUsdz(material: THREE.Material): THREE.Material {
|
|
856
916
|
const clone = material.clone()
|
|
857
917
|
clone.side = THREE.FrontSide
|
|
918
|
+
// USDPreviewSurface has no transmission and USDZExporter only writes
|
|
919
|
+
// `inputs:opacity`; hand glass back its authored opacity for Quick Look.
|
|
920
|
+
const physical = clone as THREE.MeshPhysicalMaterial
|
|
921
|
+
const authoredOpacity = clone.userData[GLASS_OPACITY_USERDATA]
|
|
922
|
+
if (
|
|
923
|
+
physical.isMeshPhysicalMaterial &&
|
|
924
|
+
physical.transmission > 0 &&
|
|
925
|
+
typeof authoredOpacity === 'number'
|
|
926
|
+
) {
|
|
927
|
+
physical.transmission = 0
|
|
928
|
+
physical.transparent = true
|
|
929
|
+
physical.opacity = authoredOpacity
|
|
930
|
+
}
|
|
858
931
|
return clone
|
|
859
932
|
}
|
|
860
933
|
|
|
@@ -925,7 +998,7 @@ export function createUsdzScene(source: THREE.Object3D): THREE.Object3D {
|
|
|
925
998
|
clone.visible = object.visible
|
|
926
999
|
clone.layers.mask = object.layers.mask
|
|
927
1000
|
clone.renderOrder = object.renderOrder
|
|
928
|
-
clone.userData =
|
|
1001
|
+
clone.userData = cloneExportUserData(object.userData)
|
|
929
1002
|
for (const child of object.children) clone.add(cloneObject(child))
|
|
930
1003
|
return clone
|
|
931
1004
|
}
|
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
import
|
|
1
|
+
import type { Manifold as ManifoldSolid, ManifoldToplevel } from 'manifold-3d'
|
|
2
2
|
import type { PrintShellCompileDiagnostic } from './print-shell-compiler-baseline'
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
ManifoldCompileOutput,
|
|
5
|
+
ManifoldMeshData,
|
|
6
|
+
ManifoldRuntimeOptions,
|
|
7
|
+
} from './print-shell-compiler-protocol'
|
|
4
8
|
|
|
5
9
|
let modulePromise: Promise<ManifoldToplevel> | null = null
|
|
6
10
|
const MANIFOLD_OUTPUT_WELD_EPSILON_METERS = 2e-5
|
|
@@ -8,14 +12,59 @@ const COLLINEAR_SEAM_CROSS_LENGTH_SQ = 1e-20
|
|
|
8
12
|
|
|
9
13
|
type Triangle = [number, number, number]
|
|
10
14
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
15
|
+
type ManifoldFactory = (config?: {
|
|
16
|
+
locateFile?: (path: string) => string
|
|
17
|
+
}) => Promise<ManifoldToplevel>
|
|
18
|
+
|
|
19
|
+
// manifold-3d's emscripten glue awaits import('node:module') behind a Node
|
|
20
|
+
// check. The branch never executes in a browser, but webpack refuses to
|
|
21
|
+
// *build* a graph that can reach it, so a static specifier here poisons every
|
|
22
|
+
// external bundler that compiles this package's source (#715). The factory is
|
|
23
|
+
// therefore loaded through an import() no bundler can trace: the bare
|
|
24
|
+
// specifier resolves wherever node-style resolution exists at runtime (bun
|
|
25
|
+
// tests, dev servers, bundlers that inline it anyway), and the version-pinned
|
|
26
|
+
// CDN copy covers bundled browser builds that left the specifier unresolved —
|
|
27
|
+
// emscripten then locates manifold.wasm relative to the glue's own URL. Hosts
|
|
28
|
+
// that can't reach the CDN (offline, CSP) pass their own URLs through
|
|
29
|
+
// configureManifoldRuntime.
|
|
30
|
+
const MANIFOLD_VERSION = '3.5.1'
|
|
31
|
+
const FALLBACK_MODULE_URL = `https://cdn.jsdelivr.net/npm/manifold-3d@${MANIFOLD_VERSION}/manifold.js`
|
|
32
|
+
|
|
33
|
+
function importUntraced(specifier: string): Promise<{ default: ManifoldFactory }> {
|
|
34
|
+
return import(/* webpackIgnore: true */ /* @vite-ignore */ specifier)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function loadManifoldFactory(moduleUrl?: string): Promise<ManifoldFactory> {
|
|
38
|
+
const specifiers = moduleUrl ? [moduleUrl] : ['manifold-3d', FALLBACK_MODULE_URL]
|
|
39
|
+
let lastError: unknown
|
|
40
|
+
for (const specifier of specifiers) {
|
|
41
|
+
try {
|
|
42
|
+
return (await importUntraced(specifier)).default
|
|
43
|
+
} catch (error) {
|
|
44
|
+
lastError = error
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
throw lastError instanceof Error ? lastError : new Error('Failed to load manifold-3d.')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function getManifoldModule(runtime?: ManifoldRuntimeOptions): Promise<ManifoldToplevel> {
|
|
51
|
+
modulePromise ??= loadManifoldFactory(runtime?.moduleUrl)
|
|
52
|
+
.then((factory) => {
|
|
53
|
+
const wasmUrl = runtime?.wasmUrl
|
|
54
|
+
return factory(wasmUrl ? { locateFile: () => wasmUrl } : undefined)
|
|
55
|
+
})
|
|
56
|
+
.then((module) => {
|
|
14
57
|
module.setup()
|
|
15
58
|
return module
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
|
|
59
|
+
})
|
|
60
|
+
try {
|
|
61
|
+
return await modulePromise
|
|
62
|
+
} catch (error) {
|
|
63
|
+
// A transient load failure (offline, blocked CDN) must not poison every
|
|
64
|
+
// later compile attempt with the cached rejection.
|
|
65
|
+
modulePromise = null
|
|
66
|
+
throw error
|
|
67
|
+
}
|
|
19
68
|
}
|
|
20
69
|
|
|
21
70
|
function manifoldMesh(
|
|
@@ -211,7 +260,7 @@ function elapsed(startedAt: number): number {
|
|
|
211
260
|
|
|
212
261
|
export async function compileManifoldMeshData(
|
|
213
262
|
meshes: ManifoldMeshData[],
|
|
214
|
-
|
|
263
|
+
runtime?: ManifoldRuntimeOptions,
|
|
215
264
|
): Promise<ManifoldCompileOutput> {
|
|
216
265
|
const startedAt = performance.now()
|
|
217
266
|
const sourceNodeIds = Array.from(new Set(meshes.map((mesh) => mesh.nodeId))).sort()
|
|
@@ -238,7 +287,7 @@ export async function compileManifoldMeshData(
|
|
|
238
287
|
}
|
|
239
288
|
|
|
240
289
|
try {
|
|
241
|
-
const module = await getManifoldModule(
|
|
290
|
+
const module = await getManifoldModule(runtime)
|
|
242
291
|
for (const mesh of meshes) {
|
|
243
292
|
try {
|
|
244
293
|
solids.push(new module.Manifold(manifoldMesh(module, mesh)))
|
|
@@ -16,12 +16,25 @@ import {
|
|
|
16
16
|
import type {
|
|
17
17
|
ManifoldCompileOutput,
|
|
18
18
|
ManifoldMeshData,
|
|
19
|
+
ManifoldRuntimeOptions,
|
|
19
20
|
ManifoldWorkerRequest,
|
|
20
21
|
ManifoldWorkerResponse,
|
|
21
22
|
} from './print-shell-compiler-protocol'
|
|
22
23
|
|
|
23
24
|
const WORKER_TIMEOUT_MS = 60_000
|
|
24
25
|
|
|
26
|
+
let manifoldRuntime: ManifoldRuntimeOptions | undefined
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Overrides where the print-export worker loads the manifold-3d module and
|
|
30
|
+
* wasm from. See the loader in print-shell-compiler-manifold-core.ts for the
|
|
31
|
+
* default resolution order; hosts with restrictive networks should call this
|
|
32
|
+
* with self-hosted asset URLs before the first print export.
|
|
33
|
+
*/
|
|
34
|
+
export function configureManifoldRuntime(options: ManifoldRuntimeOptions | undefined): void {
|
|
35
|
+
manifoldRuntime = options
|
|
36
|
+
}
|
|
37
|
+
|
|
25
38
|
export type ManifoldCompileRunner = (meshes: ManifoldMeshData[]) => Promise<ManifoldCompileOutput>
|
|
26
39
|
|
|
27
40
|
export type SemanticManifoldCompileOptions = SemanticPrintCompileOptions & {
|
|
@@ -76,7 +89,7 @@ export const runManifoldWorker: ManifoldCompileRunner = (meshes) => {
|
|
|
76
89
|
const activeWorker = getWorker()
|
|
77
90
|
const id = nextRequestId
|
|
78
91
|
nextRequestId += 1
|
|
79
|
-
const request: ManifoldWorkerRequest = { id, meshes }
|
|
92
|
+
const request: ManifoldWorkerRequest = { id, meshes, runtime: manifoldRuntime }
|
|
80
93
|
const transfer = meshes.flatMap((mesh) => [
|
|
81
94
|
mesh.positions.buffer as ArrayBuffer,
|
|
82
95
|
mesh.indices.buffer as ArrayBuffer,
|
|
@@ -10,7 +10,7 @@ const workerScope = self as unknown as {
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
workerScope.addEventListener('message', async (event) => {
|
|
13
|
-
const output = await compileManifoldMeshData(event.data.meshes)
|
|
13
|
+
const output = await compileManifoldMeshData(event.data.meshes, event.data.runtime)
|
|
14
14
|
const response: ManifoldWorkerResponse = { id: event.data.id, ...output }
|
|
15
15
|
const transfer: Transferable[] = []
|
|
16
16
|
if (response.status === 'compiled') {
|
|
@@ -22,9 +22,17 @@ export type ManifoldCompileOutput =
|
|
|
22
22
|
durationMs: number
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
export type ManifoldRuntimeOptions = {
|
|
26
|
+
/** URL of the manifold-3d emscripten glue module. Defaults to the pinned CDN copy. */
|
|
27
|
+
moduleUrl?: string
|
|
28
|
+
/** URL of manifold.wasm. Defaults to resolving relative to the glue module. */
|
|
29
|
+
wasmUrl?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
25
32
|
export type ManifoldWorkerRequest = {
|
|
26
33
|
id: number
|
|
27
34
|
meshes: ManifoldMeshData[]
|
|
35
|
+
runtime?: ManifoldRuntimeOptions
|
|
28
36
|
}
|
|
29
37
|
|
|
30
38
|
export type ManifoldWorkerResponse = ManifoldCompileOutput & { id: number }
|
|
@@ -382,7 +382,7 @@ function parseClipboardPayload(text: string): ClipboardPayload | null {
|
|
|
382
382
|
if (materials.some((result) => !result.success)) return null
|
|
383
383
|
|
|
384
384
|
const parsedNodes = nodes.filter((result) => result.success).map((result) => result.data)
|
|
385
|
-
const nodeIds = new Set(parsedNodes.map((node) => node.id))
|
|
385
|
+
const nodeIds = new Set<AnyNodeId>(parsedNodes.map((node) => node.id))
|
|
386
386
|
const rootIds = candidate.rootIds as AnyNodeId[]
|
|
387
387
|
if (rootIds.length === 0 || rootIds.some((id) => !nodeIds.has(id))) return null
|
|
388
388
|
|