@pascal-app/editor 1.0.0-beta.4 → 1.0.0
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/LICENSE +21 -0
- package/bunfig.toml +4 -0
- package/package.json +15 -8
- package/scripts/generate-print-golden-house.ts +89 -0
- package/src/components/editor/alignment-3d-guide-layer.tsx +12 -3
- package/src/components/editor/bake-exporter.tsx +1 -0
- package/src/components/editor/camera-dragging-lifecycle.test.ts +41 -0
- package/src/components/editor/camera-dragging-lifecycle.ts +10 -2
- package/src/components/editor/capture-camera-rig.tsx +59 -0
- package/src/components/editor/custom-camera-controls.tsx +20 -11
- package/src/components/editor/editor-layout-mobile.tsx +6 -6
- package/src/components/editor/editor-layout-v2.tsx +17 -5
- package/src/components/editor/elevation-3d-guide-layer.tsx +3 -2
- package/src/components/editor/export-manager.tsx +175 -33
- package/src/components/editor/first-person/build-collider-world.test.ts +30 -0
- package/src/components/editor/first-person-controls.tsx +182 -57
- package/src/components/editor/floating-action-menu.tsx +55 -55
- package/src/components/editor/floorplan-background-selection.test.ts +31 -4
- package/src/components/editor/floorplan-background-selection.ts +27 -7
- package/src/components/editor/floorplan-panel.tsx +81 -57
- package/src/components/editor/grid.tsx +12 -7
- package/src/components/editor/group-actions.ts +34 -50
- package/src/components/editor/group-floating-action-menu.tsx +30 -15
- package/src/components/editor/group-move-3d.ts +51 -21
- package/src/components/editor/group-transform-shared.test.ts +78 -0
- package/src/components/editor/group-transform-shared.ts +74 -7
- package/src/components/editor/handles/handle-arrow-raycast.test.ts +86 -0
- package/src/components/editor/handles/handle-arrow.tsx +109 -48
- package/src/components/editor/handles/linear-resize-drag.ts +67 -0
- package/src/components/editor/handles/resize-snap.test.ts +46 -0
- package/src/components/editor/handles/resize-snap.ts +10 -1
- package/src/components/editor/handles/use-handle-drag.ts +18 -1
- package/src/components/editor/index.tsx +252 -46
- package/src/components/editor/measurement-pill.tsx +12 -12
- package/src/components/editor/node-action-menu.tsx +42 -1
- package/src/components/editor/node-arrow-handles.tsx +47 -58
- package/src/components/editor/opening-guides-3d-layer.tsx +13 -4
- package/src/components/editor/selection-manager.tsx +132 -47
- package/src/components/editor/site-edge-labels.tsx +2 -1
- package/src/components/editor/snapshot-capture-overlay.tsx +309 -41
- package/src/components/editor/snapshot-capture.test.ts +422 -0
- package/src/components/editor/snapshot-capture.ts +145 -0
- package/src/components/editor/thumbnail-generator.tsx +295 -232
- package/src/components/editor/use-floorplan-background-placement.ts +53 -5
- package/src/components/editor/wall-measurement-label.tsx +3 -2
- package/src/components/editor/wall-move-side-handles.tsx +299 -7
- package/src/components/editor-2d/floorplan-group-action-menu.tsx +23 -16
- package/src/components/editor-2d/floorplan-group-move.tsx +67 -33
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +3 -1
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +129 -38
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +14 -1
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +16 -0
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +2 -0
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +52 -3
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +196 -1
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +204 -114
- package/src/components/systems/ceiling/ceiling-bracket-batch.test.ts +402 -0
- package/src/components/systems/ceiling/ceiling-bracket-batch.ts +371 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.test.ts +347 -0
- package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +281 -305
- package/src/components/systems/roof/roof-edit-system.test.tsx +120 -0
- package/src/components/systems/roof/roof-edit-system.tsx +10 -19
- package/src/components/systems/selection-affordance-manager.tsx +59 -9
- package/src/components/systems/selection-affordance-services.ts +19 -0
- package/src/components/systems/stair/stair-edit-system.tsx +5 -2
- package/src/components/tools/fence/fence-drafting.test.ts +100 -0
- package/src/components/tools/fence/fence-drafting.ts +6 -25
- package/src/components/tools/item/draft-creation.test.ts +12 -0
- package/src/components/tools/item/draft-creation.ts +10 -0
- package/src/components/tools/item/face-host-commit.test.ts +65 -0
- package/src/components/tools/item/face-host-commit.ts +56 -0
- package/src/components/tools/item/face-host-preview.test.ts +77 -0
- package/src/components/tools/item/face-host-preview.ts +82 -0
- package/src/components/tools/item/move-tool.tsx +4 -3
- package/src/components/tools/item/placement-math.test.ts +11 -1
- package/src/components/tools/item/placement-math.ts +0 -20
- package/src/components/tools/item/placement-strategies.test.ts +494 -0
- package/src/components/tools/item/placement-strategies.ts +192 -24
- package/src/components/tools/item/placement-surface.test.ts +61 -0
- package/src/components/tools/item/placement-surface.ts +24 -0
- package/src/components/tools/item/placement-types.ts +6 -0
- package/src/components/tools/item/test-face-host.ts +121 -0
- package/src/components/tools/item/use-draft-node.test.tsx +336 -0
- package/src/components/tools/item/use-draft-node.ts +26 -4
- package/src/components/tools/item/use-placement-coordinator.tsx +447 -180
- package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
- package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
- package/src/components/tools/registry/move-registry-node-tool.tsx +306 -110
- package/src/components/tools/registry-tool-context.tsx +30 -0
- package/src/components/tools/select/box-select-tool.tsx +3 -1
- package/src/components/tools/select/marquee-footprint.test.ts +53 -0
- package/src/components/tools/select/marquee-footprint.ts +29 -0
- package/src/components/tools/shared/placement-box.tsx +63 -8
- package/src/components/tools/shared/placement-dimension-guides.tsx +121 -0
- package/src/components/tools/shared/pointer-support-cap.test.ts +319 -17
- package/src/components/tools/shared/pointer-support-cap.ts +45 -8
- package/src/components/tools/site/terrain-brush-cursor.tsx +2 -1
- package/src/components/tools/site/terrain-sculpt-grid.tsx +9 -5
- package/src/components/tools/site/terrain-sculpt-tool.tsx +1 -1
- package/src/components/tools/stair/stair-tool.tsx +127 -74
- package/src/components/tools/tool-manager.tsx +25 -8
- package/src/components/tools/wall/wall-drafting.test.ts +527 -4
- package/src/components/tools/wall/wall-drafting.ts +55 -462
- package/src/components/ui/action-menu/control-modes.tsx +9 -6
- package/src/components/ui/action-menu/index.tsx +11 -1
- package/src/components/ui/command-palette/editor-commands.tsx +12 -13
- package/src/components/ui/command-palette/index.tsx +7 -2
- package/src/components/ui/controls/material-paint-panel.tsx +6 -4
- package/src/components/ui/controls/material-picker.tsx +4 -4
- package/src/components/ui/controls/scene-material-list.tsx +6 -5
- package/src/components/ui/controls/segmented-control.tsx +8 -1
- package/src/components/ui/controls/slider-control.tsx +19 -3
- package/src/components/ui/controls/toggle-control.tsx +18 -5
- package/src/components/ui/controls/tool-options-panel.tsx +126 -0
- package/src/components/ui/floating-level-selector.tsx +23 -6
- package/src/components/ui/helpers/contextual-helper-panel.tsx +9 -0
- package/src/components/ui/helpers/helper-manager.tsx +47 -11
- package/src/components/ui/helpers/item-helper.tsx +1 -1
- package/src/components/ui/helpers/registered-tool-helper.tsx +21 -0
- package/src/components/ui/panels/homogeneous-selection.test.ts +80 -0
- package/src/components/ui/panels/homogeneous-selection.ts +47 -0
- package/src/components/ui/panels/mobile-selection-bar.tsx +11 -5
- package/src/components/ui/panels/multi-field-value.test.ts +209 -0
- package/src/components/ui/panels/multi-field-value.ts +163 -0
- package/src/components/ui/panels/multi-height-mode.tsx +185 -0
- package/src/components/ui/panels/multi-parametric-inspector.tsx +263 -0
- package/src/components/ui/panels/multi-selection-panel.tsx +78 -26
- package/src/components/ui/panels/node-display.ts +4 -0
- package/src/components/ui/panels/panel-manager.tsx +77 -3
- package/src/components/ui/panels/panel-wrapper.tsx +228 -12
- package/src/components/ui/panels/parametric-field-control.tsx +173 -0
- package/src/components/ui/panels/parametric-field-utils.ts +18 -0
- package/src/components/ui/panels/parametric-inspector.tsx +29 -162
- package/src/components/ui/panels/reference-panel.tsx +31 -1
- package/src/components/ui/primitives/shortcut-token.tsx +23 -1
- package/src/components/ui/scene-loader.tsx +35 -0
- package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +7 -2
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +390 -36
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +31 -23
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +140 -0
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +109 -0
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +128 -46
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +5 -2
- package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +28 -11
- package/src/components/ui/sidebar/panels/site-panel/tree-node.test.ts +8 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +22 -7
- package/src/components/ui/sidebar/tab-bar.tsx +9 -1
- package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
- package/src/components/viewer/floorplan-compass-button.tsx +50 -0
- package/src/components/viewer/floorplan-preview-geometry.test.ts +107 -0
- package/src/components/viewer/floorplan-preview-geometry.ts +281 -0
- package/src/components/viewer/floorplan-preview-navigation.test.ts +43 -0
- package/src/components/viewer/floorplan-preview-navigation.ts +62 -0
- package/src/components/viewer/floorplan-preview.tsx +1069 -0
- package/src/components/viewer/use-viewer-camera-navigation-sync.ts +140 -0
- package/src/components/viewer/viewer-scene-header.tsx +2 -1
- package/src/components/viewer/viewer-stage-modes.test.ts +38 -0
- package/src/components/viewer/viewer-stage-modes.ts +49 -0
- package/src/components/viewer/viewer-stage-switcher.tsx +95 -0
- package/src/components/viewer/viewer-stage.test.tsx +47 -0
- package/src/components/viewer/viewer-stage.tsx +221 -0
- package/src/components/viewer-overlay.tsx +13 -30
- package/src/components/viewer-zone-system.tsx +6 -2
- package/src/components/walkthrough-hud.tsx +19 -10
- package/src/hooks/use-auto-save.test.ts +122 -1
- package/src/hooks/use-auto-save.ts +119 -13
- package/src/hooks/use-ceiling-events.test.ts +122 -0
- package/src/hooks/use-drag-action.ts +17 -3
- package/src/hooks/use-grid-events.test.ts +240 -0
- package/src/hooks/use-grid-events.ts +190 -11
- package/src/hooks/use-keyboard.test.ts +244 -0
- package/src/hooks/use-keyboard.ts +164 -63
- package/src/hooks/use-registered-tool-enabled.ts +20 -0
- package/src/hooks/use-save-shortcut.ts +33 -0
- package/src/index.tsx +109 -4
- package/src/lib/active-placement-surface.test.ts +16 -0
- package/src/lib/active-placement-surface.ts +14 -0
- package/src/lib/ceiling-surface-raycast.test.ts +64 -0
- package/src/lib/ceiling-surface-raycast.ts +12 -0
- package/src/lib/contextual-help-extension.ts +17 -0
- package/src/lib/contextual-help.test.ts +8 -0
- package/src/lib/contextual-help.ts +8 -0
- package/src/lib/continuation.test.ts +11 -0
- package/src/lib/continuation.ts +8 -1
- package/src/lib/direct-manipulation.test.ts +132 -0
- package/src/lib/direct-manipulation.ts +38 -1
- package/src/lib/editor-api.ts +6 -3
- package/src/lib/elevation-guides.test.ts +28 -1
- package/src/lib/elevation-guides.ts +22 -7
- package/src/lib/floating-menu-scale.test.ts +29 -0
- package/src/lib/floating-menu-scale.ts +14 -0
- package/src/lib/floorplan/apply-alignment.test.ts +47 -2
- package/src/lib/floorplan/apply-alignment.ts +10 -1
- package/src/lib/floorplan/floorplan-export.test.ts +397 -1
- package/src/lib/floorplan/floorplan-export.tsx +111 -16
- package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +26 -0
- package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +32 -24
- package/src/lib/floorplan/floorplan-readonly.test.ts +47 -0
- package/src/lib/floorplan/floorplan-readonly.ts +85 -0
- package/src/lib/floorplan-grid-event-point.test.ts +29 -0
- package/src/lib/floorplan-grid-event-point.ts +13 -0
- package/src/lib/glb-export.test.ts +1169 -21
- package/src/lib/glb-export.ts +629 -101
- package/src/lib/grid-event-presentation.ts +16 -0
- package/src/lib/history.test.ts +523 -77
- package/src/lib/history.ts +133 -14
- package/src/lib/host-tree-children.test.ts +27 -0
- package/src/lib/host-tree-children.ts +79 -0
- package/src/lib/inspector-card-mode.test.ts +92 -0
- package/src/lib/inspector-card-mode.ts +60 -0
- package/src/lib/interaction/hot-set.test.ts +10 -1
- package/src/lib/interaction/hot-set.ts +3 -1
- package/src/lib/interaction/overlay-policy.test.ts +33 -1
- package/src/lib/interaction/overlay-policy.ts +16 -0
- package/src/lib/interaction/registered-drafting.ts +36 -0
- package/src/lib/interaction/scope.ts +31 -0
- package/src/lib/level-print-export.test.ts +484 -0
- package/src/lib/level-print-export.ts +526 -0
- package/src/lib/local-project-presentation-persistence.test.ts +302 -0
- package/src/lib/local-project-presentation-persistence.ts +335 -0
- package/src/lib/material-paint.ts +2 -0
- package/src/lib/measurement-parser.ts +3 -0
- package/src/lib/measurements.test.ts +4 -0
- package/src/lib/model-export.ts +29 -0
- package/src/lib/paint-preview-owner.ts +71 -0
- package/src/lib/paint-scope.test.ts +58 -2
- package/src/lib/paint-scope.ts +8 -2
- package/src/lib/planar-cursor-placement.test.ts +178 -1
- package/src/lib/planar-cursor-placement.ts +61 -5
- package/src/lib/plugin-panels.test.ts +92 -1
- package/src/lib/plugin-panels.ts +48 -0
- package/src/lib/portable-export.test.ts +366 -0
- package/src/lib/portable-export.ts +969 -0
- package/src/lib/print-3mf.test.ts +86 -0
- package/src/lib/print-3mf.ts +161 -0
- package/src/lib/print-content-scope.test.ts +107 -0
- package/src/lib/print-content-scope.ts +90 -0
- package/src/lib/print-export.test.ts +294 -0
- package/src/lib/print-export.ts +735 -0
- package/src/lib/print-feature-thickness.test.ts +147 -0
- package/src/lib/print-feature-thickness.ts +180 -0
- package/src/lib/print-golden-house.test-fixture.ts +352 -0
- package/src/lib/print-golden-house.test.ts +249 -0
- package/src/lib/print-roof-solids.test.ts +133 -0
- package/src/lib/print-roof-solids.ts +630 -0
- package/src/lib/print-shell-compiler-baseline.test.ts +419 -0
- package/src/lib/print-shell-compiler-baseline.ts +225 -0
- package/src/lib/print-shell-compiler-manifold-core.ts +330 -0
- package/src/lib/print-shell-compiler-manifold-worker.ts +187 -0
- package/src/lib/print-shell-compiler-manifold.worker.ts +20 -0
- package/src/lib/print-shell-compiler-mesh-data.ts +32 -0
- package/src/lib/print-shell-compiler-protocol.ts +30 -0
- package/src/lib/print-shell-compiler.ts +273 -0
- package/src/lib/print-wall-solids.test.ts +194 -0
- package/src/lib/print-wall-solids.ts +328 -0
- package/src/lib/rigid-plan-svg-transform.test.ts +35 -0
- package/src/lib/rigid-plan-svg-transform.ts +24 -0
- package/src/lib/scene.ts +22 -8
- package/src/lib/selection-routing.test.ts +125 -6
- package/src/lib/selection-routing.ts +34 -1
- package/src/lib/session-groups.test.ts +88 -0
- package/src/lib/session-groups.ts +201 -0
- package/src/lib/sfx-bus.ts +4 -0
- package/src/lib/sfx-player.ts +8 -0
- package/src/lib/snapping-mode.test.ts +32 -4
- package/src/lib/snapping-mode.ts +11 -7
- package/src/lib/structured-clone-fallback.test.ts +45 -0
- package/src/lib/structured-clone-fallback.ts +24 -0
- package/src/lib/usdz-export.ts +38 -0
- package/src/lib/walkthrough-pointer-lock.ts +47 -0
- package/src/store/live-draft-preview-stores.test.ts +3 -0
- package/src/store/terrain-sculpt-mode.test.ts +3 -6
- package/src/store/tool-mode.test.ts +87 -0
- package/src/store/use-camera-hint-focus.ts +48 -0
- package/src/store/use-editor.tsx +299 -188
- package/src/store/use-floorplan-draft-preview.ts +9 -0
- package/src/store/use-interaction-scope.test.ts +23 -1
- package/src/store/use-interaction-scope.ts +63 -2
- package/src/store/use-placement-preview.ts +56 -3
- package/src/store/use-session-groups.ts +103 -0
- package/src/components/tools/roof/roof-tool.tsx +0 -656
- package/src/components/ui/helpers/roof-helper.tsx +0 -14
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import type {
|
|
3
|
+
SnapshotCaptureFailedEvent,
|
|
4
|
+
SnapshotCapturePose,
|
|
5
|
+
ThumbnailGenerateEvent,
|
|
6
|
+
} from '@pascal-app/core'
|
|
7
|
+
import { Euler, PerspectiveCamera, Quaternion, Vector3 } from 'three'
|
|
8
|
+
import {
|
|
9
|
+
applySnapshotCapturePose,
|
|
10
|
+
captureSnapshotScene,
|
|
11
|
+
createSnapshotQueue,
|
|
12
|
+
enqueueSnapshotCapture,
|
|
13
|
+
isOverlaySnapshotSave,
|
|
14
|
+
runSnapshotCapture,
|
|
15
|
+
} from './snapshot-capture'
|
|
16
|
+
|
|
17
|
+
test('manual capture feedback ignores background requests and other projects', () => {
|
|
18
|
+
const saved = { id: 'frame', url: 'https://example.test/frame.webp', width: 1920, height: 1080 }
|
|
19
|
+
expect(isOverlaySnapshotSave(undefined, 'project')).toBe(true)
|
|
20
|
+
expect(isOverlaySnapshotSave(saved, 'project')).toBe(true)
|
|
21
|
+
expect(isOverlaySnapshotSave({ ...saved, projectId: 'project' }, 'project')).toBe(true)
|
|
22
|
+
expect(isOverlaySnapshotSave({ ...saved, projectId: 'other' }, 'project')).toBe(false)
|
|
23
|
+
expect(isOverlaySnapshotSave({ ...saved, requestId: 'background' }, 'project')).toBe(false)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
describe('capture scene restoration', () => {
|
|
27
|
+
test('restores all presentation changes before GPU readback settles', async () => {
|
|
28
|
+
const events: string[] = []
|
|
29
|
+
let readback!: (value: string) => void
|
|
30
|
+
const result = captureSnapshotScene((restore) => {
|
|
31
|
+
restore(() => events.push('levels restored'))
|
|
32
|
+
restore(() => events.push('materials restored'))
|
|
33
|
+
events.push('rendered')
|
|
34
|
+
return new Promise<string>((resolve) => {
|
|
35
|
+
readback = resolve
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
expect(events).toEqual(['rendered', 'materials restored', 'levels restored'])
|
|
39
|
+
readback('frame')
|
|
40
|
+
expect(await result).toBe('frame')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('a setup failure still restores every already-applied change', async () => {
|
|
44
|
+
let restored = false
|
|
45
|
+
await expect(
|
|
46
|
+
captureSnapshotScene((restore) => {
|
|
47
|
+
restore(() => {
|
|
48
|
+
restored = true
|
|
49
|
+
})
|
|
50
|
+
throw new Error('Framing failed')
|
|
51
|
+
}),
|
|
52
|
+
).rejects.toThrow('Framing failed')
|
|
53
|
+
expect(restored).toBe(true)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('an after-capture listener failure cannot prevent level and visibility restoration', async () => {
|
|
57
|
+
const restored: string[] = []
|
|
58
|
+
await expect(
|
|
59
|
+
captureSnapshotScene((restore) => {
|
|
60
|
+
restore(() => {
|
|
61
|
+
restored.push('levels')
|
|
62
|
+
})
|
|
63
|
+
restore(() => {
|
|
64
|
+
restored.push('visibility')
|
|
65
|
+
})
|
|
66
|
+
restore(() => {
|
|
67
|
+
throw new Error('Listener failed')
|
|
68
|
+
})
|
|
69
|
+
return Promise.reject(new Error('Readback failed'))
|
|
70
|
+
}),
|
|
71
|
+
).rejects.toThrow('Listener failed')
|
|
72
|
+
expect(restored).toEqual(['visibility', 'levels'])
|
|
73
|
+
await Promise.resolve()
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe('explicit snapshot camera', () => {
|
|
78
|
+
const pose: SnapshotCapturePose = {
|
|
79
|
+
position: [4, 2, -3],
|
|
80
|
+
quaternion: new Quaternion().setFromEuler(new Euler(0.2, 1.1, 0.6)).toArray(),
|
|
81
|
+
fov: 47,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
test('retains the authored world pose including roll without moving the viewport camera', () => {
|
|
85
|
+
const viewportCamera = new PerspectiveCamera(65, 2, 0.1, 1000)
|
|
86
|
+
viewportCamera.position.set(10, 20, 30)
|
|
87
|
+
viewportCamera.rotation.set(0.9, 0.8, 0.7)
|
|
88
|
+
const previousPosition = viewportCamera.position.toArray()
|
|
89
|
+
const previousQuaternion = viewportCamera.quaternion.toArray()
|
|
90
|
+
const captureCamera = viewportCamera.clone()
|
|
91
|
+
|
|
92
|
+
applySnapshotCapturePose(
|
|
93
|
+
captureCamera,
|
|
94
|
+
pose,
|
|
95
|
+
{ width: 1600, height: 900 },
|
|
96
|
+
{ w: 1920, h: 1080 },
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
expect(captureCamera.position.toArray()).toEqual(pose.position)
|
|
100
|
+
expect(captureCamera.quaternion.toArray()).toEqual(pose.quaternion)
|
|
101
|
+
expect(captureCamera.fov).toBeCloseTo(pose.fov, 10)
|
|
102
|
+
expect(viewportCamera.position.toArray()).toEqual(previousPosition)
|
|
103
|
+
expect(viewportCamera.quaternion.toArray()).toEqual(previousQuaternion)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test.each([
|
|
107
|
+
{ width: 900, height: 1600, w: 1920, h: 1080 },
|
|
108
|
+
{ width: 1600, height: 900, w: 1080, h: 1920 },
|
|
109
|
+
{ width: 1440, height: 900, w: 1920, h: 1080 },
|
|
110
|
+
])('matches the authored composition after center crop: %j', ({ width, height, w, h }) => {
|
|
111
|
+
const capture = new PerspectiveCamera()
|
|
112
|
+
applySnapshotCapturePose(capture, pose, { width, height }, { w, h })
|
|
113
|
+
const ideal = new PerspectiveCamera(pose.fov, w / h)
|
|
114
|
+
ideal.position.fromArray(pose.position)
|
|
115
|
+
ideal.quaternion.fromArray(pose.quaternion)
|
|
116
|
+
ideal.updateMatrixWorld()
|
|
117
|
+
const worldPoint = new Vector3(0.4, 0.7, -7)
|
|
118
|
+
.applyQuaternion(ideal.quaternion)
|
|
119
|
+
.add(ideal.position)
|
|
120
|
+
const expected = worldPoint.clone().project(ideal)
|
|
121
|
+
const actual = worldPoint.clone().project(capture)
|
|
122
|
+
const cropHeight = width / height < w / h ? Math.round(width / (w / h)) : height
|
|
123
|
+
const cropWidth = width / height > w / h ? Math.round(height * (w / h)) : width
|
|
124
|
+
|
|
125
|
+
expect((actual.x * width) / cropWidth).toBeCloseTo(expected.x, 3)
|
|
126
|
+
expect((actual.y * height) / cropHeight).toBeCloseTo(expected.y, 10)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('rejects a malformed lens before mutating the capture camera', () => {
|
|
130
|
+
const camera = new PerspectiveCamera()
|
|
131
|
+
expect(() =>
|
|
132
|
+
applySnapshotCapturePose(
|
|
133
|
+
camera,
|
|
134
|
+
{ ...pose, fov: Number.NaN },
|
|
135
|
+
{ width: 1600, height: 900 },
|
|
136
|
+
{ w: 1920, h: 1080 },
|
|
137
|
+
),
|
|
138
|
+
).toThrow('Invalid snapshot camera pose or dimensions')
|
|
139
|
+
expect(camera.position.toArray()).toEqual([0, 0, 0])
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
describe('snapshot request correlation', () => {
|
|
144
|
+
test('queued authored frames survive a viewport camera and callback replacement', async () => {
|
|
145
|
+
const enqueue = createSnapshotQueue()
|
|
146
|
+
const version = { current: 1 }
|
|
147
|
+
const failures: SnapshotCaptureFailedEvent[] = []
|
|
148
|
+
const captured: ThumbnailGenerateEvent[] = []
|
|
149
|
+
let release!: () => void
|
|
150
|
+
const background = enqueue(
|
|
151
|
+
{},
|
|
152
|
+
() =>
|
|
153
|
+
new Promise<void>((resolve) => {
|
|
154
|
+
release = resolve
|
|
155
|
+
}),
|
|
156
|
+
)
|
|
157
|
+
await Promise.resolve()
|
|
158
|
+
let viewportCamera = new PerspectiveCamera()
|
|
159
|
+
const pose: SnapshotCapturePose = {
|
|
160
|
+
position: [4, 2, -3],
|
|
161
|
+
quaternion: [0, 0, 0, 1],
|
|
162
|
+
fov: 47,
|
|
163
|
+
}
|
|
164
|
+
const event: ThumbnailGenerateEvent = {
|
|
165
|
+
projectId: 'project',
|
|
166
|
+
requestId: 'frame',
|
|
167
|
+
captureMode: 'standard',
|
|
168
|
+
cameraPose: pose,
|
|
169
|
+
}
|
|
170
|
+
const generate = async (request: ThumbnailGenerateEvent) => {
|
|
171
|
+
const captureCamera = viewportCamera.clone()
|
|
172
|
+
applySnapshotCapturePose(
|
|
173
|
+
captureCamera,
|
|
174
|
+
request.cameraPose!,
|
|
175
|
+
{ width: 1600, height: 900 },
|
|
176
|
+
{ w: 1920, h: 1080 },
|
|
177
|
+
)
|
|
178
|
+
expect(captureCamera.position.toArray()).toEqual(pose.position)
|
|
179
|
+
captured.push(request)
|
|
180
|
+
}
|
|
181
|
+
const first = enqueueSnapshotCapture(enqueue, version, event, generate, (failure) =>
|
|
182
|
+
failures.push(failure),
|
|
183
|
+
)
|
|
184
|
+
viewportCamera = new PerspectiveCamera(90)
|
|
185
|
+
viewportCamera.position.set(20, 10, 5)
|
|
186
|
+
const second = enqueueSnapshotCapture(
|
|
187
|
+
enqueue,
|
|
188
|
+
version,
|
|
189
|
+
{ ...event, requestId: 'next-frame' },
|
|
190
|
+
async (request) => generate(request),
|
|
191
|
+
(failure) => failures.push(failure),
|
|
192
|
+
)
|
|
193
|
+
release()
|
|
194
|
+
await Promise.all([background, first, second])
|
|
195
|
+
expect(captured.map((request) => request.requestId)).toEqual(['frame', 'next-frame'])
|
|
196
|
+
expect(viewportCamera.position.toArray()).toEqual([20, 10, 5])
|
|
197
|
+
expect(failures).toEqual([])
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
test('disposing the capture pipeline cancels queued frames before the replacement scene renders', async () => {
|
|
201
|
+
const enqueue = createSnapshotQueue()
|
|
202
|
+
const version = { current: 1 }
|
|
203
|
+
const failures: SnapshotCaptureFailedEvent[] = []
|
|
204
|
+
const captured: string[] = []
|
|
205
|
+
let release!: () => void
|
|
206
|
+
const background = enqueue(
|
|
207
|
+
{},
|
|
208
|
+
() =>
|
|
209
|
+
new Promise<void>((resolve) => {
|
|
210
|
+
release = resolve
|
|
211
|
+
}),
|
|
212
|
+
)
|
|
213
|
+
await Promise.resolve()
|
|
214
|
+
const capture = async (event: ThumbnailGenerateEvent) => {
|
|
215
|
+
captured.push(event.requestId!)
|
|
216
|
+
}
|
|
217
|
+
const oldFrame = enqueueSnapshotCapture(
|
|
218
|
+
enqueue,
|
|
219
|
+
version,
|
|
220
|
+
{ projectId: 'old', requestId: 'old-frame' },
|
|
221
|
+
capture,
|
|
222
|
+
(failure) => failures.push(failure),
|
|
223
|
+
)
|
|
224
|
+
version.current += 1
|
|
225
|
+
const newFrame = enqueueSnapshotCapture(
|
|
226
|
+
enqueue,
|
|
227
|
+
version,
|
|
228
|
+
{ projectId: 'new', requestId: 'new-frame' },
|
|
229
|
+
capture,
|
|
230
|
+
(failure) => failures.push(failure),
|
|
231
|
+
)
|
|
232
|
+
release()
|
|
233
|
+
await Promise.all([background, oldFrame, newFrame])
|
|
234
|
+
expect(captured).toEqual(['new-frame'])
|
|
235
|
+
expect(failures).toEqual([
|
|
236
|
+
{ requestId: 'old-frame', error: 'The scene changed before capture. Try again.' },
|
|
237
|
+
])
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
test.each([
|
|
241
|
+
'standard',
|
|
242
|
+
'viewport',
|
|
243
|
+
'area',
|
|
244
|
+
] as const)('a manual %s shutter waits for a background upload; redundant autosaves are dropped', async (captureMode) => {
|
|
245
|
+
const enqueue = createSnapshotQueue()
|
|
246
|
+
const busy = { current: false }
|
|
247
|
+
const failures: SnapshotCaptureFailedEvent[] = []
|
|
248
|
+
const events: string[] = []
|
|
249
|
+
let finishUpload!: () => void
|
|
250
|
+
const uploading = new Promise<void>((resolve) => {
|
|
251
|
+
finishUpload = resolve
|
|
252
|
+
})
|
|
253
|
+
const background = enqueue({ requestId: 'background' }, () =>
|
|
254
|
+
runSnapshotCapture(
|
|
255
|
+
'background',
|
|
256
|
+
busy,
|
|
257
|
+
async () => {
|
|
258
|
+
events.push('background rendered')
|
|
259
|
+
await uploading
|
|
260
|
+
events.push('background saved')
|
|
261
|
+
},
|
|
262
|
+
(failure) => failures.push(failure),
|
|
263
|
+
),
|
|
264
|
+
)
|
|
265
|
+
await Promise.resolve()
|
|
266
|
+
expect(busy.current).toBe(true)
|
|
267
|
+
const autosave = enqueue({}, async () => {
|
|
268
|
+
events.push('autosave')
|
|
269
|
+
})
|
|
270
|
+
let overlayState = 'capturing'
|
|
271
|
+
const manual = enqueue({ captureMode }, () =>
|
|
272
|
+
runSnapshotCapture(
|
|
273
|
+
undefined,
|
|
274
|
+
busy,
|
|
275
|
+
async () => {
|
|
276
|
+
events.push('manual saved')
|
|
277
|
+
overlayState = 'saved'
|
|
278
|
+
},
|
|
279
|
+
(failure) => failures.push(failure),
|
|
280
|
+
),
|
|
281
|
+
)
|
|
282
|
+
await autosave
|
|
283
|
+
expect(overlayState).toBe('capturing')
|
|
284
|
+
expect(events).toEqual(['background rendered'])
|
|
285
|
+
finishUpload()
|
|
286
|
+
await Promise.all([background, manual])
|
|
287
|
+
expect(events).toEqual(['background rendered', 'background saved', 'manual saved'])
|
|
288
|
+
expect(overlayState).toBe('saved')
|
|
289
|
+
expect(failures).toEqual([])
|
|
290
|
+
expect(busy.current).toBe(false)
|
|
291
|
+
await enqueue({}, async () => {
|
|
292
|
+
events.push('idle autosave')
|
|
293
|
+
})
|
|
294
|
+
expect(events.at(-1)).toBe('idle autosave')
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
test('queues a frame dispatched by a save callback until the previous capture releases its lock', async () => {
|
|
298
|
+
const enqueue = createSnapshotQueue()
|
|
299
|
+
const busy = { current: false }
|
|
300
|
+
const failures: SnapshotCaptureFailedEvent[] = []
|
|
301
|
+
const events: string[] = []
|
|
302
|
+
let second: Promise<void> | undefined
|
|
303
|
+
await enqueue({ requestId: 'first' }, () =>
|
|
304
|
+
runSnapshotCapture(
|
|
305
|
+
'first',
|
|
306
|
+
busy,
|
|
307
|
+
async () => {
|
|
308
|
+
events.push('first saved')
|
|
309
|
+
second = enqueue({ requestId: 'second' }, () =>
|
|
310
|
+
runSnapshotCapture(
|
|
311
|
+
'second',
|
|
312
|
+
busy,
|
|
313
|
+
async () => {
|
|
314
|
+
events.push('second saved')
|
|
315
|
+
},
|
|
316
|
+
(failure) => failures.push(failure),
|
|
317
|
+
),
|
|
318
|
+
)
|
|
319
|
+
await Promise.resolve()
|
|
320
|
+
events.push('first callback returned')
|
|
321
|
+
},
|
|
322
|
+
(failure) => failures.push(failure),
|
|
323
|
+
),
|
|
324
|
+
)
|
|
325
|
+
await second
|
|
326
|
+
expect(events).toEqual(['first saved', 'first callback returned', 'second saved'])
|
|
327
|
+
expect(failures).toEqual([])
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
test('a rejected queue task does not strand later captures', async () => {
|
|
331
|
+
const enqueue = createSnapshotQueue()
|
|
332
|
+
await expect(
|
|
333
|
+
enqueue({}, async () => {
|
|
334
|
+
throw new Error('Failed')
|
|
335
|
+
}),
|
|
336
|
+
).rejects.toThrow('Failed')
|
|
337
|
+
let captured = false
|
|
338
|
+
await enqueue({}, async () => {
|
|
339
|
+
captured = true
|
|
340
|
+
})
|
|
341
|
+
expect(captured).toBe(true)
|
|
342
|
+
})
|
|
343
|
+
test('reports only the rejected request as busy and allows the next capture after completion', async () => {
|
|
344
|
+
const busy = { current: false }
|
|
345
|
+
const failures: SnapshotCaptureFailedEvent[] = []
|
|
346
|
+
const completed: string[] = []
|
|
347
|
+
let finish!: () => void
|
|
348
|
+
const pending = new Promise<void>((resolve) => {
|
|
349
|
+
finish = resolve
|
|
350
|
+
})
|
|
351
|
+
const first = runSnapshotCapture(
|
|
352
|
+
'first',
|
|
353
|
+
busy,
|
|
354
|
+
async () => {
|
|
355
|
+
await pending
|
|
356
|
+
completed.push('first')
|
|
357
|
+
},
|
|
358
|
+
(failure) => failures.push(failure),
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
await runSnapshotCapture(
|
|
362
|
+
'second',
|
|
363
|
+
busy,
|
|
364
|
+
async () => {
|
|
365
|
+
completed.push('second')
|
|
366
|
+
},
|
|
367
|
+
(failure) => failures.push(failure),
|
|
368
|
+
)
|
|
369
|
+
expect(failures).toEqual([
|
|
370
|
+
{
|
|
371
|
+
requestId: 'second',
|
|
372
|
+
error: 'Another snapshot is being captured. Try again.',
|
|
373
|
+
},
|
|
374
|
+
])
|
|
375
|
+
expect(completed).toEqual([])
|
|
376
|
+
expect(busy.current).toBe(true)
|
|
377
|
+
|
|
378
|
+
finish()
|
|
379
|
+
await first
|
|
380
|
+
await runSnapshotCapture(
|
|
381
|
+
'third',
|
|
382
|
+
busy,
|
|
383
|
+
async () => {
|
|
384
|
+
completed.push('third')
|
|
385
|
+
},
|
|
386
|
+
(failure) => failures.push(failure),
|
|
387
|
+
)
|
|
388
|
+
expect(completed).toEqual(['first', 'third'])
|
|
389
|
+
expect(busy.current).toBe(false)
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
test('correlates render failures and releases the capture lock', async () => {
|
|
393
|
+
const busy = { current: false }
|
|
394
|
+
const failures: SnapshotCaptureFailedEvent[] = []
|
|
395
|
+
await runSnapshotCapture(
|
|
396
|
+
'failed-frame',
|
|
397
|
+
busy,
|
|
398
|
+
async () => {
|
|
399
|
+
throw new Error('GPU readback failed')
|
|
400
|
+
},
|
|
401
|
+
(failure) => failures.push(failure),
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
expect(failures).toEqual([{ requestId: 'failed-frame', error: 'GPU readback failed' }])
|
|
405
|
+
expect(busy.current).toBe(false)
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
test('uncorrelated legacy requests still capture', async () => {
|
|
409
|
+
let captured = false
|
|
410
|
+
const failures: SnapshotCaptureFailedEvent[] = []
|
|
411
|
+
await runSnapshotCapture(
|
|
412
|
+
undefined,
|
|
413
|
+
{ current: false },
|
|
414
|
+
async () => {
|
|
415
|
+
captured = true
|
|
416
|
+
},
|
|
417
|
+
(failure) => failures.push(failure),
|
|
418
|
+
)
|
|
419
|
+
expect(captured).toBe(true)
|
|
420
|
+
expect(failures).toEqual([])
|
|
421
|
+
})
|
|
422
|
+
})
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
SnapshotCaptureFailedEvent,
|
|
3
|
+
SnapshotCapturePose,
|
|
4
|
+
SnapshotSavedEvent,
|
|
5
|
+
ThumbnailGenerateEvent,
|
|
6
|
+
} from '@pascal-app/core'
|
|
7
|
+
import { MathUtils, type PerspectiveCamera } from 'three'
|
|
8
|
+
|
|
9
|
+
export function isOverlaySnapshotSave(event: SnapshotSavedEvent | undefined, projectId: string) {
|
|
10
|
+
return !event?.requestId && (!event?.projectId || event.projectId === projectId)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function createSnapshotQueue() {
|
|
14
|
+
let tail = Promise.resolve()
|
|
15
|
+
let pendingCount = 0
|
|
16
|
+
return (
|
|
17
|
+
event: Pick<ThumbnailGenerateEvent, 'requestId' | 'captureMode'>,
|
|
18
|
+
capture: () => Promise<void>,
|
|
19
|
+
) => {
|
|
20
|
+
if (pendingCount > 0 && !event.requestId && !event.captureMode) return Promise.resolve()
|
|
21
|
+
pendingCount += 1
|
|
22
|
+
const pending = tail.then(capture).finally(() => {
|
|
23
|
+
pendingCount -= 1
|
|
24
|
+
})
|
|
25
|
+
tail = pending.catch(() => {})
|
|
26
|
+
return pending
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function enqueueSnapshotCapture(
|
|
31
|
+
enqueue: ReturnType<typeof createSnapshotQueue>,
|
|
32
|
+
version: { current: number },
|
|
33
|
+
event: ThumbnailGenerateEvent,
|
|
34
|
+
capture: (event: ThumbnailGenerateEvent) => Promise<void>,
|
|
35
|
+
reportFailure: (failure: SnapshotCaptureFailedEvent) => void,
|
|
36
|
+
) {
|
|
37
|
+
const requestedVersion = version.current
|
|
38
|
+
return enqueue(event, async () => {
|
|
39
|
+
if (requestedVersion !== version.current) {
|
|
40
|
+
if (event.requestId) {
|
|
41
|
+
reportFailure({
|
|
42
|
+
requestId: event.requestId,
|
|
43
|
+
error: 'The scene changed before capture. Try again.',
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
await capture(event)
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function captureSnapshotScene<T>(
|
|
53
|
+
capture: (restore: (callback: () => void) => void) => T | Promise<T>,
|
|
54
|
+
): Promise<T> {
|
|
55
|
+
const restorers: Array<() => void> = []
|
|
56
|
+
const errors: unknown[] = []
|
|
57
|
+
let result: T | Promise<T> | undefined
|
|
58
|
+
try {
|
|
59
|
+
result = capture((restore) => restorers.push(restore))
|
|
60
|
+
} catch (error) {
|
|
61
|
+
errors.push(error)
|
|
62
|
+
}
|
|
63
|
+
// The offscreen render is synchronous. Restore before adopting its promise,
|
|
64
|
+
// so GPU readback never leaves the interactive scene in its capture pose.
|
|
65
|
+
for (const restore of restorers.reverse()) {
|
|
66
|
+
try {
|
|
67
|
+
restore()
|
|
68
|
+
} catch (error) {
|
|
69
|
+
errors.push(error)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (errors.length > 0) {
|
|
73
|
+
void Promise.resolve(result).catch(() => {})
|
|
74
|
+
throw errors.length === 1
|
|
75
|
+
? errors[0]
|
|
76
|
+
: new AggregateError(errors, 'Snapshot restoration failed')
|
|
77
|
+
}
|
|
78
|
+
return result as T | Promise<T>
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function applySnapshotCapturePose(
|
|
82
|
+
camera: PerspectiveCamera,
|
|
83
|
+
pose: SnapshotCapturePose,
|
|
84
|
+
viewport: { width: number; height: number },
|
|
85
|
+
output: { w: number; h: number },
|
|
86
|
+
) {
|
|
87
|
+
if (
|
|
88
|
+
![...pose.position, ...pose.quaternion, pose.fov].every(Number.isFinite) ||
|
|
89
|
+
pose.fov <= 0 ||
|
|
90
|
+
pose.fov >= 180 ||
|
|
91
|
+
![viewport.width, viewport.height, output.w, output.h].every(
|
|
92
|
+
(dimension) => Number.isFinite(dimension) && dimension >= 1,
|
|
93
|
+
) ||
|
|
94
|
+
Math.abs(pose.quaternion.reduce((sum, value) => sum + value * value, 0) - 1) > 0.001
|
|
95
|
+
) {
|
|
96
|
+
throw new Error('Invalid snapshot camera pose or dimensions')
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const aspect = viewport.width / viewport.height
|
|
100
|
+
const outputAspect = output.w / output.h
|
|
101
|
+
const cropHeight =
|
|
102
|
+
aspect < outputAspect ? Math.round(viewport.width / outputAspect) : viewport.height
|
|
103
|
+
if (cropHeight < 1) throw new Error('Snapshot crop is too small')
|
|
104
|
+
|
|
105
|
+
camera.position.fromArray(pose.position)
|
|
106
|
+
camera.quaternion.fromArray(pose.quaternion)
|
|
107
|
+
camera.aspect = aspect
|
|
108
|
+
// The snapshot pipeline center-crops a viewport-sized render. Expand its
|
|
109
|
+
// vertical FOV so that the cropped image keeps the authored lens framing.
|
|
110
|
+
camera.fov = MathUtils.radToDeg(
|
|
111
|
+
2 * Math.atan(Math.tan(MathUtils.degToRad(pose.fov) / 2) * (viewport.height / cropHeight)),
|
|
112
|
+
)
|
|
113
|
+
camera.zoom = 1
|
|
114
|
+
camera.updateProjectionMatrix()
|
|
115
|
+
camera.updateMatrixWorld()
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function runSnapshotCapture(
|
|
119
|
+
requestId: string | undefined,
|
|
120
|
+
busy: { current: boolean },
|
|
121
|
+
capture: () => Promise<void>,
|
|
122
|
+
reportFailure: (failure: SnapshotCaptureFailedEvent) => void,
|
|
123
|
+
) {
|
|
124
|
+
if (busy.current) {
|
|
125
|
+
if (requestId)
|
|
126
|
+
reportFailure({ requestId, error: 'Another snapshot is being captured. Try again.' })
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
busy.current = true
|
|
131
|
+
try {
|
|
132
|
+
await capture()
|
|
133
|
+
} catch (error) {
|
|
134
|
+
if (requestId) {
|
|
135
|
+
reportFailure({
|
|
136
|
+
requestId,
|
|
137
|
+
error: error instanceof Error ? error.message : 'Snapshot capture failed',
|
|
138
|
+
})
|
|
139
|
+
} else {
|
|
140
|
+
console.error('Failed to generate thumbnail:', error)
|
|
141
|
+
}
|
|
142
|
+
} finally {
|
|
143
|
+
busy.current = false
|
|
144
|
+
}
|
|
145
|
+
}
|