@pascal-app/editor 0.9.2 → 1.0.0-beta.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 +9 -7
- package/src/components/editor/bake-exporter.tsx +3 -1
- package/src/components/editor/bake-thumbnail.tsx +95 -0
- package/src/components/editor/camera-dragging-lifecycle.test.ts +49 -0
- package/src/components/editor/camera-dragging-lifecycle.ts +41 -0
- package/src/components/editor/custom-camera-controls.tsx +307 -212
- package/src/components/editor/delete-confirmation-dialog.tsx +54 -0
- package/src/components/editor/elevation-3d-guide-layer.tsx +120 -0
- package/src/components/editor/first-person/build-collider-world.test.ts +66 -2
- package/src/components/editor/first-person/build-collider-world.ts +20 -0
- package/src/components/editor/first-person/terrain-collider.test.ts +132 -0
- package/src/components/editor/first-person/terrain-collider.ts +212 -0
- package/src/components/editor/first-person-controls.tsx +295 -132
- package/src/components/editor/floating-action-menu.tsx +59 -65
- package/src/components/editor/floorplan-background-selection.test.ts +16 -2
- package/src/components/editor/floorplan-background-selection.ts +4 -18
- package/src/components/editor/floorplan-camera-sync.test.ts +249 -0
- package/src/components/editor/floorplan-camera-sync.ts +213 -0
- package/src/components/editor/floorplan-mode-coordinator.tsx +82 -0
- package/src/components/editor/floorplan-navigation-presentation.test.ts +142 -0
- package/src/components/editor/floorplan-navigation-presentation.ts +156 -0
- package/src/components/editor/floorplan-panel.tsx +931 -861
- package/src/components/editor/group-actions.ts +155 -15
- package/src/components/editor/group-floating-action-menu.tsx +1 -1
- package/src/components/editor/group-rotate-handle.tsx +1 -1
- package/src/components/editor/group-selection-box-3d.tsx +1 -1
- package/src/components/editor/handles/handle-drag-history.test.ts +96 -0
- package/src/components/editor/handles/handle-drag-history.ts +14 -0
- package/src/components/editor/handles/preview-overrides.test.ts +26 -0
- package/src/components/editor/handles/preview-overrides.ts +13 -0
- package/src/components/editor/handles/resize-snap.test.ts +52 -0
- package/src/components/editor/handles/resize-snap.ts +23 -0
- package/src/components/editor/handles/use-handle-drag.ts +17 -7
- package/src/components/editor/index.tsx +42 -9
- package/src/components/editor/measurement-pill.tsx +6 -1
- package/src/components/editor/node-arrow-handles.tsx +51 -15
- package/src/components/editor/quick-measurement-card.tsx +9 -3
- package/src/components/editor/quick-measurement-hud.tsx +7 -1
- package/src/components/editor/selection-manager.tsx +3 -1
- package/src/components/editor/site-edge-labels.tsx +24 -2
- package/src/components/editor/snapshot-capture-overlay.tsx +4 -1
- package/src/components/editor/thumbnail-generator.tsx +66 -294
- package/src/components/editor/use-floorplan-hit-testing.test.ts +82 -0
- package/src/components/editor/use-floorplan-hit-testing.ts +144 -161
- package/src/components/editor/use-mesh-settle-epoch.ts +1 -0
- package/src/components/editor/wall-measurement-label.tsx +8 -16
- package/src/components/editor/wall-move-side-handles.tsx +193 -18
- package/src/components/editor/wall-opening-highlights.tsx +63 -32
- package/src/components/editor/wall-snap-beacon-layer.tsx +6 -4
- package/src/components/editor-2d/floorplan-alignment-guide-layer.tsx +2 -1
- package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +39 -8
- package/src/components/editor-2d/floorplan-cursor-indicator-position.test.ts +31 -0
- package/src/components/editor-2d/floorplan-cursor-indicator-position.ts +39 -0
- package/src/components/editor-2d/floorplan-group-move.tsx +47 -4
- package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +21 -5
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +72 -0
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +93 -59
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +27 -0
- package/src/components/editor-2d/floorplan-render-context.test.ts +27 -0
- package/src/components/editor-2d/floorplan-render-context.tsx +113 -8
- package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +285 -0
- package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +516 -0
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +236 -0
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +618 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +369 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +431 -32
- package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +75 -1
- package/src/components/editor-2d/renderers/floorplan-label-angle.ts +92 -0
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +93 -28
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +279 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +621 -281
- package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +20 -10
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +12 -7
- package/src/components/tools/fence/fence-drafting.ts +27 -0
- package/src/components/tools/item/use-draft-node.ts +131 -72
- package/src/components/tools/item/use-placement-coordinator.tsx +52 -4
- package/src/components/tools/registry/move-registry-node-tool.tsx +75 -4
- package/src/components/tools/roof/roof-tool.tsx +16 -0
- package/src/components/tools/shared/floor-stack-preview.ts +4 -0
- package/src/components/tools/shared/horizontal-construction-plane.ts +123 -0
- package/src/components/tools/shared/pointer-support-cap.test.ts +98 -0
- package/src/components/tools/shared/pointer-support-cap.ts +273 -0
- package/src/components/tools/site/site-boundary-editor.tsx +13 -6
- package/src/components/tools/site/stroke-pointer-ownership.test.ts +91 -0
- package/src/components/tools/site/stroke-pointer-ownership.ts +57 -0
- package/src/components/tools/site/terrain-brush-cursor.tsx +247 -0
- package/src/components/tools/site/terrain-sculpt-grid.tsx +192 -0
- package/src/components/tools/site/terrain-sculpt-tool.tsx +356 -0
- package/src/components/tools/stair/stair-click-guard.test.ts +58 -0
- package/src/components/tools/stair/stair-click-guard.ts +72 -0
- package/src/components/tools/stair/stair-tool.tsx +98 -47
- package/src/components/tools/tool-manager.tsx +67 -9
- package/src/components/tools/wall/wall-drafting.test.ts +118 -1
- package/src/components/tools/wall/wall-drafting.ts +128 -9
- package/src/components/tools/wall/wall-snap-geometry.test.ts +8 -0
- package/src/components/tools/wall/wall-snap-geometry.ts +70 -3
- package/src/components/tools/zone/zone-tool.tsx +16 -0
- package/src/components/ui/action-menu/control-modes.tsx +4 -1
- package/src/components/ui/action-menu/measurement-control.tsx +107 -28
- package/src/components/ui/command-palette/editor-commands.tsx +14 -1
- package/src/components/ui/controls/material-paint-panel.tsx +7 -1
- package/src/components/ui/controls/material-picker.tsx +86 -7
- package/src/components/ui/controls/terrain-sculpt-panel.tsx +193 -0
- package/src/components/ui/floating-level-selector.tsx +73 -19
- package/src/components/ui/helpers/helper-manager.tsx +46 -0
- package/src/components/ui/panels/multi-selection-panel.tsx +57 -0
- package/src/components/ui/panels/panel-manager.tsx +14 -1
- package/src/components/ui/panels/parametric-inspector.tsx +4 -2
- package/src/components/ui/panels/selection-breakdown.test.ts +24 -0
- package/src/components/ui/panels/selection-breakdown.ts +20 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +14 -4
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +16 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +2 -1
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +2 -0
- package/src/components/viewer/viewer-controls-bar.tsx +466 -0
- package/src/components/viewer/viewer-scene-header.tsx +235 -0
- package/src/components/viewer-overlay.tsx +20 -655
- package/src/components/walkthrough-hud.tsx +111 -0
- package/src/hooks/use-grid-events.ts +24 -0
- package/src/hooks/use-keyboard.ts +53 -30
- package/src/index.tsx +137 -4
- package/src/lib/active-placement-surface.test.ts +24 -0
- package/src/lib/active-placement-surface.ts +8 -1
- package/src/lib/camera-pose.test.ts +237 -0
- package/src/lib/camera-pose.ts +172 -0
- package/src/lib/direct-manipulation.test.ts +83 -0
- package/src/lib/direct-manipulation.ts +9 -0
- package/src/lib/door-interaction.ts +1 -1
- package/src/lib/elevation-guides.test.ts +107 -0
- package/src/lib/elevation-guides.ts +236 -0
- package/src/lib/floorplan/annotation-visibility.test.ts +327 -0
- package/src/lib/floorplan/annotation-visibility.ts +133 -0
- package/src/lib/floorplan/drawing-coordination.test.ts +46 -0
- package/src/lib/floorplan/drawing-coordination.ts +13 -0
- package/src/lib/floorplan/floorplan-export.test.ts +319 -0
- package/src/lib/floorplan/floorplan-export.tsx +745 -131
- package/src/lib/floorplan/floorplan-extension.test.ts +43 -0
- package/src/lib/floorplan/floorplan-extension.ts +181 -0
- package/src/lib/floorplan/floorplan-mode.test.ts +120 -0
- package/src/lib/floorplan/floorplan-mode.ts +73 -0
- package/src/lib/floorplan/floorplan-pdfkit-document.ts +194 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +107 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +680 -0
- package/src/lib/floorplan/geometry.ts +2 -3
- package/src/lib/fresh-planar-placement.test.ts +44 -1
- package/src/lib/fresh-planar-placement.ts +21 -4
- package/src/lib/glb-export.test.ts +116 -1
- package/src/lib/glb-export.ts +204 -7
- package/src/lib/ground-surface.ts +95 -0
- package/src/lib/history.test.ts +96 -0
- package/src/lib/history.ts +22 -0
- package/src/lib/interaction/overlay-policy.test.ts +2 -1
- package/src/lib/interaction/scope.ts +40 -8
- package/src/lib/level-duplication.test.ts +2 -1
- package/src/lib/measurements.test.ts +5 -0
- package/src/lib/measurements.ts +12 -1
- package/src/lib/quick-action-feedback.ts +33 -0
- package/src/lib/quick-action-nodes.test.ts +94 -0
- package/src/lib/quick-action-nodes.ts +47 -0
- package/src/lib/scene-clipboard.test.ts +130 -1
- package/src/lib/scene-clipboard.ts +237 -13
- package/src/lib/selection-routing.test.ts +16 -1
- package/src/lib/selection-routing.ts +5 -0
- package/src/lib/sfx-bus.ts +33 -2
- package/src/lib/sfx-player.test.ts +148 -6
- package/src/lib/sfx-player.ts +156 -8
- package/src/lib/site-boundary.test.ts +53 -0
- package/src/lib/site-boundary.ts +27 -0
- package/src/lib/snapping-mode.test.ts +29 -2
- package/src/lib/snapping-mode.ts +15 -3
- package/src/lib/stair-duplication.test.ts +103 -0
- package/src/lib/stair-duplication.ts +57 -85
- package/src/lib/stair-levels.ts +2 -0
- package/src/lib/terrain-sculpt.test.ts +429 -0
- package/src/lib/terrain-sculpt.ts +301 -0
- package/src/lib/terrain-verb-color.test.ts +51 -0
- package/src/lib/terrain-verb-color.ts +58 -0
- package/src/lib/touch-gesture-priority.test.ts +45 -0
- package/src/lib/touch-gesture-priority.ts +30 -0
- package/src/lib/window-interaction.ts +1 -1
- package/src/store/camera-pose-store.test.ts +29 -0
- package/src/store/camera-pose-store.ts +27 -0
- package/src/store/live-draft-preview-stores.test.ts +136 -0
- package/src/store/navigation-sync-pose-store.test.ts +34 -0
- package/src/store/navigation-sync-pose-store.ts +27 -0
- package/src/store/terrain-sculpt-mode.test.ts +223 -0
- package/src/store/use-delete-confirmation.ts +27 -0
- package/src/store/use-drawing-view.test.ts +23 -0
- package/src/store/use-drawing-view.ts +84 -0
- package/src/store/use-editor.tsx +184 -15
- package/src/store/use-elevation-guides.ts +40 -0
- package/src/store/use-fence-curve-draft.ts +38 -8
- package/src/store/use-first-person-hud.ts +40 -0
- package/src/store/use-floorplan-annotation-visibility.ts +60 -0
- package/src/store/use-floorplan-draft-preview.ts +54 -4
- package/src/store/use-floorplan-mode.ts +110 -0
- package/src/store/use-interaction-scope.test.ts +23 -3
- package/src/store/use-interaction-scope.ts +8 -0
- package/src/store/use-measurement-draft.test.ts +29 -0
- package/src/store/use-measurement-draft.ts +33 -6
- package/src/store/use-path-draft-preview.ts +90 -0
- package/src/store/use-stair-build-preview.ts +12 -2
- package/src/lib/floorplan/selection-tool.ts +0 -271
|
@@ -8,10 +8,17 @@ import {
|
|
|
8
8
|
findLevelAncestorId,
|
|
9
9
|
nodeRegistry,
|
|
10
10
|
registerNode,
|
|
11
|
+
type SceneCommit,
|
|
12
|
+
subscribeSceneCommits,
|
|
11
13
|
useScene,
|
|
12
14
|
} from '@pascal-app/core'
|
|
13
15
|
import { z } from 'zod'
|
|
14
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
commitFreshPlacementSubtree,
|
|
18
|
+
createFreshPlacementSubtree,
|
|
19
|
+
duplicatesAsFreshSubtree,
|
|
20
|
+
prepareFreshPlacementRootDuplicate,
|
|
21
|
+
} from './fresh-planar-placement'
|
|
15
22
|
|
|
16
23
|
type RafFn = (cb: (time: number) => void) => number
|
|
17
24
|
;(globalThis as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= ((
|
|
@@ -170,12 +177,15 @@ describe('commitFreshPlacementSubtree', () => {
|
|
|
170
177
|
})
|
|
171
178
|
|
|
172
179
|
test('commits a fresh draft as one undoable clean subtree', () => {
|
|
180
|
+
const commits: SceneCommit[] = []
|
|
181
|
+
const unsubscribe = subscribeSceneCommits((commit) => commits.push(commit))
|
|
173
182
|
useScene.temporal.getState().pause()
|
|
174
183
|
|
|
175
184
|
const committedId = commitFreshPlacementSubtree(SHELF_ID, {
|
|
176
185
|
position: [2, 0, 3],
|
|
177
186
|
visible: true,
|
|
178
187
|
} as Partial<AnyNode>)
|
|
188
|
+
unsubscribe()
|
|
179
189
|
|
|
180
190
|
expect(committedId).toBeTruthy()
|
|
181
191
|
expect(committedId).not.toBe(SHELF_ID)
|
|
@@ -192,6 +202,12 @@ describe('commitFreshPlacementSubtree', () => {
|
|
|
192
202
|
expect((useScene.getState().nodes[LEVEL_ID] as { children: AnyNodeId[] }).children).toEqual([
|
|
193
203
|
finalId,
|
|
194
204
|
])
|
|
205
|
+
expect(commits).toHaveLength(1)
|
|
206
|
+
expect(commits[0]?.origin).toBe('local')
|
|
207
|
+
expect(commits[0]?.before.nodes[SHELF_ID]).toBeUndefined()
|
|
208
|
+
expect(commits[0]?.before.nodes[finalId]).toBeUndefined()
|
|
209
|
+
expect(commits[0]?.current.nodes[SHELF_ID]).toBeUndefined()
|
|
210
|
+
expect(commits[0]?.current.nodes[finalId]).toEqual(committed)
|
|
195
211
|
|
|
196
212
|
useScene.temporal.getState().resume()
|
|
197
213
|
useScene.temporal.getState().undo()
|
|
@@ -201,6 +217,33 @@ describe('commitFreshPlacementSubtree', () => {
|
|
|
201
217
|
expect((useScene.getState().nodes[LEVEL_ID] as { children: AnyNodeId[] }).children).toEqual([])
|
|
202
218
|
})
|
|
203
219
|
|
|
220
|
+
test('uses the subtree contract for childless variants and never aliases root-only children', () => {
|
|
221
|
+
registerCabinetClonePrepTestKind()
|
|
222
|
+
const childlessCabinet = {
|
|
223
|
+
...shelf(),
|
|
224
|
+
type: 'cabinet',
|
|
225
|
+
children: [],
|
|
226
|
+
metadata: { isTransient: true, label: 'source' },
|
|
227
|
+
} as AnyNode
|
|
228
|
+
expect(duplicatesAsFreshSubtree(childlessCabinet)).toBe(true)
|
|
229
|
+
|
|
230
|
+
const source = {
|
|
231
|
+
...shelf(),
|
|
232
|
+
children: ['item_original' as AnyNodeId],
|
|
233
|
+
metadata: { isTransient: true, label: 'source' },
|
|
234
|
+
} as AnyNode
|
|
235
|
+
const duplicate = prepareFreshPlacementRootDuplicate(source) as AnyNode & {
|
|
236
|
+
children: AnyNodeId[]
|
|
237
|
+
id?: AnyNodeId
|
|
238
|
+
metadata?: Record<string, unknown>
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
expect(duplicate.id).toBeUndefined()
|
|
242
|
+
expect(duplicate.children).toEqual([])
|
|
243
|
+
expect(duplicate.metadata).toEqual({ isNew: true, label: 'source' })
|
|
244
|
+
expect((source as AnyNode & { children: AnyNodeId[] }).children).toEqual(['item_original'])
|
|
245
|
+
})
|
|
246
|
+
|
|
204
247
|
test('commits a duplicated cabinet draft without deleting the original modules', () => {
|
|
205
248
|
seedCabinetRun()
|
|
206
249
|
useScene.temporal.getState().clear()
|
|
@@ -27,10 +27,27 @@ function duplicableConfigFor(node: AnyNode): DuplicableConfig | null {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
export function duplicatesAsFreshSubtree(node: AnyNode): boolean {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
return duplicableConfigFor(node)?.subtree === true
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Prepares a non-subtree duplicate without retaining ownership of the
|
|
35
|
+
* original node's children. Subtree-capable kinds take the path above and
|
|
36
|
+
* receive fresh descendant IDs; every other kind duplicates only its root.
|
|
37
|
+
*/
|
|
38
|
+
export function prepareFreshPlacementRootDuplicate(node: AnyNode): AnyNode {
|
|
39
|
+
const duplicate = structuredClone(node) as unknown as Record<string, unknown> & {
|
|
40
|
+
id?: AnyNodeId
|
|
41
|
+
children?: unknown
|
|
42
|
+
metadata?: unknown
|
|
43
|
+
}
|
|
44
|
+
delete duplicate.id
|
|
45
|
+
if (Array.isArray(duplicate.children)) duplicate.children = []
|
|
46
|
+
duplicate.metadata = {
|
|
47
|
+
...getPlacementMetadataRecord(stripPlacementMetadataFlags(duplicate.metadata)),
|
|
48
|
+
isNew: true,
|
|
49
|
+
}
|
|
50
|
+
return duplicate as unknown as AnyNode
|
|
34
51
|
}
|
|
35
52
|
|
|
36
53
|
/**
|
|
@@ -2,7 +2,13 @@ import { afterEach, describe, expect, test } from 'bun:test'
|
|
|
2
2
|
import { type AnyNode, DoorNode, registerNode, sceneRegistry } from '@pascal-app/core'
|
|
3
3
|
import { buildDoorPreviewMesh } from '@pascal-app/viewer'
|
|
4
4
|
import * as THREE from 'three'
|
|
5
|
-
import {
|
|
5
|
+
import type { GLTFWriter } from 'three/examples/jsm/exporters/GLTFExporter.js'
|
|
6
|
+
import { prepareSceneForExport, writeTextureReferenceExtras } from './glb-export'
|
|
7
|
+
|
|
8
|
+
// The reference module reads the storage origin lazily on first use, so
|
|
9
|
+
// setting the env here (before any validation call) pins it for the file.
|
|
10
|
+
process.env.NEXT_PUBLIC_SUPABASE_URL ??= 'https://test-storage.supabase.co'
|
|
11
|
+
const STORAGE_ORIGIN = new URL(process.env.NEXT_PUBLIC_SUPABASE_URL).origin
|
|
6
12
|
|
|
7
13
|
afterEach(() => {
|
|
8
14
|
sceneRegistry.clear()
|
|
@@ -62,6 +68,79 @@ describe('prepareSceneForExport', () => {
|
|
|
62
68
|
expect(meshes[0]!.material).toBe(meshes[1]!.material)
|
|
63
69
|
})
|
|
64
70
|
|
|
71
|
+
test('reference mode swaps stamped compressed textures and leaves unstamped textures embedded', () => {
|
|
72
|
+
const root = new THREE.Group()
|
|
73
|
+
const stamped = new THREE.CompressedTexture([], 4, 4)
|
|
74
|
+
stamped.wrapS = THREE.MirroredRepeatWrapping
|
|
75
|
+
stamped.wrapT = THREE.ClampToEdgeWrapping
|
|
76
|
+
stamped.repeat.set(2, 3)
|
|
77
|
+
stamped.offset.set(0.25, 0.5)
|
|
78
|
+
stamped.center.set(0.5, 0.5)
|
|
79
|
+
stamped.rotation = 0.75
|
|
80
|
+
stamped.flipY = false
|
|
81
|
+
stamped.colorSpace = THREE.SRGBColorSpace
|
|
82
|
+
stamped.updateMatrix()
|
|
83
|
+
stamped.userData.pascalTextureRef = {
|
|
84
|
+
v: 1,
|
|
85
|
+
kind: 'library-material',
|
|
86
|
+
src: `${STORAGE_ORIGIN}/storage/v1/object/public/materials/user/material/oak_basecolor_512.ktx2`,
|
|
87
|
+
map: 'basecolor',
|
|
88
|
+
colorSpace: 'srgb',
|
|
89
|
+
}
|
|
90
|
+
const unstamped = new THREE.DataTexture(new Uint8Array([128, 128, 255, 255]), 1, 1)
|
|
91
|
+
root.add(
|
|
92
|
+
meshWithNodeMaterial(nodeMaterial({ map: stamped, normalMap: unstamped })),
|
|
93
|
+
meshWithNodeMaterial(nodeMaterial({ map: stamped })),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
const { scene } = prepareSceneForExport(root, {}, { textures: 'reference' })
|
|
97
|
+
|
|
98
|
+
const material = (scene.children[0] as THREE.Mesh).material as THREE.MeshStandardMaterial
|
|
99
|
+
const placeholder = material.map as THREE.DataTexture
|
|
100
|
+
expect(placeholder).not.toBe(stamped)
|
|
101
|
+
expect(placeholder.isDataTexture).toBe(true)
|
|
102
|
+
expect(
|
|
103
|
+
(placeholder as THREE.DataTexture & { isCompressedTexture?: boolean }).isCompressedTexture,
|
|
104
|
+
).toBeUndefined()
|
|
105
|
+
expect(placeholder.image.width).toBe(1)
|
|
106
|
+
expect(placeholder.image.height).toBe(1)
|
|
107
|
+
expect(Array.from(placeholder.image.data!)).toEqual([255, 255, 255, 255])
|
|
108
|
+
expect(placeholder.wrapS).toBe(stamped.wrapS)
|
|
109
|
+
expect(placeholder.wrapT).toBe(stamped.wrapT)
|
|
110
|
+
expect(placeholder.repeat.toArray()).toEqual(stamped.repeat.toArray())
|
|
111
|
+
expect(placeholder.offset.toArray()).toEqual(stamped.offset.toArray())
|
|
112
|
+
expect(placeholder.center.toArray()).toEqual(stamped.center.toArray())
|
|
113
|
+
expect(placeholder.rotation).toBe(stamped.rotation)
|
|
114
|
+
expect(placeholder.flipY).toBe(stamped.flipY)
|
|
115
|
+
expect(placeholder.colorSpace).toBe(stamped.colorSpace)
|
|
116
|
+
expect(placeholder.userData.pascalTextureRef).toEqual(stamped.userData.pascalTextureRef)
|
|
117
|
+
expect(material.normalMap).toBe(unstamped)
|
|
118
|
+
const sharedMaterial = (scene.children[1] as THREE.Mesh).material as THREE.MeshStandardMaterial
|
|
119
|
+
expect(sharedMaterial.map).toBe(placeholder)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test('writes identical texture-reference extras to the texture and image definitions', () => {
|
|
123
|
+
const texture = new THREE.DataTexture(new Uint8Array([255, 255, 255, 255]), 1, 1)
|
|
124
|
+
const ref = {
|
|
125
|
+
v: 1,
|
|
126
|
+
kind: 'item-glb',
|
|
127
|
+
src: `${STORAGE_ORIGIN}/storage/v1/object/public/items/system/chair/models/chair.glb`,
|
|
128
|
+
imageIndex: 3,
|
|
129
|
+
map: 'normal',
|
|
130
|
+
colorSpace: 'linear',
|
|
131
|
+
}
|
|
132
|
+
texture.userData.pascalTextureRef = ref
|
|
133
|
+
const imageDef: { extras?: Record<string, unknown> } = {}
|
|
134
|
+
const textureDef: { source: number; extras?: Record<string, unknown> } = { source: 0 }
|
|
135
|
+
const writer = { json: { images: [imageDef] } } as unknown as GLTFWriter
|
|
136
|
+
|
|
137
|
+
writeTextureReferenceExtras(writer, texture, textureDef)
|
|
138
|
+
|
|
139
|
+
expect(textureDef.extras?.pascalTextureRef).toEqual(ref)
|
|
140
|
+
expect(imageDef.extras?.pascalTextureRef).toEqual(ref)
|
|
141
|
+
expect(textureDef.extras?.pascalTextureRef).toEqual(imageDef.extras?.pascalTextureRef)
|
|
142
|
+
})
|
|
143
|
+
|
|
65
144
|
test('strips editor overlays that live off the scene layer', () => {
|
|
66
145
|
const root = new THREE.Group()
|
|
67
146
|
const realMesh = meshWithNodeMaterial(nodeMaterial())
|
|
@@ -78,6 +157,22 @@ describe('prepareSceneForExport', () => {
|
|
|
78
157
|
expect(meshes).toHaveLength(1)
|
|
79
158
|
})
|
|
80
159
|
|
|
160
|
+
test('strips presentation-only geometry marked by its renderer', () => {
|
|
161
|
+
const root = new THREE.Group()
|
|
162
|
+
const siteGround = meshWithNodeMaterial(nodeMaterial())
|
|
163
|
+
const horizonDisc = meshWithNodeMaterial(nodeMaterial())
|
|
164
|
+
horizonDisc.userData.pascalExport = 'strip'
|
|
165
|
+
root.add(siteGround, horizonDisc)
|
|
166
|
+
|
|
167
|
+
const { scene } = prepareSceneForExport(root, {})
|
|
168
|
+
|
|
169
|
+
const meshes: THREE.Mesh[] = []
|
|
170
|
+
scene.traverse((object) => {
|
|
171
|
+
if ((object as THREE.Mesh).isMesh) meshes.push(object as THREE.Mesh)
|
|
172
|
+
})
|
|
173
|
+
expect(meshes).toHaveLength(1)
|
|
174
|
+
})
|
|
175
|
+
|
|
81
176
|
test('neutralises an invisible hitbox root but keeps its visible children', () => {
|
|
82
177
|
// Door/window roots are selection hitboxes: a box geometry with an invisible
|
|
83
178
|
// material (object stays visible). Left intact it would plug the wall opening.
|
|
@@ -107,6 +202,26 @@ describe('prepareSceneForExport', () => {
|
|
|
107
202
|
expect(visibleChildren).toHaveLength(1)
|
|
108
203
|
})
|
|
109
204
|
|
|
205
|
+
test('fills undefined slots in an array material so no undefined survives the prune', () => {
|
|
206
|
+
// Multi-material / group geometry where one slot was never assigned:
|
|
207
|
+
// `mesh.material = [validMat, undefined]`. The scalar-null guard doesn't
|
|
208
|
+
// catch this (an array is never `== null`), so the undefined slot used to
|
|
209
|
+
// reach GLTFExporter and crash on `material.isShaderMaterial`.
|
|
210
|
+
const root = new THREE.Group()
|
|
211
|
+
const mesh = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1))
|
|
212
|
+
mesh.material = [nodeMaterial(), undefined as unknown as THREE.Material]
|
|
213
|
+
root.add(mesh)
|
|
214
|
+
|
|
215
|
+
const { scene } = prepareSceneForExport(root, {})
|
|
216
|
+
|
|
217
|
+
const exported = scene.children[0] as THREE.Mesh
|
|
218
|
+
const materials = exported.material as THREE.Material[]
|
|
219
|
+
expect(Array.isArray(materials)).toBe(true)
|
|
220
|
+
expect(materials).toHaveLength(2)
|
|
221
|
+
// No undefined/null slot survives; every slot is a real material.
|
|
222
|
+
expect(materials.every((m) => m != null)).toBe(true)
|
|
223
|
+
})
|
|
224
|
+
|
|
110
225
|
test('stamps identity from the scene registry and strips other userData', () => {
|
|
111
226
|
const root = new THREE.Group()
|
|
112
227
|
const doorGroup = new THREE.Group()
|
package/src/lib/glb-export.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
type ZoneNode,
|
|
14
14
|
} from '@pascal-app/core'
|
|
15
15
|
import {
|
|
16
|
+
getPascalTextureRef,
|
|
16
17
|
poseDoorMovingParts,
|
|
17
18
|
poseWindowMovingParts,
|
|
18
19
|
SCENE_LAYER,
|
|
@@ -20,7 +21,11 @@ import {
|
|
|
20
21
|
} from '@pascal-app/viewer'
|
|
21
22
|
import type { Object3D } from 'three'
|
|
22
23
|
import * as THREE from 'three'
|
|
23
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
GLTFExporter,
|
|
26
|
+
type GLTFExporterPlugin,
|
|
27
|
+
type GLTFWriter,
|
|
28
|
+
} from 'three/examples/jsm/exporters/GLTFExporter.js'
|
|
24
29
|
import * as WebGPUTextureUtils from 'three/examples/jsm/utils/WebGPUTextureUtils.js'
|
|
25
30
|
|
|
26
31
|
/**
|
|
@@ -41,6 +46,10 @@ export type GlbExport = {
|
|
|
41
46
|
animations: THREE.AnimationClip[]
|
|
42
47
|
}
|
|
43
48
|
|
|
49
|
+
export type GlbExportOptions = {
|
|
50
|
+
textures?: 'embed' | 'reference'
|
|
51
|
+
}
|
|
52
|
+
|
|
44
53
|
/** Resolve after the next couple of animation frames, giving React/R3F time to
|
|
45
54
|
* commit and mount export-only geometry (e.g. instanced kinds' real meshes)
|
|
46
55
|
* before the exporter clones the scene graph. Callers must set
|
|
@@ -51,10 +60,63 @@ export function nextFrames(): Promise<void> {
|
|
|
51
60
|
})
|
|
52
61
|
}
|
|
53
62
|
|
|
63
|
+
type GltfExtrasDef = {
|
|
64
|
+
extras?: Record<string, unknown>
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
type TextureReferenceWriter = GLTFWriter & {
|
|
68
|
+
json: {
|
|
69
|
+
images?: GltfExtrasDef[]
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function getExportedImageIndex(textureDef: Record<string, unknown>): number | null {
|
|
74
|
+
if (Number.isInteger(textureDef.source)) return textureDef.source as number
|
|
75
|
+
const extensions = textureDef.extensions as Record<string, { source?: unknown }> | undefined
|
|
76
|
+
const source = extensions?.EXT_texture_webp?.source ?? extensions?.EXT_texture_avif?.source
|
|
77
|
+
return Number.isInteger(source) ? (source as number) : null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function writeTextureReferenceExtras(
|
|
81
|
+
writer: GLTFWriter,
|
|
82
|
+
texture: THREE.Texture,
|
|
83
|
+
textureDef: Record<string, unknown>,
|
|
84
|
+
) {
|
|
85
|
+
const ref = getPascalTextureRef(texture)
|
|
86
|
+
if (!ref) return
|
|
87
|
+
|
|
88
|
+
const imageIndex = getExportedImageIndex(textureDef)
|
|
89
|
+
const imageDef =
|
|
90
|
+
imageIndex === null ? undefined : (writer as TextureReferenceWriter).json.images?.[imageIndex]
|
|
91
|
+
if (!imageDef) {
|
|
92
|
+
throw new Error('GLTFExporter did not expose an image for a referenced Pascal texture')
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const textureWithExtras = textureDef as GltfExtrasDef
|
|
96
|
+
textureWithExtras.extras = {
|
|
97
|
+
...textureWithExtras.extras,
|
|
98
|
+
pascalTextureRef: ref,
|
|
99
|
+
}
|
|
100
|
+
imageDef.extras = {
|
|
101
|
+
...imageDef.extras,
|
|
102
|
+
pascalTextureRef: ref,
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function textureReferencePlugin(writer: GLTFWriter): GLTFExporterPlugin {
|
|
107
|
+
return {
|
|
108
|
+
writeTexture: (texture, textureDef) => {
|
|
109
|
+
writeTextureReferenceExtras(writer, texture, textureDef)
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
54
114
|
export async function exportSceneToGlb(
|
|
55
115
|
sceneGroup: Object3D,
|
|
56
116
|
nodes: Record<string, AnyNode>,
|
|
117
|
+
options: GlbExportOptions = {},
|
|
57
118
|
): Promise<ArrayBuffer> {
|
|
119
|
+
const textureMode = options.textures ?? 'embed'
|
|
58
120
|
emitter.emit('thumbnail:before-capture', undefined)
|
|
59
121
|
// Snap levels to their true stacked positions (like thumbnail capture) so the
|
|
60
122
|
// export always reflects the clean stacked building, regardless of the live
|
|
@@ -63,7 +125,10 @@ export async function exportSceneToGlb(
|
|
|
63
125
|
const restoreLevels = snapLevelsToTruePositions()
|
|
64
126
|
let prepared: ReturnType<typeof prepareSceneForExport>
|
|
65
127
|
try {
|
|
66
|
-
prepared =
|
|
128
|
+
prepared =
|
|
129
|
+
textureMode === 'reference'
|
|
130
|
+
? prepareSceneForExport(sceneGroup, nodes, { textures: 'reference' })
|
|
131
|
+
: prepareSceneForExport(sceneGroup, nodes)
|
|
67
132
|
} finally {
|
|
68
133
|
restoreLevels()
|
|
69
134
|
emitter.emit('thumbnail:after-capture', undefined)
|
|
@@ -71,6 +136,7 @@ export async function exportSceneToGlb(
|
|
|
71
136
|
const { scene: exportScene, animations } = prepared
|
|
72
137
|
|
|
73
138
|
const exporter = new GLTFExporter()
|
|
139
|
+
if (textureMode === 'reference') exporter.register(textureReferencePlugin)
|
|
74
140
|
// Painted finishes use KTX2 (GPU-compressed) maps; GLTFExporter can't read
|
|
75
141
|
// those directly. WebGPUTextureUtils blits each one to RGBA on its own
|
|
76
142
|
// offscreen renderer (passing the live renderer would resize/draw over the
|
|
@@ -112,6 +178,7 @@ export async function exportSceneToGlb(
|
|
|
112
178
|
export function prepareSceneForExport(
|
|
113
179
|
source: THREE.Object3D,
|
|
114
180
|
nodes: Record<string, AnyNode>,
|
|
181
|
+
options: GlbExportOptions = {},
|
|
115
182
|
): GlbExport {
|
|
116
183
|
const scene = source.clone(true)
|
|
117
184
|
const cloneByOriginal = pairClones(source, scene)
|
|
@@ -142,7 +209,7 @@ export function prepareSceneForExport(
|
|
|
142
209
|
|
|
143
210
|
pruneNonRenderableMeshes(scene, identityNodes)
|
|
144
211
|
sanitizeMaterialGroups(scene, identityNodes)
|
|
145
|
-
convertMaterials(scene)
|
|
212
|
+
convertMaterials(scene, options.textures ?? 'embed')
|
|
146
213
|
|
|
147
214
|
const { clips, clipNamesByNode } = bakeAnimationClips(cloneByOriginal, nodes)
|
|
148
215
|
|
|
@@ -185,6 +252,10 @@ const PLACEHOLDER_MATERIAL = new THREE.MeshBasicMaterial({ visible: false })
|
|
|
185
252
|
|
|
186
253
|
/**
|
|
187
254
|
* Strip everything that must not bake into the model:
|
|
255
|
+
* - Renderer-owned presentation geometry explicitly marked
|
|
256
|
+
* `userData.pascalExport = 'strip'` (for example the site's 800 m horizon
|
|
257
|
+
* disc). These meshes make the authoring viewport look grounded but aren't
|
|
258
|
+
* part of the portable scene artifact.
|
|
188
259
|
* - Editor overlays on non-scene layers (gizmos, selection handles, ground
|
|
189
260
|
* grid, zone fills). The editor camera shows them via extra layers; a
|
|
190
261
|
* thumbnail/bake is layer 0 only. Scene-layer affordances that can't be
|
|
@@ -199,6 +270,10 @@ const PLACEHOLDER_MATERIAL = new THREE.MeshBasicMaterial({ visible: false })
|
|
|
199
270
|
function pruneNonRenderableMeshes(root: THREE.Object3D, identityNodes: Set<THREE.Object3D>) {
|
|
200
271
|
const toRemove: THREE.Object3D[] = []
|
|
201
272
|
root.traverse((object) => {
|
|
273
|
+
if (object.userData.pascalExport === 'strip') {
|
|
274
|
+
toRemove.push(object)
|
|
275
|
+
return
|
|
276
|
+
}
|
|
202
277
|
// Editor-only overlays (gizmos, selection handles, ground grid, zone fills)
|
|
203
278
|
// live off the scene layer; the editor camera shows them via extra layers
|
|
204
279
|
// but a thumbnail/bake only wants layer 0. Drop the whole overlay subtree —
|
|
@@ -228,6 +303,20 @@ function pruneNonRenderableMeshes(root: THREE.Object3D, identityNodes: Set<THREE
|
|
|
228
303
|
}
|
|
229
304
|
return
|
|
230
305
|
}
|
|
306
|
+
// The scalar branch above only catches `material == null`; an ARRAY material
|
|
307
|
+
// (multi-material / group geometry) is never `== null`, so a slot that was
|
|
308
|
+
// never assigned — e.g. `[validMat, undefined]` — slips through and reaches
|
|
309
|
+
// GLTFExporter, which reads `material.isShaderMaterial` on every group's
|
|
310
|
+
// material and crashes on the undefined slot. Don't drop the mesh (the other
|
|
311
|
+
// slots may be real geometry): fill any null/undefined holes with the hidden
|
|
312
|
+
// placeholder so the exporter never sees an undefined material.
|
|
313
|
+
if (
|
|
314
|
+
(renderable.isMesh === true || renderable.isLine === true || renderable.isPoints === true) &&
|
|
315
|
+
Array.isArray(renderable.material) &&
|
|
316
|
+
renderable.material.some((m) => m == null)
|
|
317
|
+
) {
|
|
318
|
+
renderable.material = renderable.material.map((m) => m ?? PLACEHOLDER_MATERIAL)
|
|
319
|
+
}
|
|
231
320
|
const mesh = object as THREE.Mesh
|
|
232
321
|
if (!mesh.isMesh || isRenderableMesh(mesh)) return
|
|
233
322
|
if (mesh.children.length > 0) {
|
|
@@ -330,14 +419,31 @@ const STANDARD_MAP_SLOTS = [
|
|
|
330
419
|
'bumpMap',
|
|
331
420
|
] as const
|
|
332
421
|
|
|
333
|
-
|
|
422
|
+
const REFERENCE_MAP_SLOTS = [
|
|
423
|
+
...STANDARD_MAP_SLOTS,
|
|
424
|
+
'clearcoatMap',
|
|
425
|
+
'clearcoatNormalMap',
|
|
426
|
+
'clearcoatRoughnessMap',
|
|
427
|
+
'iridescenceMap',
|
|
428
|
+
'iridescenceThicknessMap',
|
|
429
|
+
'transmissionMap',
|
|
430
|
+
'thicknessMap',
|
|
431
|
+
'specularIntensityMap',
|
|
432
|
+
'specularColorMap',
|
|
433
|
+
'sheenRoughnessMap',
|
|
434
|
+
'sheenColorMap',
|
|
435
|
+
'anisotropyMap',
|
|
436
|
+
] as const
|
|
437
|
+
|
|
438
|
+
function convertMaterials(root: THREE.Object3D, textureMode: 'embed' | 'reference') {
|
|
334
439
|
const cache = new Map<THREE.Material, THREE.Material>()
|
|
440
|
+
const placeholderCache = new Map<THREE.Texture, THREE.Texture>()
|
|
335
441
|
root.traverse((object) => {
|
|
336
442
|
const mesh = object as THREE.Mesh
|
|
337
443
|
if (!mesh.isMesh) return
|
|
338
444
|
const material = mesh.material
|
|
339
445
|
if (Array.isArray(material)) {
|
|
340
|
-
mesh.material = material.map((m) => convertMaterial(m, cache))
|
|
446
|
+
mesh.material = material.map((m) => convertMaterial(m, cache, textureMode, placeholderCache))
|
|
341
447
|
return
|
|
342
448
|
}
|
|
343
449
|
// glTF has no BackSide — GLTFExporter renders the *front* face for any
|
|
@@ -351,7 +457,7 @@ function convertMaterials(root: THREE.Object3D) {
|
|
|
351
457
|
) {
|
|
352
458
|
mesh.geometry = flipGeometryWinding(mesh.geometry)
|
|
353
459
|
}
|
|
354
|
-
mesh.material = convertMaterial(material, cache)
|
|
460
|
+
mesh.material = convertMaterial(material, cache, textureMode, placeholderCache)
|
|
355
461
|
})
|
|
356
462
|
}
|
|
357
463
|
|
|
@@ -401,8 +507,19 @@ function flipGeometryWinding(geometry: THREE.BufferGeometry): THREE.BufferGeomet
|
|
|
401
507
|
function convertMaterial(
|
|
402
508
|
material: THREE.Material,
|
|
403
509
|
cache: Map<THREE.Material, THREE.Material>,
|
|
510
|
+
textureMode: 'embed' | 'reference',
|
|
511
|
+
placeholderCache: Map<THREE.Texture, THREE.Texture>,
|
|
404
512
|
): THREE.Material {
|
|
405
|
-
|
|
513
|
+
const isNodeMaterial = (material as { isNodeMaterial?: boolean }).isNodeMaterial === true
|
|
514
|
+
if (!isNodeMaterial) {
|
|
515
|
+
if (textureMode === 'embed') return material
|
|
516
|
+
const cached = cache.get(material)
|
|
517
|
+
if (cached) return cached
|
|
518
|
+
const target = material.clone()
|
|
519
|
+
replaceReferencedTextures(target, placeholderCache)
|
|
520
|
+
cache.set(material, target)
|
|
521
|
+
return target
|
|
522
|
+
}
|
|
406
523
|
|
|
407
524
|
const cached = cache.get(material)
|
|
408
525
|
if (cached) return cached
|
|
@@ -443,10 +560,90 @@ function convertMaterial(
|
|
|
443
560
|
}
|
|
444
561
|
}
|
|
445
562
|
|
|
563
|
+
if (textureMode === 'reference') replaceReferencedTextures(target, placeholderCache)
|
|
564
|
+
|
|
446
565
|
cache.set(material, target)
|
|
447
566
|
return target
|
|
448
567
|
}
|
|
449
568
|
|
|
569
|
+
function replaceReferencedTextures(
|
|
570
|
+
material: THREE.Material,
|
|
571
|
+
placeholderCache: Map<THREE.Texture, THREE.Texture>,
|
|
572
|
+
) {
|
|
573
|
+
const textureMaterial = material as THREE.Material & Record<string, unknown>
|
|
574
|
+
for (const slot of REFERENCE_MAP_SLOTS) {
|
|
575
|
+
const texture = textureMaterial[slot]
|
|
576
|
+
if (!(texture instanceof THREE.Texture) || !getPascalTextureRef(texture)) continue
|
|
577
|
+
|
|
578
|
+
let placeholder = placeholderCache.get(texture)
|
|
579
|
+
if (!placeholder) {
|
|
580
|
+
placeholder = createReferencePlaceholder(texture)
|
|
581
|
+
placeholderCache.set(texture, placeholder)
|
|
582
|
+
}
|
|
583
|
+
textureMaterial[slot] = placeholder
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/** GLTFExporter serializes images via canvas drawImage/createImageBitmap,
|
|
588
|
+
* which reject a DataTexture's raw `{data,width,height}` image — so in DOM
|
|
589
|
+
* environments the placeholder must be canvas-backed. The DataTexture branch
|
|
590
|
+
* covers non-DOM runs (bun tests), where the exporter itself never runs. */
|
|
591
|
+
function createPlaceholderCanvas(): OffscreenCanvas | HTMLCanvasElement | null {
|
|
592
|
+
const canvas =
|
|
593
|
+
typeof OffscreenCanvas !== 'undefined'
|
|
594
|
+
? new OffscreenCanvas(1, 1)
|
|
595
|
+
: typeof document !== 'undefined'
|
|
596
|
+
? Object.assign(document.createElement('canvas'), { width: 1, height: 1 })
|
|
597
|
+
: null
|
|
598
|
+
if (!canvas) return null
|
|
599
|
+
const ctx = canvas.getContext('2d') as
|
|
600
|
+
| OffscreenCanvasRenderingContext2D
|
|
601
|
+
| CanvasRenderingContext2D
|
|
602
|
+
| null
|
|
603
|
+
if (!ctx) return null
|
|
604
|
+
ctx.fillStyle = '#ffffff'
|
|
605
|
+
ctx.fillRect(0, 0, 1, 1)
|
|
606
|
+
return canvas
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function createReferencePlaceholder(texture: THREE.Texture): THREE.Texture {
|
|
610
|
+
const ref = getPascalTextureRef(texture)
|
|
611
|
+
if (!ref) throw new Error('Cannot create a placeholder for an invalid Pascal texture reference')
|
|
612
|
+
|
|
613
|
+
const canvas = createPlaceholderCanvas()
|
|
614
|
+
const placeholder = canvas
|
|
615
|
+
? new THREE.Texture(canvas)
|
|
616
|
+
: new THREE.DataTexture(
|
|
617
|
+
new Uint8Array([255, 255, 255, 255]),
|
|
618
|
+
1,
|
|
619
|
+
1,
|
|
620
|
+
THREE.RGBAFormat,
|
|
621
|
+
THREE.UnsignedByteType,
|
|
622
|
+
)
|
|
623
|
+
placeholder.name = texture.name
|
|
624
|
+
placeholder.mapping = texture.mapping
|
|
625
|
+
placeholder.channel = texture.channel
|
|
626
|
+
placeholder.wrapS = texture.wrapS
|
|
627
|
+
placeholder.wrapT = texture.wrapT
|
|
628
|
+
placeholder.magFilter = texture.magFilter
|
|
629
|
+
placeholder.minFilter = texture.minFilter
|
|
630
|
+
placeholder.anisotropy = texture.anisotropy
|
|
631
|
+
placeholder.offset.copy(texture.offset)
|
|
632
|
+
placeholder.repeat.copy(texture.repeat)
|
|
633
|
+
placeholder.center.copy(texture.center)
|
|
634
|
+
placeholder.rotation = texture.rotation
|
|
635
|
+
placeholder.matrixAutoUpdate = texture.matrixAutoUpdate
|
|
636
|
+
placeholder.matrix.copy(texture.matrix)
|
|
637
|
+
placeholder.generateMipmaps = texture.generateMipmaps
|
|
638
|
+
placeholder.premultiplyAlpha = texture.premultiplyAlpha
|
|
639
|
+
placeholder.flipY = texture.flipY
|
|
640
|
+
placeholder.unpackAlignment = texture.unpackAlignment
|
|
641
|
+
placeholder.colorSpace = texture.colorSpace
|
|
642
|
+
placeholder.userData = { pascalTextureRef: ref }
|
|
643
|
+
placeholder.needsUpdate = true
|
|
644
|
+
return placeholder
|
|
645
|
+
}
|
|
646
|
+
|
|
450
647
|
// --- Animation clip baking ----------------------------------------------
|
|
451
648
|
|
|
452
649
|
function bakeAnimationClips(
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// The one place that answers "where is the ground under this pointer ray?".
|
|
2
|
+
//
|
|
3
|
+
// Before terrain there was no question to answer: the ground was `y = 0` and
|
|
4
|
+
// every consumer intersected its own horizontal plane. Terrain makes those
|
|
5
|
+
// planes wrong in a way that is invisible on flat ground and badly wrong on a
|
|
6
|
+
// slope — a ray intersected at the datum instead of at the surface reports an XZ
|
|
7
|
+
// that slides along the ray, further the more oblique the camera. Item placement
|
|
8
|
+
// already repairs this locally (`resolvePointerSupportSurface`); this module
|
|
9
|
+
// exists so the repair is shared instead of copied into column, wall, fence, and
|
|
10
|
+
// the sculpt tool.
|
|
11
|
+
//
|
|
12
|
+
// It lives in `editor` rather than `core` because the decision needs one piece of
|
|
13
|
+
// editor knowledge — which plane a consumer is asking about — while the geometry
|
|
14
|
+
// it delegates to (`raycastTerrain`, `heightAt`) is pure core.
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
heightAt,
|
|
18
|
+
isSiteDatum,
|
|
19
|
+
raycastTerrain,
|
|
20
|
+
type SiteNode,
|
|
21
|
+
type TerrainField,
|
|
22
|
+
type TerrainHit,
|
|
23
|
+
terrainFieldOf,
|
|
24
|
+
useScene,
|
|
25
|
+
} from '@pascal-app/core'
|
|
26
|
+
|
|
27
|
+
/** The active site's terrain field, or null when the scene has no sculpted ground. */
|
|
28
|
+
export function siteTerrainField(): TerrainField | null {
|
|
29
|
+
const { nodes, rootNodeIds } = useScene.getState()
|
|
30
|
+
const siteId = rootNodeIds[0]
|
|
31
|
+
const site = siteId ? nodes[siteId] : null
|
|
32
|
+
if (site?.type !== 'site') return null
|
|
33
|
+
return terrainFieldOf(site as SiteNode)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* True when a horizontal query plane at `planeY` is the *site ground*, as opposed
|
|
38
|
+
* to a built flat surface that happens to be horizontal.
|
|
39
|
+
*
|
|
40
|
+
* This is the whole scoping rule, and getting it wrong in either direction is
|
|
41
|
+
* worse than not having terrain at all:
|
|
42
|
+
*
|
|
43
|
+
* - Treat every horizontal plane as ground and a second-storey wall draft snaps
|
|
44
|
+
* to the hillside under the building.
|
|
45
|
+
* - Treat none of them as ground and every ground tool keeps the perspective skew
|
|
46
|
+
* terrain was supposed to remove.
|
|
47
|
+
*
|
|
48
|
+
* The datum is exactly the right discriminator because terrain *is* the surface at
|
|
49
|
+
* the datum: the site renderer removes the flat ground fill when terrain mounts,
|
|
50
|
+
* so at the datum there is nothing else to hit. Every other horizontal plane —
|
|
51
|
+
* a storey base, a slab top, a shelf — is a real flat surface built by the user,
|
|
52
|
+
* and a plane is the correct model for it.
|
|
53
|
+
*
|
|
54
|
+
* `isSiteDatum` comes from core so this rule and the one the support resolver
|
|
55
|
+
* applies (`terrain-support.ts`) cannot drift into disagreeing about which
|
|
56
|
+
* surfaces are ground.
|
|
57
|
+
*/
|
|
58
|
+
export function isSiteGroundPlane(planeY: number): boolean {
|
|
59
|
+
return isSiteDatum(planeY)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Where a pointer ray meets the sculpted ground, or null when the flat-plane path
|
|
64
|
+
* should be used instead — no terrain, the ray misses the field, or the query
|
|
65
|
+
* plane is a built surface rather than the ground.
|
|
66
|
+
*
|
|
67
|
+
* Returning null rather than a fallback hit is deliberate: the caller already owns
|
|
68
|
+
* a correct plane intersection for its own frame (world for grid events,
|
|
69
|
+
* level-local for support election), and duplicating that here would mean two
|
|
70
|
+
* definitions of the fallback to keep in sync.
|
|
71
|
+
*/
|
|
72
|
+
export function resolveTerrainGroundHit(
|
|
73
|
+
origin: readonly [number, number, number],
|
|
74
|
+
direction: readonly [number, number, number],
|
|
75
|
+
planeY: number,
|
|
76
|
+
): TerrainHit | null {
|
|
77
|
+
if (!isSiteGroundPlane(planeY)) return null
|
|
78
|
+
const field = siteTerrainField()
|
|
79
|
+
if (!field) return null
|
|
80
|
+
return raycastTerrain(field, origin, direction)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Ground height at a world XZ, for consumers with no ray to march.
|
|
85
|
+
*
|
|
86
|
+
* The 2D plan view is the case this exists for: an orthographic top-down camera
|
|
87
|
+
* makes the pointer ray vertical, so the march degenerates to a single sample and
|
|
88
|
+
* there is no XZ skew to correct — only a Y to report.
|
|
89
|
+
*/
|
|
90
|
+
export function groundHeightAt(x: number, z: number, planeY: number): number | null {
|
|
91
|
+
if (!isSiteGroundPlane(planeY)) return null
|
|
92
|
+
const field = siteTerrainField()
|
|
93
|
+
if (!field) return null
|
|
94
|
+
return heightAt(field, x, z)
|
|
95
|
+
}
|