@pascal-app/editor 1.0.0-beta.5 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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/bake-exporter.tsx +5 -0
- package/src/components/editor/bake-thumbnail.tsx +4 -1
- 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 +9 -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 +180 -12
- package/src/components/editor/floating-action-menu.tsx +55 -51
- package/src/components/editor/floorplan-panel.tsx +99 -15
- package/src/components/editor/grid.tsx +12 -7
- package/src/components/editor/group-actions.ts +34 -50
- 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/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 +78 -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 +23 -3
- package/src/components/editor/index.tsx +151 -24
- package/src/components/editor/node-action-menu.tsx +14 -1
- package/src/components/editor/node-arrow-handles.tsx +67 -119
- package/src/components/editor/selection-manager.tsx +153 -42
- package/src/components/editor/snapshot-capture-overlay.tsx +298 -39
- 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 -239
- package/src/components/editor/use-floorplan-background-placement.ts +53 -5
- package/src/components/editor/wall-move-side-handles.tsx +299 -7
- 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-action-menu.tsx +12 -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 +226 -35
- package/src/components/procedural-items/inspector.tsx +223 -0
- 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/ceiling/ceiling-system.tsx +3 -1
- 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/item-surface-placement.test.ts +175 -0
- package/src/components/tools/item/item-surface-strategy-resolver.test.ts +210 -0
- package/src/components/tools/item/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 +331 -4
- package/src/components/tools/item/placement-strategies.ts +304 -171
- 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 +10 -0
- package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -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 +116 -44
- package/src/components/tools/item/use-placement-coordinator.tsx +672 -256
- package/src/components/tools/registered-tool-install-lifecycle.test.tsx +256 -0
- package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
- package/src/components/tools/registry/item-surface-move.test.ts +501 -0
- package/src/components/tools/registry/item-surface-move.ts +441 -0
- package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
- package/src/components/tools/registry/move-registry-node-tool.test.ts +10 -0
- package/src/components/tools/registry/move-registry-node-tool.tsx +505 -164
- 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 +57 -7
- 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 +56 -17
- package/src/components/tools/shared/shelf-stickiness.ts +54 -0
- package/src/components/tools/shared/surface-hit.test.ts +56 -0
- package/src/components/tools/shared/surface-hit.ts +23 -0
- package/src/components/tools/shared/surface-rejection.test.tsx +106 -0
- package/src/components/tools/shared/surface-rejection.tsx +29 -0
- package/src/components/tools/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 +320 -2
- package/src/components/tools/wall/wall-drafting.ts +23 -114
- package/src/components/tools/zone/zone-tool.tsx +13 -4
- 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 +63 -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/metric-control.tsx +29 -13
- 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 +44 -21
- 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 +103 -10
- 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 +266 -0
- package/src/components/ui/panels/multi-selection-panel.tsx +48 -37
- 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/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 +35 -164
- package/src/components/ui/panels/reference-panel.tsx +34 -4
- package/src/components/ui/primitives/checkbox.tsx +34 -0
- 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/settings-panel/index.tsx +502 -31
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +20 -23
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +167 -0
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +128 -0
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
- package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +19 -5
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +222 -50
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
- 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/shelf-tree-node.tsx +3 -8
- 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 +20 -7
- 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/sidebar/tab-bar.tsx +2 -0
- package/src/components/ui/sidebar/use-plugin-panels.tsx +17 -2
- package/src/components/ui/snap-target-badge.tsx +2 -0
- package/src/components/viewer/viewer-scene-header.tsx +5 -1
- package/src/components/viewer/viewer-stage.tsx +4 -1
- package/src/components/viewer/viewer-units-panel.tsx +116 -0
- package/src/components/viewer-overlay.tsx +1 -22
- 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 +73 -1
- package/src/hooks/use-auto-save.ts +76 -5
- package/src/hooks/use-ceiling-events.test.ts +144 -0
- package/src/hooks/use-ceiling-events.ts +5 -1
- 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 +132 -63
- package/src/hooks/use-registered-tool-enabled.ts +20 -0
- package/src/hooks/use-save-shortcut.ts +33 -0
- package/src/index.tsx +87 -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/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/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/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 +536 -2
- package/src/lib/floorplan/floorplan-export.tsx +120 -19
- 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.ts +5 -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/fresh-planar-placement.ts +13 -5
- package/src/lib/glb-export-serialize.test.ts +280 -0
- package/src/lib/glb-export.test.ts +1375 -21
- package/src/lib/glb-export.ts +781 -106
- package/src/lib/grid-event-presentation.ts +16 -0
- package/src/lib/history.test.ts +528 -147
- package/src/lib/history.ts +81 -7
- package/src/lib/host-tree-children.test.ts +27 -0
- package/src/lib/host-tree-children.ts +79 -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-duplication.test.ts +73 -0
- package/src/lib/level-duplication.ts +32 -2
- package/src/lib/level-print-export.test.ts +484 -0
- package/src/lib/level-print-export.ts +526 -0
- 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/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/model-export.ts +29 -0
- package/src/lib/node-action-movement.ts +21 -0
- package/src/lib/paint-preview-owner.ts +71 -0
- 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 +455 -0
- package/src/lib/portable-export.ts +1042 -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 +379 -0
- package/src/lib/print-shell-compiler-manifold-worker.ts +200 -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 +38 -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-clipboard.ts +1 -1
- package/src/lib/scene.ts +22 -8
- package/src/lib/selection-routing.test.ts +105 -1
- package/src/lib/selection-routing.ts +19 -0
- 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/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/surface-attachment.ts +77 -0
- package/src/lib/units.ts +254 -0
- package/src/lib/usdz-export.ts +65 -0
- package/src/lib/use-linear-display.ts +8 -32
- 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-handle-group.ts +4 -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-send-to-app.ts +21 -0
- package/src/components/tools/roof/roof-tool.tsx +0 -656
- package/src/components/ui/helpers/roof-helper.tsx +0 -14
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { LocalAppError, probeLocalApp, sendGlbToLocalApp, waitForLocalImport } from './send-to-app'
|
|
3
|
+
|
|
4
|
+
type Handler = (url: string, init?: RequestInit) => Response | Promise<Response>
|
|
5
|
+
|
|
6
|
+
function fakeFetch(handler: Handler): typeof fetch {
|
|
7
|
+
return ((input: RequestInfo | URL, init?: RequestInit) =>
|
|
8
|
+
Promise.resolve(handler(String(input), init))) as typeof fetch
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const json = (body: unknown, status = 200) =>
|
|
12
|
+
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })
|
|
13
|
+
|
|
14
|
+
describe('probeLocalApp', () => {
|
|
15
|
+
test('reports unreachable when nothing answers on any port', async () => {
|
|
16
|
+
const tried: string[] = []
|
|
17
|
+
const probe = await probeLocalApp(
|
|
18
|
+
fakeFetch((url) => {
|
|
19
|
+
tried.push(url)
|
|
20
|
+
throw new TypeError('Failed to fetch')
|
|
21
|
+
}),
|
|
22
|
+
27412,
|
|
23
|
+
3,
|
|
24
|
+
)
|
|
25
|
+
expect(probe).toEqual({ status: 'unreachable' })
|
|
26
|
+
expect(tried).toEqual([
|
|
27
|
+
'http://127.0.0.1:27412/pascal/health',
|
|
28
|
+
'http://127.0.0.1:27413/pascal/health',
|
|
29
|
+
'http://127.0.0.1:27414/pascal/health',
|
|
30
|
+
])
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('finds the app on a fallback port and tells whether it allows this origin', async () => {
|
|
34
|
+
const health = { app: 'blender', version: '5.2.1', addon: '0.1.0', port: 27413, allowed: false }
|
|
35
|
+
const probe = await probeLocalApp(
|
|
36
|
+
fakeFetch((url) => {
|
|
37
|
+
if (url.startsWith('http://127.0.0.1:27413/')) return json(health)
|
|
38
|
+
throw new TypeError('Failed to fetch')
|
|
39
|
+
}),
|
|
40
|
+
)
|
|
41
|
+
expect(probe).toEqual({ status: 'refused', base: 'http://127.0.0.1:27413', health })
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
describe('sendGlbToLocalApp', () => {
|
|
46
|
+
test('posts the GLB with percent-encoded project headers', async () => {
|
|
47
|
+
let seen: RequestInit | undefined
|
|
48
|
+
const status = await sendGlbToLocalApp(
|
|
49
|
+
'http://127.0.0.1:27412',
|
|
50
|
+
new Blob([new Uint8Array([1, 2, 3])]),
|
|
51
|
+
{ name: 'Maison été', projectId: 'project_1' },
|
|
52
|
+
fakeFetch((_url, init) => {
|
|
53
|
+
seen = init
|
|
54
|
+
return json({ id: 'abc', state: 'queued' }, 202)
|
|
55
|
+
}),
|
|
56
|
+
)
|
|
57
|
+
expect(status).toEqual({ id: 'abc', state: 'queued' })
|
|
58
|
+
expect(seen?.method).toBe('POST')
|
|
59
|
+
expect(seen?.headers).toEqual({
|
|
60
|
+
'Content-Type': 'model/gltf-binary',
|
|
61
|
+
'X-Pascal-Project-Name': 'Maison%20%C3%A9t%C3%A9',
|
|
62
|
+
'X-Pascal-Project-Id': 'project_1',
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('maps a 403 to a refused error', async () => {
|
|
67
|
+
await expect(
|
|
68
|
+
sendGlbToLocalApp(
|
|
69
|
+
'http://127.0.0.1:27412',
|
|
70
|
+
new Blob([]),
|
|
71
|
+
{},
|
|
72
|
+
fakeFetch(() => json({ error: 'origin_not_allowed' }, 403)),
|
|
73
|
+
),
|
|
74
|
+
).rejects.toMatchObject({ name: 'LocalAppError', reason: 'refused' })
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
describe('waitForLocalImport', () => {
|
|
79
|
+
test('polls until the import is done', async () => {
|
|
80
|
+
let calls = 0
|
|
81
|
+
const status = await waitForLocalImport(
|
|
82
|
+
'http://127.0.0.1:27412',
|
|
83
|
+
'abc',
|
|
84
|
+
fakeFetch(() => {
|
|
85
|
+
calls += 1
|
|
86
|
+
return json(
|
|
87
|
+
calls < 3
|
|
88
|
+
? { id: 'abc', state: 'queued' }
|
|
89
|
+
: { id: 'abc', state: 'done', summary: 'Imported' },
|
|
90
|
+
)
|
|
91
|
+
}),
|
|
92
|
+
{ intervalMs: 1 },
|
|
93
|
+
)
|
|
94
|
+
expect(status.state).toBe('done')
|
|
95
|
+
expect(calls).toBe(3)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('surfaces a failed import', async () => {
|
|
99
|
+
const error = await waitForLocalImport(
|
|
100
|
+
'http://127.0.0.1:27412',
|
|
101
|
+
'abc',
|
|
102
|
+
fakeFetch(() => json({ id: 'abc', state: 'failed', error: 'boom' })),
|
|
103
|
+
{ intervalMs: 1 },
|
|
104
|
+
).catch((e: unknown) => e)
|
|
105
|
+
expect(error).toBeInstanceOf(LocalAppError)
|
|
106
|
+
expect((error as LocalAppError).reason).toBe('failed')
|
|
107
|
+
})
|
|
108
|
+
})
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hand a scene to a desktop app listening on loopback (the Pascal Blender add-on
|
|
3
|
+
* today). The app answers `GET /pascal/health`, accepts a GLB on
|
|
4
|
+
* `POST /pascal/import`, and reports the import on `GET /pascal/import/<id>`.
|
|
5
|
+
* Protocol reference: https://github.com/pascalorg/blender-addon#send-to-blender-from-the-editor
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const LOCAL_APP_PORT = 27412
|
|
9
|
+
export const LOCAL_APP_PORT_RANGE = 5
|
|
10
|
+
const PROBE_TIMEOUT_MS = 1000
|
|
11
|
+
|
|
12
|
+
export type LocalAppHealth = {
|
|
13
|
+
app: string
|
|
14
|
+
version: string
|
|
15
|
+
addon: string
|
|
16
|
+
port: number
|
|
17
|
+
allowed: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type LocalAppProbe =
|
|
21
|
+
| { status: 'listening'; base: string; health: LocalAppHealth }
|
|
22
|
+
| { status: 'refused'; base: string; health: LocalAppHealth }
|
|
23
|
+
| { status: 'unreachable' }
|
|
24
|
+
|
|
25
|
+
export type LocalImportStatus = {
|
|
26
|
+
id: string
|
|
27
|
+
state: 'queued' | 'done' | 'failed'
|
|
28
|
+
summary?: string
|
|
29
|
+
error?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class LocalAppError extends Error {
|
|
33
|
+
constructor(
|
|
34
|
+
readonly reason: 'refused' | 'rejected' | 'failed' | 'timeout',
|
|
35
|
+
message: string,
|
|
36
|
+
) {
|
|
37
|
+
super(message)
|
|
38
|
+
this.name = 'LocalAppError'
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type FetchLike = typeof fetch
|
|
43
|
+
|
|
44
|
+
function isHealth(value: unknown): value is LocalAppHealth {
|
|
45
|
+
const health = value as Partial<LocalAppHealth> | null
|
|
46
|
+
return Boolean(health && typeof health.app === 'string' && typeof health.allowed === 'boolean')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Try the app's port and a few after it, one second each. */
|
|
50
|
+
export async function probeLocalApp(
|
|
51
|
+
fetchImpl: FetchLike = fetch,
|
|
52
|
+
port = LOCAL_APP_PORT,
|
|
53
|
+
range = LOCAL_APP_PORT_RANGE,
|
|
54
|
+
): Promise<LocalAppProbe> {
|
|
55
|
+
for (let candidate = port; candidate < port + range; candidate++) {
|
|
56
|
+
const base = `http://127.0.0.1:${candidate}`
|
|
57
|
+
try {
|
|
58
|
+
const response = await fetchImpl(`${base}/pascal/health`, {
|
|
59
|
+
cache: 'no-store',
|
|
60
|
+
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
|
|
61
|
+
})
|
|
62
|
+
if (!response.ok) continue
|
|
63
|
+
const health: unknown = await response.json()
|
|
64
|
+
if (!isHealth(health)) continue
|
|
65
|
+
return { status: health.allowed ? 'listening' : 'refused', base, health }
|
|
66
|
+
} catch {
|
|
67
|
+
// Nothing listening there; try the next port.
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return { status: 'unreachable' }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type SendSceneMeta = {
|
|
74
|
+
name?: string
|
|
75
|
+
projectId?: string
|
|
76
|
+
version?: string
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** POST the GLB; resolves with the queued import id. */
|
|
80
|
+
export async function sendGlbToLocalApp(
|
|
81
|
+
base: string,
|
|
82
|
+
blob: Blob,
|
|
83
|
+
meta: SendSceneMeta = {},
|
|
84
|
+
fetchImpl: FetchLike = fetch,
|
|
85
|
+
): Promise<LocalImportStatus> {
|
|
86
|
+
const headers: Record<string, string> = { 'Content-Type': 'model/gltf-binary' }
|
|
87
|
+
// Header values are Latin-1; the listener percent-decodes these.
|
|
88
|
+
if (meta.name) headers['X-Pascal-Project-Name'] = encodeURIComponent(meta.name)
|
|
89
|
+
if (meta.projectId) headers['X-Pascal-Project-Id'] = meta.projectId
|
|
90
|
+
if (meta.version) headers['X-Pascal-Version'] = meta.version
|
|
91
|
+
const response = await fetchImpl(`${base}/pascal/import`, { method: 'POST', headers, body: blob })
|
|
92
|
+
if (response.status === 403) {
|
|
93
|
+
throw new LocalAppError('refused', 'The app has not allowed this site to send scenes.')
|
|
94
|
+
}
|
|
95
|
+
if (!response.ok) {
|
|
96
|
+
throw new LocalAppError('rejected', `The app rejected the scene (HTTP ${response.status}).`)
|
|
97
|
+
}
|
|
98
|
+
return (await response.json()) as LocalImportStatus
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Poll until the app has imported (or failed to import) the queued scene. */
|
|
102
|
+
export async function waitForLocalImport(
|
|
103
|
+
base: string,
|
|
104
|
+
id: string,
|
|
105
|
+
fetchImpl: FetchLike = fetch,
|
|
106
|
+
{ timeoutMs = 120_000, intervalMs = 500 } = {},
|
|
107
|
+
): Promise<LocalImportStatus> {
|
|
108
|
+
const deadline = Date.now() + timeoutMs
|
|
109
|
+
while (Date.now() < deadline) {
|
|
110
|
+
const response = await fetchImpl(`${base}/pascal/import/${id}`, { cache: 'no-store' })
|
|
111
|
+
if (response.ok) {
|
|
112
|
+
const status = (await response.json()) as LocalImportStatus
|
|
113
|
+
if (status.state === 'done') return status
|
|
114
|
+
if (status.state === 'failed') {
|
|
115
|
+
throw new LocalAppError('failed', status.error ?? 'The app could not import the scene.')
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs))
|
|
119
|
+
}
|
|
120
|
+
throw new LocalAppError('timeout', 'The app did not finish importing in time.')
|
|
121
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { sfxEmitter } from '../sfx-bus'
|
|
2
|
+
|
|
1
3
|
const DEFAULT_FREE_MOVEMENT_SFX_STEP_M = 0.1
|
|
2
4
|
|
|
3
5
|
type MovementSfxStepKeyArgs = {
|
|
@@ -16,3 +18,38 @@ export function movementSfxStepKey({
|
|
|
16
18
|
const step = gridSnapActive && gridStep > 0 ? gridStep : freeStep
|
|
17
19
|
return coords.map((coord) => Math.round(coord / step)).join(',')
|
|
18
20
|
}
|
|
21
|
+
|
|
22
|
+
export function createMovementSfxTick() {
|
|
23
|
+
let previousKey: string | null = null
|
|
24
|
+
let pending: (() => void) | null = null
|
|
25
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
26
|
+
const tick = (args: MovementSfxStepKeyArgs, emitInitialStep = false) => {
|
|
27
|
+
const nextKey = movementSfxStepKey(args)
|
|
28
|
+
if (nextKey !== previousKey && (previousKey !== null || emitInitialStep)) {
|
|
29
|
+
sfxEmitter.emit('sfx:grid-snap')
|
|
30
|
+
}
|
|
31
|
+
previousKey = nextKey
|
|
32
|
+
}
|
|
33
|
+
const cancel = () => {
|
|
34
|
+
clearTimeout(timer)
|
|
35
|
+
timer = undefined
|
|
36
|
+
pending = null
|
|
37
|
+
}
|
|
38
|
+
const flush = () => {
|
|
39
|
+
const apply = pending
|
|
40
|
+
cancel()
|
|
41
|
+
apply?.()
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
tick,
|
|
45
|
+
schedule(args: MovementSfxStepKeyArgs, shouldPlay: () => boolean, emitInitialStep = false) {
|
|
46
|
+
pending = () => {
|
|
47
|
+
if (shouldPlay()) tick(args, emitInitialStep)
|
|
48
|
+
}
|
|
49
|
+
// Wait for R3F host dispatch after the native grid listener, without delaying the move.
|
|
50
|
+
if (timer === undefined) timer = setTimeout(flush, 0)
|
|
51
|
+
},
|
|
52
|
+
flush,
|
|
53
|
+
cancel,
|
|
54
|
+
}
|
|
55
|
+
}
|
package/src/lib/sfx-bus.ts
CHANGED
|
@@ -27,6 +27,7 @@ type SFXEvents = {
|
|
|
27
27
|
'sfx:menu-hover': undefined
|
|
28
28
|
'sfx:menu-click': undefined
|
|
29
29
|
'sfx:paint-apply': undefined
|
|
30
|
+
'sfx:success': undefined
|
|
30
31
|
'sfx:terrain-sculpt-start': TerrainVerb
|
|
31
32
|
'sfx:terrain-sculpt-stop': undefined
|
|
32
33
|
}
|
|
@@ -56,6 +57,7 @@ const handleSnapshotCapture = () => playSFX('snapshotCapture')
|
|
|
56
57
|
const handleMenuHover = () => playSFX('menuHover')
|
|
57
58
|
const handleMenuClick = () => playSFX('menuClick')
|
|
58
59
|
const handlePaintApply = () => playSFX('paintApply')
|
|
60
|
+
const handleSuccess = () => playSFX('success')
|
|
59
61
|
const TERRAIN_LOOP_BY_VERB = {
|
|
60
62
|
raise: 'terrainRaise',
|
|
61
63
|
lower: 'terrainLower',
|
|
@@ -86,6 +88,7 @@ export function initSFXBus() {
|
|
|
86
88
|
sfxEmitter.on('sfx:menu-hover', handleMenuHover)
|
|
87
89
|
sfxEmitter.on('sfx:menu-click', handleMenuClick)
|
|
88
90
|
sfxEmitter.on('sfx:paint-apply', handlePaintApply)
|
|
91
|
+
sfxEmitter.on('sfx:success', handleSuccess)
|
|
89
92
|
sfxEmitter.on('sfx:terrain-sculpt-start', handleTerrainSculptStart)
|
|
90
93
|
sfxEmitter.on('sfx:terrain-sculpt-stop', handleTerrainSculptStop)
|
|
91
94
|
unsubscribeAudio = useAudio.subscribe(updateSFXVolumes)
|
|
@@ -106,6 +109,7 @@ export function disposeSFXBus() {
|
|
|
106
109
|
sfxEmitter.off('sfx:menu-hover', handleMenuHover)
|
|
107
110
|
sfxEmitter.off('sfx:menu-click', handleMenuClick)
|
|
108
111
|
sfxEmitter.off('sfx:paint-apply', handlePaintApply)
|
|
112
|
+
sfxEmitter.off('sfx:success', handleSuccess)
|
|
109
113
|
sfxEmitter.off('sfx:terrain-sculpt-start', handleTerrainSculptStart)
|
|
110
114
|
sfxEmitter.off('sfx:terrain-sculpt-stop', handleTerrainSculptStop)
|
|
111
115
|
unsubscribeAudio?.()
|
package/src/lib/sfx-player.ts
CHANGED
|
@@ -112,6 +112,14 @@ export const SFX: Record<string, SFXConfig> = {
|
|
|
112
112
|
volumeRange: [0.85, 1.0],
|
|
113
113
|
minIntervalMs: 60,
|
|
114
114
|
},
|
|
115
|
+
// A three-second jingle for finishing something, not a click cue. No pitch or
|
|
116
|
+
// volume jitter — a fanfare that lands a semitone off reads as broken rather
|
|
117
|
+
// than as varied — and a gap longer than the sound itself, so two milestones
|
|
118
|
+
// that land together play once instead of phasing over each other.
|
|
119
|
+
success: {
|
|
120
|
+
src: '/audios/sfx/success.mp3',
|
|
121
|
+
minIntervalMs: 3_000,
|
|
122
|
+
},
|
|
115
123
|
} as const
|
|
116
124
|
|
|
117
125
|
export type SFXName = keyof typeof SFX
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'bun:test'
|
|
2
|
-
import { ROTATE_HANDLE_DRAG_LABEL } from './contextual-help'
|
|
2
|
+
import { GROUP_MOVE_DRAG_LABEL, ROTATE_HANDLE_DRAG_LABEL } from './contextual-help'
|
|
3
3
|
import {
|
|
4
4
|
cycleSnappingModeIn,
|
|
5
5
|
DEFAULT_SNAPPING_MODE,
|
|
@@ -49,12 +49,24 @@ describe('resolveSnapFlags', () => {
|
|
|
49
49
|
})
|
|
50
50
|
|
|
51
51
|
describe('per-context snapping', () => {
|
|
52
|
-
it('items default to
|
|
53
|
-
expect(defaultSnappingModeFor('item')).toBe('
|
|
52
|
+
it('items default to magnetic alignment with no angle lock', () => {
|
|
53
|
+
expect(defaultSnappingModeFor('item')).toBe('lines')
|
|
54
54
|
expect(snappingModesFor('item')).toEqual(['lines', 'grid', 'off'])
|
|
55
55
|
expect(snappingModesFor('item')).not.toContain('angles')
|
|
56
56
|
})
|
|
57
57
|
|
|
58
|
+
it('group moves use the magnetic item context by default', () => {
|
|
59
|
+
const context = snapContextOf({
|
|
60
|
+
scope: { kind: 'handle-drag', handle: GROUP_MOVE_DRAG_LABEL },
|
|
61
|
+
mode: 'select',
|
|
62
|
+
tool: null,
|
|
63
|
+
profileOf: () => undefined,
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
expect(context).toBe('item')
|
|
67
|
+
expect(resolveSnapFlags(defaultSnappingModeFor(context!)).magnetic).toBe(true)
|
|
68
|
+
})
|
|
69
|
+
|
|
58
70
|
it('walls default to grid and expose the angle lock; polygons do NOT', () => {
|
|
59
71
|
expect(defaultSnappingModeFor('wall')).toBe('grid')
|
|
60
72
|
expect(defaultSnappingModeFor('polygon')).toBe('grid')
|
|
@@ -83,10 +95,17 @@ describe('snapContextOf (profile-driven, node-declared)', () => {
|
|
|
83
95
|
ceiling: 'structural',
|
|
84
96
|
roof: 'structural',
|
|
85
97
|
zone: 'structural',
|
|
98
|
+
block: 'structural',
|
|
86
99
|
}
|
|
87
100
|
const profileOf = (t: string) => declared[t]
|
|
88
101
|
const profileOfNode = (id: string) =>
|
|
89
|
-
id === 'cabinet-module_1'
|
|
102
|
+
id === 'cabinet-module_1'
|
|
103
|
+
? declared.item
|
|
104
|
+
: id === 'wall_1'
|
|
105
|
+
? declared.wall
|
|
106
|
+
: id === 'block_1'
|
|
107
|
+
? declared['block']
|
|
108
|
+
: undefined
|
|
90
109
|
const ctx = (
|
|
91
110
|
scope: {
|
|
92
111
|
kind: string
|
|
@@ -95,6 +114,7 @@ describe('snapContextOf (profile-driven, node-declared)', () => {
|
|
|
95
114
|
nodeId?: string
|
|
96
115
|
tool?: string
|
|
97
116
|
handle?: string
|
|
117
|
+
operator?: string
|
|
98
118
|
},
|
|
99
119
|
mode = 'select',
|
|
100
120
|
tool: string | null = null,
|
|
@@ -115,6 +135,14 @@ describe('snapContextOf (profile-driven, node-declared)', () => {
|
|
|
115
135
|
).toBeNull()
|
|
116
136
|
})
|
|
117
137
|
|
|
138
|
+
it('gives mesh rotation the angle context and other edit operations polygon snapping', () => {
|
|
139
|
+
expect(ctx({ kind: 'mesh-editing', nodeId: 'block_1' })).toBe('polygon')
|
|
140
|
+
expect(ctx({ kind: 'mesh-editing', nodeId: 'block_1', operator: 'translate' })).toBe('polygon')
|
|
141
|
+
expect(ctx({ kind: 'mesh-editing', nodeId: 'block_1', operator: 'scale' })).toBe('polygon')
|
|
142
|
+
expect(ctx({ kind: 'mesh-editing', nodeId: 'block_1', operator: 'loop-cut' })).toBe('polygon')
|
|
143
|
+
expect(ctx({ kind: 'mesh-editing', nodeId: 'block_1', operator: 'rotate' })).toBe('wall')
|
|
144
|
+
})
|
|
145
|
+
|
|
118
146
|
it('endpoint reshape is angle-bearing (wall); curve + polygon vertex edits are not', () => {
|
|
119
147
|
expect(ctx({ kind: 'reshaping', reshape: 'endpoint' })).toBe('wall')
|
|
120
148
|
expect(ctx({ kind: 'reshaping', reshape: 'curve' })).toBe('polygon')
|
package/src/lib/snapping-mode.ts
CHANGED
|
@@ -4,10 +4,9 @@ import { GROUP_MOVE_DRAG_LABEL, ROTATE_HANDLE_DRAG_LABEL } from './contextual-he
|
|
|
4
4
|
/**
|
|
5
5
|
* Snapping mode is a single global, user-cyclable control that maps onto the
|
|
6
6
|
* two pre-existing snap knobs (`gridSnapStep` grid snap + `magneticSnap`).
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* does any snap math get suppressed.
|
|
7
|
+
* Each context chooses its own default. Item movement defaults to magnetic
|
|
8
|
+
* alignment so a picked-up group catches neighboring geometry; grid and off
|
|
9
|
+
* remain explicit alternatives in the contextual chip.
|
|
11
10
|
*/
|
|
12
11
|
export type SnappingMode = 'grid' | 'lines' | 'angles' | 'off'
|
|
13
12
|
|
|
@@ -82,9 +81,9 @@ type SnapModeSet = { modes: SnappingMode[]; default: SnappingMode }
|
|
|
82
81
|
const SNAP_PROFILES: Record<SnapContext, SnapModeSet> = {
|
|
83
82
|
// Wall / fence drafting + endpoint reshape: direction matters → angle lock.
|
|
84
83
|
wall: { modes: ['grid', 'lines', 'angles', 'off'], default: 'grid' },
|
|
85
|
-
// Item placement / move:
|
|
86
|
-
//
|
|
87
|
-
item: { modes: ['lines', 'grid', 'off'], default: '
|
|
84
|
+
// Item placement / move: magnetic alignment by default; grid is an explicit
|
|
85
|
+
// alternative, and angle lock is meaningless for a footprint.
|
|
86
|
+
item: { modes: ['lines', 'grid', 'off'], default: 'lines' },
|
|
88
87
|
// Structural / surface, no direction to set: slab / ceiling / roof draft+move,
|
|
89
88
|
// whole wall/fence translate, curve reshape, polygon boundary edit. Grid by
|
|
90
89
|
// default, NO angle lock.
|
|
@@ -148,6 +147,7 @@ export function snapContextOf(args: {
|
|
|
148
147
|
nodeId?: string
|
|
149
148
|
tool?: string
|
|
150
149
|
handle?: string
|
|
150
|
+
operator?: string
|
|
151
151
|
}
|
|
152
152
|
mode: string
|
|
153
153
|
tool: string | null
|
|
@@ -166,6 +166,10 @@ export function snapContextOf(args: {
|
|
|
166
166
|
return 'item'
|
|
167
167
|
}
|
|
168
168
|
switch (scope.kind) {
|
|
169
|
+
case 'mesh-editing':
|
|
170
|
+
return scope.nodeId
|
|
171
|
+
? contextForProfile(profileOfNode?.(scope.nodeId), scope.operator === 'rotate')
|
|
172
|
+
: null
|
|
169
173
|
case 'handle-drag':
|
|
170
174
|
if (scope.handle === ROTATE_HANDLE_DRAG_LABEL) return null
|
|
171
175
|
return scope.nodeId ? contextForProfile(profileOfNode?.(scope.nodeId), false) : null
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { type AnyNode, type AnyNodeId, useScene } from '@pascal-app/core'
|
|
2
|
+
import { evaluateRecipe, isProceduralItem } from '@pascal-app/core/procedural-items'
|
|
3
|
+
import { Euler, Quaternion, Vector3 } from 'three'
|
|
4
|
+
|
|
5
|
+
type Pose = { position: [number, number, number]; rotation: [number, number, number] }
|
|
6
|
+
|
|
7
|
+
export function surfaceAttachmentId(node: Pick<AnyNode, 'id' | 'parentId'>): string | null {
|
|
8
|
+
const parent = node.parentId ? useScene.getState().nodes[node.parentId as AnyNodeId] : undefined
|
|
9
|
+
return isProceduralItem(parent) ? (parent.attachments[node.id] ?? null) : null
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function surfaceAttachmentUpdates(
|
|
13
|
+
id: AnyNodeId,
|
|
14
|
+
parentId: string | null | undefined,
|
|
15
|
+
surfaceId: string | null,
|
|
16
|
+
) {
|
|
17
|
+
const nodes = useScene.getState().nodes
|
|
18
|
+
const updates: { id: AnyNodeId; data: Partial<AnyNode> }[] = []
|
|
19
|
+
for (const hostId of new Set([nodes[id]?.parentId, parentId])) {
|
|
20
|
+
const host = hostId ? nodes[hostId as AnyNodeId] : undefined
|
|
21
|
+
if (!isProceduralItem(host)) continue
|
|
22
|
+
const nextId = host.id === parentId ? surfaceId : null
|
|
23
|
+
if ((host.attachments[id] ?? null) === nextId) continue
|
|
24
|
+
const attachments = { ...host.attachments }
|
|
25
|
+
if (nextId === null) delete attachments[id]
|
|
26
|
+
else attachments[id] = nextId
|
|
27
|
+
updates.push({ id: host.id, data: { attachments } })
|
|
28
|
+
}
|
|
29
|
+
return updates
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function updateSurfaceNode(
|
|
33
|
+
id: AnyNodeId,
|
|
34
|
+
data: Partial<AnyNode>,
|
|
35
|
+
surfaceId?: string | null,
|
|
36
|
+
) {
|
|
37
|
+
const node = useScene.getState().nodes[id]
|
|
38
|
+
const parentId = data.parentId === undefined ? node?.parentId : data.parentId
|
|
39
|
+
const selected =
|
|
40
|
+
surfaceId === undefined && node && node.parentId === parentId
|
|
41
|
+
? surfaceAttachmentId(node)
|
|
42
|
+
: (surfaceId ?? null)
|
|
43
|
+
useScene
|
|
44
|
+
.getState()
|
|
45
|
+
.updateNodes([{ id, data }, ...surfaceAttachmentUpdates(id, parentId, selected)])
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function surfaceFramePose(
|
|
49
|
+
parentId: string | null | undefined,
|
|
50
|
+
surfaceId: string | null,
|
|
51
|
+
pose: Pose,
|
|
52
|
+
toStorage: boolean,
|
|
53
|
+
): Pose {
|
|
54
|
+
const host = parentId ? useScene.getState().nodes[parentId as AnyNodeId] : undefined
|
|
55
|
+
if (!isProceduralItem(host) || surfaceId === null)
|
|
56
|
+
return { position: pose.position, rotation: pose.rotation }
|
|
57
|
+
const surface = evaluateRecipe(host.recipe, host.parameters).surfaces.find(
|
|
58
|
+
(s) => s.id === surfaceId,
|
|
59
|
+
)
|
|
60
|
+
if (!surface) throw new Error('Missing attachment surface')
|
|
61
|
+
const q = new Quaternion().setFromEuler(new Euler(...surface.rotation))
|
|
62
|
+
const position = new Vector3(...pose.position)
|
|
63
|
+
if (toStorage) position.sub(new Vector3(...surface.position)).applyQuaternion(q.clone().invert())
|
|
64
|
+
else position.applyQuaternion(q).add(new Vector3(...surface.position))
|
|
65
|
+
if (!surface.rotation[0] && !surface.rotation[2] && !pose.rotation[0] && !pose.rotation[2]) {
|
|
66
|
+
return {
|
|
67
|
+
position: position.toArray(),
|
|
68
|
+
rotation: [0, pose.rotation[1] + (toStorage ? -1 : 1) * surface.rotation[1], 0],
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const rotation = new Euler().setFromQuaternion(
|
|
72
|
+
new Quaternion()
|
|
73
|
+
.setFromEuler(new Euler(...pose.rotation))
|
|
74
|
+
.premultiply(toStorage ? q.invert() : q),
|
|
75
|
+
)
|
|
76
|
+
return { position: position.toArray(), rotation: [rotation.x, rotation.y, rotation.z] }
|
|
77
|
+
}
|