@pascal-app/editor 1.0.0 → 1.0.3
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 +12 -6
- package/src/components/editor/bake-exporter.tsx +4 -0
- package/src/components/editor/bake-thumbnail.tsx +4 -1
- package/src/components/editor/custom-camera-controls.tsx +22 -74
- package/src/components/editor/editor-layout-mobile.tsx +1 -0
- package/src/components/editor/floating-action-menu.tsx +14 -1
- package/src/components/editor/floorplan-panel.tsx +233 -166
- package/src/components/editor/grid.tsx +31 -7
- package/src/components/editor/group-actions.ts +5 -9
- package/src/components/editor/group-floating-action-menu.tsx +19 -9
- package/src/components/editor/group-move-3d.ts +4 -6
- package/src/components/editor/group-rotate-handle.tsx +81 -35
- package/src/components/editor/group-selection-box-3d.tsx +25 -17
- package/src/components/editor/group-transform-shared.test.ts +126 -1
- package/src/components/editor/group-transform-shared.ts +79 -73
- package/src/components/editor/handles/handle-arrow.tsx +72 -2
- 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 +80 -8
- package/src/components/editor/index.tsx +36 -13
- package/src/components/editor/node-action-menu.tsx +2 -0
- package/src/components/editor/node-arrow-handles.tsx +162 -69
- package/src/components/editor/registry-action-contributions.tsx +53 -0
- package/src/components/editor/selection-manager.tsx +49 -0
- package/src/components/editor/thumbnail-generator.tsx +11 -2
- package/src/components/editor/wall-move-side-handles.tsx +71 -21
- package/src/components/editor/wall-opening-highlights.tsx +2 -3
- package/src/components/editor-2d/floorplan-group-move.tsx +17 -24
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +33 -12
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +154 -49
- package/src/components/editor-2d/renderers/floorplan-draft-wall-measurement.tsx +166 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +49 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +113 -24
- package/src/components/procedural-items/inspector.tsx +223 -0
- package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
- package/src/components/systems/zone/zone-system.tsx +8 -4
- package/src/components/tools/__fixtures__/hosting.ts +10 -0
- package/src/components/tools/item/item-surface-placement.test.ts +169 -0
- package/src/components/tools/item/item-surface-strategy-resolver.test.ts +204 -0
- package/src/components/tools/item/placement-strategies.ts +212 -200
- 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 +136 -47
- package/src/components/tools/item/use-placement-coordinator.tsx +391 -157
- package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
- package/src/components/tools/registry/item-surface-move.test.ts +509 -0
- package/src/components/tools/registry/item-surface-move.ts +463 -0
- package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
- package/src/components/tools/registry/move-registry-node-tool.tsx +237 -78
- package/src/components/tools/shared/affordance-dispatch.test.ts +24 -0
- package/src/components/tools/shared/affordance-dispatch.ts +13 -0
- package/src/components/tools/shared/draft-measurement-label.tsx +38 -0
- package/src/components/tools/shared/pointer-support-cap.test.ts +27 -0
- package/src/components/tools/shared/pointer-support-cap.ts +27 -13
- 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 +107 -0
- package/src/components/tools/shared/surface-rejection.tsx +43 -0
- package/src/components/tools/tool-manager.tsx +36 -3
- 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/material-paint-panel.tsx +6 -67
- package/src/components/ui/controls/material-picker.tsx +5 -67
- package/src/components/ui/controls/metric-control.tsx +29 -13
- package/src/components/ui/controls/slider-control.tsx +87 -35
- package/src/components/ui/floating-level-selector.tsx +81 -5
- package/src/components/ui/helpers/helper-manager.tsx +24 -6
- package/src/components/ui/helpers/registered-tool-helper.tsx +2 -32
- package/src/components/ui/item-catalog/item-catalog.tsx +5 -30
- package/src/components/ui/panels/multi-height-mode.tsx +27 -21
- 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 +9 -17
- package/src/components/ui/panels/reference-panel.tsx +3 -3
- package/src/components/ui/primitives/checkbox.tsx +34 -0
- package/src/components/ui/primitives/shortcut-token.tsx +4 -0
- package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +669 -336
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
- 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 +30 -11
- 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/floorplan-preview-visibility.test.ts +35 -0
- package/src/components/viewer/floorplan-preview-visibility.ts +19 -0
- package/src/components/viewer/floorplan-preview.tsx +1 -12
- package/src/components/viewer/viewer-scene-header.tsx +3 -0
- package/src/components/viewer/viewer-units-panel.tsx +116 -0
- package/src/components/viewer-zone-system.tsx +7 -2
- package/src/hooks/use-auto-save.integration.test.tsx +197 -0
- package/src/hooks/use-auto-save.ts +21 -6
- package/src/hooks/use-ceiling-events.test.ts +25 -2
- package/src/hooks/use-ceiling-events.ts +7 -1
- package/src/hooks/use-grid-events.test.ts +2 -0
- package/src/hooks/use-grid-events.ts +16 -11
- package/src/hooks/use-keyboard.test.ts +7 -0
- package/src/hooks/use-keyboard.ts +43 -28
- package/src/index.tsx +68 -3
- package/src/lib/catalog-panel-model.ts +39 -0
- package/src/lib/catalog-selection.test.ts +27 -0
- package/src/lib/catalog-selection.ts +8 -0
- package/src/lib/contextual-help.test.ts +27 -1
- package/src/lib/contextual-help.ts +16 -9
- 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 +142 -2
- package/src/lib/floorplan/floorplan-export.tsx +9 -3
- package/src/lib/floorplan/floorplan-extension.ts +16 -0
- package/src/lib/floorplan/floorplan-readonly.ts +4 -0
- package/src/lib/fresh-planar-placement.ts +173 -33
- package/src/lib/glb-export-serialize.test.ts +280 -0
- package/src/lib/glb-export.test.ts +265 -16
- package/src/lib/glb-export.ts +197 -37
- package/src/lib/grid-frame.test.ts +19 -0
- package/src/lib/grid-frame.ts +18 -0
- package/src/lib/interaction/scope.ts +9 -1
- package/src/lib/keyboard-pan.test.ts +62 -0
- package/src/lib/keyboard-pan.ts +89 -0
- 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/material-catalog-model.ts +72 -0
- package/src/lib/material-paint-panel-model.ts +76 -0
- package/src/lib/node-action-movement.ts +21 -0
- package/src/lib/overlay-transform.test.ts +28 -0
- package/src/lib/overlay-transform.ts +12 -0
- package/src/lib/panel-rows.ts +75 -0
- package/src/lib/panel-tool-options.test.ts +34 -0
- package/src/lib/panel-tool-options.ts +46 -0
- package/src/lib/parametric-node-update.ts +37 -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/scene.test.ts +99 -0
- package/src/lib/scene.ts +21 -3
- 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/snapping-mode.test.ts +13 -2
- package/src/lib/snapping-mode.ts +11 -6
- package/src/lib/spatial-drag-plane.test.ts +53 -0
- package/src/lib/spatial-drag-plane.ts +19 -0
- package/src/lib/spatial-pointer-input.test.ts +72 -0
- package/src/lib/spatial-pointer-input.ts +62 -0
- package/src/lib/spatial-snap.test.ts +47 -0
- package/src/lib/surface-attachment.ts +77 -0
- package/src/lib/terrain-panel-model.ts +117 -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/lib/world-grid-snap.test.ts +48 -0
- package/src/lib/world-grid-snap.ts +9 -5
- package/src/store/rotation-snapping.test.ts +53 -0
- package/src/store/use-editor.tsx +10 -2
- package/src/store/use-floorplan-draft-preview.ts +10 -1
- package/src/store/use-handle-group.ts +4 -0
- package/src/store/use-interaction-scope.ts +155 -6
- package/src/store/use-send-to-app.ts +21 -0
package/src/lib/glb-export.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
findLevelAncestorId,
|
|
8
8
|
type GeometryContext,
|
|
9
9
|
getLevelDisplayName,
|
|
10
|
+
hidesDescendants,
|
|
10
11
|
isNodeKindEnabled,
|
|
11
12
|
isOperationDoorType,
|
|
12
13
|
itemClipRegistry,
|
|
@@ -35,9 +36,13 @@ import {
|
|
|
35
36
|
type GLTFExporterPlugin,
|
|
36
37
|
type GLTFWriter,
|
|
37
38
|
} from 'three/examples/jsm/exporters/GLTFExporter.js'
|
|
38
|
-
import
|
|
39
|
+
import { createExportTextureUtils, type ExportTextureUtils } from './export-texture-utils'
|
|
40
|
+
import { cloneExportUserData } from './export-user-data'
|
|
39
41
|
import {
|
|
42
|
+
type CompressedTextureDecompressor,
|
|
43
|
+
decompressCanonicalNormalMaps,
|
|
40
44
|
disposeExportResources,
|
|
45
|
+
GLASS_OPACITY_USERDATA,
|
|
41
46
|
normalizePortableScene,
|
|
42
47
|
normalizeViewerArtifactMaterials,
|
|
43
48
|
} from './portable-export'
|
|
@@ -69,12 +74,61 @@ export type GlbExportOptions = {
|
|
|
69
74
|
excludedNodeTypes?: readonly string[]
|
|
70
75
|
/** Selected static viewer-presentation contributions; omitted means none. */
|
|
71
76
|
includedPresentationIds?: readonly string[]
|
|
72
|
-
/** Portable
|
|
77
|
+
/** Portable materials/geometry normalisation vs the baked viewer artifact. */
|
|
73
78
|
purpose?: 'portable' | 'viewer'
|
|
79
|
+
/**
|
|
80
|
+
* Door/window open clips. Defaults to `keep` for the viewer bake and for GLB
|
|
81
|
+
* downloads (Blender turns them into actions); USDZ and print pass `none`
|
|
82
|
+
* because those formats freeze geometry and cannot play them.
|
|
83
|
+
*/
|
|
84
|
+
animations?: 'keep' | 'none'
|
|
74
85
|
/** Called for actual lossy portable conversions discovered during preparation. */
|
|
75
86
|
onWarning?: (warning: string) => void
|
|
76
87
|
/** Reject retained node kinds whose export geometry can only be baked asynchronously. */
|
|
77
88
|
requireSynchronousBake?: boolean
|
|
89
|
+
/** GPU decompressor for compressed normal maps that must be baked; defaults to `textureUtils`. */
|
|
90
|
+
decompressTexture?: CompressedTextureDecompressor
|
|
91
|
+
/**
|
|
92
|
+
* Shared GPU decompressor for every compressed texture in the export. The
|
|
93
|
+
* export entry points create one per export and dispose it; supplying your
|
|
94
|
+
* own keeps ownership with you.
|
|
95
|
+
*/
|
|
96
|
+
textureUtils?: ExportTextureUtils
|
|
97
|
+
/**
|
|
98
|
+
* Wall-clock deadline for the whole export (preparation and serialisation),
|
|
99
|
+
* after which it rejects instead of leaving the caller waiting. Defaults to
|
|
100
|
+
* `DEFAULT_MODEL_EXPORT_TIMEOUT_MS`; `Infinity` disables it.
|
|
101
|
+
*/
|
|
102
|
+
timeoutMs?: number
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export const DEFAULT_MODEL_EXPORT_TIMEOUT_MS = 180_000
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* three's exporters finish inside `FileReader.onloadend` and `canvas.toBlob`
|
|
109
|
+
* callbacks that carry no error path: a failed read or a callback the browser
|
|
110
|
+
* never invokes calls neither `onDone` nor `onError`, so without a deadline the
|
|
111
|
+
* returned promise stays pending forever and the export UI is stuck.
|
|
112
|
+
*/
|
|
113
|
+
export function withExportDeadline<Result>(
|
|
114
|
+
promise: Promise<Result>,
|
|
115
|
+
timeoutMs: number,
|
|
116
|
+
format: string,
|
|
117
|
+
onTimeout?: () => void,
|
|
118
|
+
): Promise<Result> {
|
|
119
|
+
if (!Number.isFinite(timeoutMs)) return promise
|
|
120
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
121
|
+
const deadline = new Promise<never>((_, reject) => {
|
|
122
|
+
timer = setTimeout(() => {
|
|
123
|
+
onTimeout?.()
|
|
124
|
+
reject(
|
|
125
|
+
new Error(
|
|
126
|
+
`${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.`,
|
|
127
|
+
),
|
|
128
|
+
)
|
|
129
|
+
}, timeoutMs)
|
|
130
|
+
})
|
|
131
|
+
return Promise.race([promise, deadline]).finally(() => clearTimeout(timer))
|
|
78
132
|
}
|
|
79
133
|
|
|
80
134
|
/** Resolve after the next couple of animation frames, giving React/R3F time to
|
|
@@ -144,15 +198,43 @@ export async function exportSceneToGlb(
|
|
|
144
198
|
options: GlbExportOptions = {},
|
|
145
199
|
): Promise<ArrayBuffer> {
|
|
146
200
|
const textureMode = options.textures ?? 'embed'
|
|
147
|
-
const
|
|
148
|
-
|
|
201
|
+
const textureUtils = options.textureUtils ?? createExportTextureUtils()
|
|
202
|
+
// Preparation awaits plugin bake hooks and presentation builders, so the
|
|
203
|
+
// deadline has to cover it as well as the serialisation.
|
|
204
|
+
const preparation = preparePortableSceneFromViewer(sceneGroup, nodes, {
|
|
205
|
+
...options,
|
|
206
|
+
textureUtils,
|
|
207
|
+
animations: options.animations ?? 'keep',
|
|
208
|
+
})
|
|
209
|
+
// Assigned inside the raced closure, so a plain `let` narrows to `never` in
|
|
210
|
+
// the `finally`.
|
|
211
|
+
const run: { prepared: GlbExport | null; abandoned: boolean } = {
|
|
212
|
+
prepared: null,
|
|
213
|
+
abandoned: false,
|
|
214
|
+
}
|
|
149
215
|
try {
|
|
150
|
-
return await
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
216
|
+
return await withExportDeadline(
|
|
217
|
+
(async () => {
|
|
218
|
+
run.prepared = await preparation
|
|
219
|
+
// The race has already rejected; don't serialise a file nobody will get.
|
|
220
|
+
if (run.abandoned) throw new Error('GLB export abandoned')
|
|
221
|
+
for (const warning of run.prepared.warnings) options.onWarning?.(warning)
|
|
222
|
+
return serializePreparedSceneToGlb(run.prepared, {
|
|
223
|
+
textures: textureMode,
|
|
224
|
+
onlyVisible: options.onlyVisible,
|
|
225
|
+
textureUtils,
|
|
226
|
+
})
|
|
227
|
+
})(),
|
|
228
|
+
options.timeoutMs ?? DEFAULT_MODEL_EXPORT_TIMEOUT_MS,
|
|
229
|
+
'GLB',
|
|
230
|
+
() => {
|
|
231
|
+
run.abandoned = true
|
|
232
|
+
},
|
|
233
|
+
)
|
|
154
234
|
} finally {
|
|
155
|
-
prepared.dispose()
|
|
235
|
+
if (run.prepared) run.prepared.dispose()
|
|
236
|
+
else preparation.then((late) => late.dispose()).catch(() => {})
|
|
237
|
+
if (!options.textureUtils) await textureUtils.dispose()
|
|
156
238
|
}
|
|
157
239
|
}
|
|
158
240
|
/**
|
|
@@ -179,24 +261,30 @@ export async function preparePortableSceneFromViewer(
|
|
|
179
261
|
return completeSceneExportPreparation(preparation)
|
|
180
262
|
}
|
|
181
263
|
|
|
264
|
+
/** Serialise a prepared scene; callers own the deadline (see `exportSceneToGlb`). */
|
|
182
265
|
export function serializePreparedSceneToGlb(
|
|
183
266
|
prepared: GlbExport,
|
|
184
|
-
options: Pick<GlbExportOptions, 'textures' | 'onlyVisible'>
|
|
267
|
+
options: Pick<GlbExportOptions, 'textures' | 'onlyVisible'> & {
|
|
268
|
+
textureUtils: ExportTextureUtils
|
|
269
|
+
},
|
|
185
270
|
): Promise<ArrayBuffer> {
|
|
186
271
|
const exporter = new GLTFExporter()
|
|
187
272
|
if ((options.textures ?? 'embed') === 'reference') {
|
|
188
273
|
exporter.register(textureReferencePlugin)
|
|
189
274
|
}
|
|
190
|
-
exporter.setTextureUtils(
|
|
275
|
+
exporter.setTextureUtils(options.textureUtils)
|
|
191
276
|
|
|
192
277
|
return new Promise<ArrayBuffer>((resolve, reject) => {
|
|
193
278
|
exporter.parse(
|
|
194
279
|
prepared.scene,
|
|
195
280
|
(gltf) => {
|
|
196
|
-
|
|
281
|
+
// A failed final FileReader hands GLTFExporter's onDone `null`, which
|
|
282
|
+
// `new Blob([null])` would happily turn into a four-byte download.
|
|
283
|
+
if (gltf instanceof ArrayBuffer) resolve(gltf)
|
|
284
|
+
else reject(new Error('GLB export produced no data'))
|
|
197
285
|
},
|
|
198
286
|
(error) => {
|
|
199
|
-
reject(error)
|
|
287
|
+
reject(error instanceof Error ? error : new Error(String(error)))
|
|
200
288
|
},
|
|
201
289
|
{
|
|
202
290
|
binary: true,
|
|
@@ -342,22 +430,47 @@ function startSceneExportPreparation(
|
|
|
342
430
|
}
|
|
343
431
|
}
|
|
344
432
|
|
|
433
|
+
/** Own a throwaway decompressor only when the caller supplied neither form. */
|
|
434
|
+
function resolveNormalMapDecompressor(options: GlbExportOptions): {
|
|
435
|
+
decompress: CompressedTextureDecompressor
|
|
436
|
+
dispose: () => Promise<void>
|
|
437
|
+
} {
|
|
438
|
+
if (options.decompressTexture) {
|
|
439
|
+
return { decompress: options.decompressTexture, dispose: async () => {} }
|
|
440
|
+
}
|
|
441
|
+
const utils = options.textureUtils ?? createExportTextureUtils()
|
|
442
|
+
return {
|
|
443
|
+
decompress: (texture) => utils.decompress(texture),
|
|
444
|
+
dispose: options.textureUtils ? async () => {} : () => utils.dispose(),
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
345
448
|
async function completeSceneExportPreparation(
|
|
346
449
|
preparation: SceneExportPreparation,
|
|
347
450
|
): Promise<GlbExport> {
|
|
451
|
+
const decompressor = resolveNormalMapDecompressor(preparation.options)
|
|
348
452
|
try {
|
|
349
453
|
await replaceBakeGeometryAsync(preparation)
|
|
350
454
|
await appendSelectedPresentations(preparation)
|
|
351
455
|
const prepared = finishSceneExportPreparation(preparation)
|
|
456
|
+
const { options } = preparation
|
|
457
|
+
const byReference = (options.textures ?? 'embed') === 'reference'
|
|
458
|
+
const normalizeOptions = {
|
|
459
|
+
preserveNormalMap: (texture: THREE.Texture) =>
|
|
460
|
+
byReference && getPascalTextureRef(texture) !== null,
|
|
461
|
+
}
|
|
462
|
+
await decompressCanonicalNormalMaps(prepared.scene, decompressor.decompress, normalizeOptions)
|
|
352
463
|
prepared.warnings.push(
|
|
353
|
-
...(
|
|
354
|
-
? normalizeViewerArtifactMaterials(prepared.scene)
|
|
355
|
-
: normalizePortableScene(prepared.scene)),
|
|
464
|
+
...(options.purpose === 'viewer'
|
|
465
|
+
? normalizeViewerArtifactMaterials(prepared.scene, normalizeOptions)
|
|
466
|
+
: normalizePortableScene(prepared.scene, normalizeOptions)),
|
|
356
467
|
)
|
|
357
468
|
return prepared
|
|
358
469
|
} catch (error) {
|
|
359
470
|
disposeExportResources(preparation.scene)
|
|
360
471
|
throw error
|
|
472
|
+
} finally {
|
|
473
|
+
await decompressor.dispose()
|
|
361
474
|
}
|
|
362
475
|
}
|
|
363
476
|
|
|
@@ -374,10 +487,12 @@ function finishSceneExportPreparation(preparation: SceneExportPreparation): GlbE
|
|
|
374
487
|
convertMaterials(scene, options.textures ?? 'embed', options.purpose ?? 'viewer')
|
|
375
488
|
|
|
376
489
|
const retainedCloneByOriginal = retainedClones(scene, cloneByOriginal)
|
|
377
|
-
const
|
|
378
|
-
options.
|
|
379
|
-
|
|
380
|
-
|
|
490
|
+
const keepClips = options.animations
|
|
491
|
+
? options.animations === 'keep'
|
|
492
|
+
: options.purpose === 'viewer'
|
|
493
|
+
const animation = keepClips
|
|
494
|
+
? bakeAnimationClips(retainedCloneByOriginal, nodes, registryEntries)
|
|
495
|
+
: { clips: [], clipNamesByNode: new Map<string, string[]>() }
|
|
381
496
|
stampIdentity(scene, retainedCloneByOriginal, nodes, animation.clipNamesByNode, registryEntries)
|
|
382
497
|
|
|
383
498
|
let disposed = false
|
|
@@ -495,7 +610,7 @@ function ownBorrowedPresentationTextures(root: THREE.Object3D): void {
|
|
|
495
610
|
let ownedTexture = ownedTextures.get(sourceTexture)
|
|
496
611
|
if (!ownedTexture) {
|
|
497
612
|
ownedTexture = sourceTexture.clone()
|
|
498
|
-
ownedTexture.userData =
|
|
613
|
+
ownedTexture.userData = cloneExportUserData(sourceTexture.userData)
|
|
499
614
|
ownedTexture.needsUpdate = true
|
|
500
615
|
ownedTextures.set(sourceTexture, ownedTexture)
|
|
501
616
|
}
|
|
@@ -601,16 +716,6 @@ function pruneHiddenSceneNodes(
|
|
|
601
716
|
registryEntries: readonly RegistryEntry[],
|
|
602
717
|
) {
|
|
603
718
|
const visibility = new Map<string, boolean>()
|
|
604
|
-
const declaredSiteParents = new Map<string, string>()
|
|
605
|
-
for (const node of Object.values(nodes)) {
|
|
606
|
-
if (node.type !== 'site' || !('children' in node) || !Array.isArray(node.children)) continue
|
|
607
|
-
for (const childId of node.children) {
|
|
608
|
-
const child = nodes[childId]
|
|
609
|
-
if (child && !child.parentId && !declaredSiteParents.has(childId)) {
|
|
610
|
-
declaredSiteParents.set(childId, node.id)
|
|
611
|
-
}
|
|
612
|
-
}
|
|
613
|
-
}
|
|
614
719
|
|
|
615
720
|
const isVisible = (id: string, path: Set<string>): boolean => {
|
|
616
721
|
const cached = visibility.get(id)
|
|
@@ -622,8 +727,9 @@ function pruneHiddenSceneNodes(
|
|
|
622
727
|
visibility.set(id, false)
|
|
623
728
|
return false
|
|
624
729
|
}
|
|
625
|
-
const parentId = node.parentId
|
|
626
|
-
|
|
730
|
+
const parentId = node.parentId
|
|
731
|
+
const parent = parentId ? nodes[parentId] : undefined
|
|
732
|
+
if (!parentId || path.has(id) || (parent && !hidesDescendants(parent))) {
|
|
627
733
|
visibility.set(id, true)
|
|
628
734
|
return true
|
|
629
735
|
}
|
|
@@ -635,12 +741,33 @@ function pruneHiddenSceneNodes(
|
|
|
635
741
|
return visible
|
|
636
742
|
}
|
|
637
743
|
|
|
744
|
+
const nodeClones = new Set<THREE.Object3D>()
|
|
745
|
+
for (const [, original] of registryEntries) {
|
|
746
|
+
const clone = cloneByOriginal.get(original)
|
|
747
|
+
if (clone) nodeClones.add(clone)
|
|
748
|
+
}
|
|
749
|
+
|
|
638
750
|
for (const [id, original] of registryEntries) {
|
|
639
751
|
if (isVisible(id, new Set())) continue
|
|
640
|
-
cloneByOriginal.get(original)
|
|
752
|
+
const clone = cloneByOriginal.get(original)
|
|
753
|
+
if (!clone) continue
|
|
754
|
+
const node = nodes[id]
|
|
755
|
+
if (!node || hidesDescendants(node)) {
|
|
756
|
+
clone.removeFromParent()
|
|
757
|
+
continue
|
|
758
|
+
}
|
|
759
|
+
// Drop the hidden Site's own ground fill and boundary; keep what it hosts.
|
|
760
|
+
for (const child of [...clone.children]) {
|
|
761
|
+
if (!hostsSceneNode(child, nodeClones)) child.removeFromParent()
|
|
762
|
+
}
|
|
641
763
|
}
|
|
642
764
|
}
|
|
643
765
|
|
|
766
|
+
function hostsSceneNode(object: THREE.Object3D, nodeClones: Set<THREE.Object3D>): boolean {
|
|
767
|
+
if (nodeClones.has(object)) return true
|
|
768
|
+
return object.children.some((child) => hostsSceneNode(child, nodeClones))
|
|
769
|
+
}
|
|
770
|
+
|
|
644
771
|
function retainedClones(
|
|
645
772
|
root: THREE.Object3D,
|
|
646
773
|
cloneByOriginal: Map<THREE.Object3D, THREE.Object3D>,
|
|
@@ -670,7 +797,7 @@ function cloneSceneForExport(
|
|
|
670
797
|
if (excludedObjects.has(source)) return new THREE.Group()
|
|
671
798
|
|
|
672
799
|
const clone = source.clone(false)
|
|
673
|
-
clone.userData =
|
|
800
|
+
clone.userData = cloneExportUserData(source.userData)
|
|
674
801
|
const renderable = source as THREE.Mesh
|
|
675
802
|
const renderableClone = clone as THREE.Mesh
|
|
676
803
|
if (renderable.geometry) {
|
|
@@ -693,7 +820,7 @@ function cloneSceneForExport(
|
|
|
693
820
|
let textureClone = cache.textures.get(texture)
|
|
694
821
|
if (!textureClone) {
|
|
695
822
|
textureClone = texture.clone()
|
|
696
|
-
textureClone.userData =
|
|
823
|
+
textureClone.userData = cloneExportUserData(texture.userData)
|
|
697
824
|
textureClone.needsUpdate = true
|
|
698
825
|
cache.textures.set(texture, textureClone)
|
|
699
826
|
}
|
|
@@ -1047,7 +1174,8 @@ function convertMaterial(
|
|
|
1047
1174
|
if (cached) return cached
|
|
1048
1175
|
|
|
1049
1176
|
const src = material as THREE.Material & Record<string, unknown>
|
|
1050
|
-
const
|
|
1177
|
+
const glass = purpose === 'portable' && isPortableGlass(material)
|
|
1178
|
+
const target = glass ? new THREE.MeshPhysicalMaterial() : new THREE.MeshStandardMaterial()
|
|
1051
1179
|
|
|
1052
1180
|
target.name = material.name
|
|
1053
1181
|
if (src.color instanceof THREE.Color) target.color.copy(src.color)
|
|
@@ -1083,12 +1211,44 @@ function convertMaterial(
|
|
|
1083
1211
|
}
|
|
1084
1212
|
}
|
|
1085
1213
|
|
|
1214
|
+
if (glass) applyPortableGlass(target as THREE.MeshPhysicalMaterial, material)
|
|
1086
1215
|
if (textureMode === 'reference') replaceReferencedTextures(target, placeholderCache)
|
|
1087
1216
|
|
|
1088
1217
|
cache.set(material, target)
|
|
1089
1218
|
return target
|
|
1090
1219
|
}
|
|
1091
1220
|
|
|
1221
|
+
/** Mirrors the viewer's own rule (`maybeApplyGlassFresnel`): an untextured
|
|
1222
|
+
* see-through surface below this opacity is glass, not tinted plastic. */
|
|
1223
|
+
const GLASS_OPACITY_THRESHOLD = 0.6
|
|
1224
|
+
|
|
1225
|
+
function isPortableGlass(material: THREE.Material): boolean {
|
|
1226
|
+
const src = material as THREE.Material & Record<string, unknown>
|
|
1227
|
+
return (
|
|
1228
|
+
material.transparent &&
|
|
1229
|
+
material.opacity < GLASS_OPACITY_THRESHOLD &&
|
|
1230
|
+
!(src.map instanceof THREE.Texture)
|
|
1231
|
+
)
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
/**
|
|
1235
|
+
* The viewer sells glass with a fresnel-driven opacity node, which glTF cannot
|
|
1236
|
+
* carry; a plain alpha blend lands in every other tool as a blue film. Real
|
|
1237
|
+
* transmission (KHR_materials_transmission + ior) is what Blender, Unity and
|
|
1238
|
+
* Unreal all render as glass. The authored opacity becomes the tint strength.
|
|
1239
|
+
*/
|
|
1240
|
+
function applyPortableGlass(target: THREE.MeshPhysicalMaterial, source: THREE.Material) {
|
|
1241
|
+
target.color.lerp(new THREE.Color(0xffffff), 1 - source.opacity)
|
|
1242
|
+
target.transmission = 1
|
|
1243
|
+
target.ior = 1.5
|
|
1244
|
+
target.roughness = Math.min(target.roughness, 0.15)
|
|
1245
|
+
target.metalness = 0
|
|
1246
|
+
target.transparent = false
|
|
1247
|
+
target.opacity = 1
|
|
1248
|
+
target.depthWrite = true
|
|
1249
|
+
target.userData[GLASS_OPACITY_USERDATA] = source.opacity
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1092
1252
|
function replaceReferencedTextures(
|
|
1093
1253
|
material: THREE.Material,
|
|
1094
1254
|
placeholderCache: Map<THREE.Texture, THREE.Texture>,
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test'
|
|
2
|
+
import { Group, Vector3 } from 'three'
|
|
3
|
+
import { gridLocalNormal, gridLocalPoint } from './grid-frame'
|
|
4
|
+
|
|
5
|
+
test('grid floor and cursor stay in model coordinates after God-view pan, scale and yaw', () => {
|
|
6
|
+
const root = new Group()
|
|
7
|
+
root.position.set(2, 4, -3)
|
|
8
|
+
root.rotation.y = Math.PI / 3
|
|
9
|
+
root.scale.setScalar(0.2)
|
|
10
|
+
root.updateMatrixWorld(true)
|
|
11
|
+
const local = new Vector3(3, 2.5, 7)
|
|
12
|
+
const world = root.localToWorld(local.clone())
|
|
13
|
+
expect(gridLocalPoint(root, world, new Vector3()).distanceTo(local)).toBeLessThan(1e-8)
|
|
14
|
+
const normal = new Vector3(1, 0, 0).transformDirection(root.matrixWorld)
|
|
15
|
+
expect(
|
|
16
|
+
gridLocalNormal(root, normal, new Vector3()).distanceTo(new Vector3(1, 0, 0)),
|
|
17
|
+
).toBeLessThan(1e-8)
|
|
18
|
+
expect(world.distanceTo(local)).toBeGreaterThan(1)
|
|
19
|
+
})
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Object3D, Vector3 } from 'three'
|
|
2
|
+
|
|
3
|
+
// Placement surfaces are world-space; the grid lives under the scalable XR
|
|
4
|
+
// scene root. Keep its lattice and reveal in that root's model coordinates.
|
|
5
|
+
export function gridLocalPoint(parent: Object3D | null, world: Vector3, target: Vector3) {
|
|
6
|
+
target.copy(world)
|
|
7
|
+
if (parent) {
|
|
8
|
+
parent.updateWorldMatrix(true, false)
|
|
9
|
+
parent.worldToLocal(target)
|
|
10
|
+
}
|
|
11
|
+
return target
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function gridLocalNormal(parent: Object3D | null, normal: Vector3, target: Vector3) {
|
|
15
|
+
target.copy(normal)
|
|
16
|
+
if (parent) target.transformDirection(parent.matrixWorld.clone().invert())
|
|
17
|
+
return target
|
|
18
|
+
}
|
|
@@ -19,7 +19,15 @@ export type ReshapeDriver = 'tool' | 'floorplan'
|
|
|
19
19
|
// node, one in-flight reshape. Grouping them as sub-states of `reshaping`
|
|
20
20
|
// (rather than four sibling scopes) keeps the union small while still making
|
|
21
21
|
// "curving and hole-editing at once" unrepresentable.
|
|
22
|
-
export type ReshapeKind =
|
|
22
|
+
export type ReshapeKind =
|
|
23
|
+
| 'curve'
|
|
24
|
+
| 'hole'
|
|
25
|
+
| 'endpoint'
|
|
26
|
+
| 'boundary'
|
|
27
|
+
| 'control-point'
|
|
28
|
+
| 'tangent'
|
|
29
|
+
// A kind's own reshape (wall 'split'): mounted from `def.affordanceTools[reshape]`.
|
|
30
|
+
| 'split'
|
|
23
31
|
|
|
24
32
|
export type InteractionScope =
|
|
25
33
|
| { kind: 'idle' }
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { afterAll, beforeAll, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
acceptsKeyboardPan,
|
|
4
|
+
clearKeyboardPanKeys,
|
|
5
|
+
hasKeyboardPanInput,
|
|
6
|
+
isKeyboardPanKey,
|
|
7
|
+
type KeyboardPanState,
|
|
8
|
+
keyboardPanDirection,
|
|
9
|
+
keyboardPanSpeed,
|
|
10
|
+
setKeyboardPanKey,
|
|
11
|
+
} from './keyboard-pan'
|
|
12
|
+
|
|
13
|
+
const idle = (): KeyboardPanState => ({
|
|
14
|
+
forward: false,
|
|
15
|
+
backward: false,
|
|
16
|
+
left: false,
|
|
17
|
+
right: false,
|
|
18
|
+
})
|
|
19
|
+
const key = (init: Partial<KeyboardEvent>) => ({ target: null, ...init }) as KeyboardEvent
|
|
20
|
+
|
|
21
|
+
// No DOM in this runner: stand in for the element classes the editable check reads.
|
|
22
|
+
const DOM_CLASSES = ['HTMLElement', 'HTMLInputElement', 'HTMLTextAreaElement', 'HTMLSelectElement']
|
|
23
|
+
const stubbed: string[] = []
|
|
24
|
+
beforeAll(() => {
|
|
25
|
+
const scope = globalThis as Record<string, unknown>
|
|
26
|
+
for (const name of DOM_CLASSES) {
|
|
27
|
+
if (scope[name]) continue
|
|
28
|
+
scope[name] = class {}
|
|
29
|
+
stubbed.push(name)
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
afterAll(() => {
|
|
33
|
+
for (const name of stubbed) delete (globalThis as Record<string, unknown>)[name]
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('physical WASD keys drive a screen-space direction; letters on other layouts do not', () => {
|
|
37
|
+
const state = idle()
|
|
38
|
+
expect(isKeyboardPanKey('KeyZ')).toBe(false)
|
|
39
|
+
expect(setKeyboardPanKey(state, 'KeyW', true)).toBe(true)
|
|
40
|
+
expect(setKeyboardPanKey(state, 'KeyW', true)).toBe(false)
|
|
41
|
+
setKeyboardPanKey(state, 'KeyD', true)
|
|
42
|
+
expect(keyboardPanDirection(state)).toEqual({ horizontal: 1, vertical: 1 })
|
|
43
|
+
setKeyboardPanKey(state, 'KeyA', true)
|
|
44
|
+
expect(keyboardPanDirection(state)).toEqual({ horizontal: 0, vertical: 1 })
|
|
45
|
+
clearKeyboardPanKeys(state)
|
|
46
|
+
expect(hasKeyboardPanInput(state)).toBe(false)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('modifier chords stay shortcuts, and typing in a field never pans', () => {
|
|
50
|
+
expect(acceptsKeyboardPan(key({}))).toBe(true)
|
|
51
|
+
for (const modifier of ['metaKey', 'ctrlKey', 'altKey'] as const)
|
|
52
|
+
expect(acceptsKeyboardPan(key({ [modifier]: true }))).toBe(false)
|
|
53
|
+
const Input = (globalThis as unknown as { HTMLInputElement: new () => EventTarget })
|
|
54
|
+
.HTMLInputElement
|
|
55
|
+
expect(acceptsKeyboardPan(key({ target: new Input() }))).toBe(false)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('speed scales with the visible width within fixed bounds', () => {
|
|
59
|
+
expect(keyboardPanSpeed(0)).toBe(2)
|
|
60
|
+
expect(keyboardPanSpeed(20)).toBeCloseTo(13)
|
|
61
|
+
expect(keyboardPanSpeed(1000)).toBe(55)
|
|
62
|
+
})
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// WASD navigation shared by the 3D camera and the 2D floor plan, so both views
|
|
2
|
+
// move the same way. Keys match by physical position (`event.code`): the
|
|
3
|
+
// cluster stays under the left hand on any layout (Z/Q/S/D on AZERTY).
|
|
4
|
+
|
|
5
|
+
export type KeyboardPanState = {
|
|
6
|
+
forward: boolean
|
|
7
|
+
backward: boolean
|
|
8
|
+
left: boolean
|
|
9
|
+
right: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const KEYBOARD_PAN_VIEW_WIDTH_PER_SECOND = 0.65
|
|
13
|
+
const KEYBOARD_PAN_MIN_SPEED = 2
|
|
14
|
+
const KEYBOARD_PAN_MAX_SPEED = 55
|
|
15
|
+
|
|
16
|
+
export function isEditableKeyboardTarget(target: EventTarget | null) {
|
|
17
|
+
return (
|
|
18
|
+
target instanceof HTMLInputElement ||
|
|
19
|
+
target instanceof HTMLTextAreaElement ||
|
|
20
|
+
target instanceof HTMLSelectElement ||
|
|
21
|
+
(target instanceof HTMLElement && target.isContentEditable)
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function setKeyboardPanKey(
|
|
26
|
+
state: KeyboardPanState,
|
|
27
|
+
code: string,
|
|
28
|
+
pressed: boolean,
|
|
29
|
+
): boolean {
|
|
30
|
+
if (code === 'KeyW') {
|
|
31
|
+
const changed = state.forward !== pressed
|
|
32
|
+
state.forward = pressed
|
|
33
|
+
return changed
|
|
34
|
+
}
|
|
35
|
+
if (code === 'KeyS') {
|
|
36
|
+
const changed = state.backward !== pressed
|
|
37
|
+
state.backward = pressed
|
|
38
|
+
return changed
|
|
39
|
+
}
|
|
40
|
+
if (code === 'KeyA') {
|
|
41
|
+
const changed = state.left !== pressed
|
|
42
|
+
state.left = pressed
|
|
43
|
+
return changed
|
|
44
|
+
}
|
|
45
|
+
if (code === 'KeyD') {
|
|
46
|
+
const changed = state.right !== pressed
|
|
47
|
+
state.right = pressed
|
|
48
|
+
return changed
|
|
49
|
+
}
|
|
50
|
+
return false
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function isKeyboardPanKey(code: string): boolean {
|
|
54
|
+
return code === 'KeyW' || code === 'KeyA' || code === 'KeyS' || code === 'KeyD'
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function hasKeyboardPanInput(state: KeyboardPanState): boolean {
|
|
58
|
+
return state.forward || state.backward || state.left || state.right
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function clearKeyboardPanKeys(state: KeyboardPanState) {
|
|
62
|
+
state.forward = false
|
|
63
|
+
state.backward = false
|
|
64
|
+
state.left = false
|
|
65
|
+
state.right = false
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Pan keys are ignored with a modifier held (shortcuts) or while typing. */
|
|
69
|
+
export function acceptsKeyboardPan(event: KeyboardEvent) {
|
|
70
|
+
return (
|
|
71
|
+
!(event.metaKey || event.ctrlKey || event.altKey) && !isEditableKeyboardTarget(event.target)
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Screen-space direction: `horizontal` +1 is right, `vertical` +1 is forward (up). */
|
|
76
|
+
export function keyboardPanDirection(state: KeyboardPanState) {
|
|
77
|
+
return {
|
|
78
|
+
horizontal: (state.right ? 1 : 0) - (state.left ? 1 : 0),
|
|
79
|
+
vertical: (state.forward ? 1 : 0) - (state.backward ? 1 : 0),
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** World units per second for a view `viewWidth` wide. */
|
|
84
|
+
export function keyboardPanSpeed(viewWidth: number) {
|
|
85
|
+
return Math.min(
|
|
86
|
+
Math.max(viewWidth * KEYBOARD_PAN_VIEW_WIDTH_PER_SECOND, KEYBOARD_PAN_MIN_SPEED),
|
|
87
|
+
KEYBOARD_PAN_MAX_SPEED,
|
|
88
|
+
)
|
|
89
|
+
}
|
|
@@ -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
|
+
})
|