@pascal-app/editor 1.0.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -6
- package/src/components/editor/bake-exporter.tsx +4 -0
- package/src/components/editor/bake-thumbnail.tsx +4 -1
- package/src/components/editor/floating-action-menu.tsx +14 -1
- package/src/components/editor/floorplan-panel.tsx +23 -3
- package/src/components/editor/handles/handle-placement.test.ts +111 -0
- package/src/components/editor/handles/handle-placement.ts +69 -0
- package/src/components/editor/handles/linear-resize-drag.ts +13 -2
- package/src/components/editor/handles/use-handle-drag.ts +5 -2
- package/src/components/editor/index.tsx +12 -4
- package/src/components/editor/node-arrow-handles.tsx +21 -62
- package/src/components/editor/selection-manager.tsx +49 -0
- package/src/components/editor/thumbnail-generator.tsx +11 -2
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +46 -3
- package/src/components/procedural-items/inspector.tsx +223 -0
- package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
- package/src/components/tools/item/item-surface-placement.test.ts +175 -0
- package/src/components/tools/item/item-surface-strategy-resolver.test.ts +210 -0
- package/src/components/tools/item/placement-strategies.ts +169 -169
- package/src/components/tools/item/placement-types.ts +4 -0
- package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
- package/src/components/tools/item/use-draft-node.ts +95 -45
- package/src/components/tools/item/use-placement-coordinator.tsx +247 -106
- package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
- package/src/components/tools/registry/item-surface-move.test.ts +501 -0
- package/src/components/tools/registry/item-surface-move.ts +441 -0
- package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
- package/src/components/tools/registry/move-registry-node-tool.tsx +215 -70
- package/src/components/tools/shared/pointer-support-cap.ts +11 -9
- package/src/components/tools/shared/shelf-stickiness.ts +54 -0
- package/src/components/tools/shared/surface-hit.test.ts +56 -0
- package/src/components/tools/shared/surface-hit.ts +23 -0
- package/src/components/tools/shared/surface-rejection.test.tsx +106 -0
- package/src/components/tools/shared/surface-rejection.tsx +29 -0
- package/src/components/tools/zone/zone-tool.tsx +13 -4
- package/src/components/ui/command-palette/editor-commands.tsx +54 -1
- package/src/components/ui/controls/metric-control.tsx +29 -13
- package/src/components/ui/controls/slider-control.tsx +25 -18
- package/src/components/ui/floating-level-selector.tsx +81 -5
- package/src/components/ui/panels/multi-parametric-inspector.tsx +6 -3
- package/src/components/ui/panels/parametric-field-control.tsx +1 -1
- package/src/components/ui/panels/parametric-inspector.tsx +6 -2
- package/src/components/ui/panels/reference-panel.tsx +3 -3
- package/src/components/ui/primitives/checkbox.tsx +34 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +120 -3
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +31 -4
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +29 -10
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +106 -4
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +6 -1
- package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
- package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
- package/src/components/ui/snap-target-badge.tsx +2 -0
- package/src/components/viewer/viewer-scene-header.tsx +3 -0
- package/src/components/viewer/viewer-units-panel.tsx +116 -0
- package/src/hooks/use-ceiling-events.test.ts +24 -2
- package/src/hooks/use-ceiling-events.ts +5 -1
- package/src/hooks/use-keyboard.ts +3 -1
- package/src/index.tsx +5 -0
- package/src/lib/export-texture-utils.test.ts +167 -0
- package/src/lib/export-texture-utils.ts +121 -0
- package/src/lib/export-user-data.test.ts +48 -0
- package/src/lib/export-user-data.ts +69 -0
- package/src/lib/floorplan/floorplan-export.test.ts +139 -1
- package/src/lib/floorplan/floorplan-export.tsx +9 -3
- package/src/lib/floorplan/floorplan-readonly.ts +4 -0
- package/src/lib/fresh-planar-placement.ts +13 -5
- package/src/lib/glb-export-serialize.test.ts +280 -0
- package/src/lib/glb-export.test.ts +206 -0
- package/src/lib/glb-export.ts +171 -24
- package/src/lib/level-duplication.test.ts +73 -0
- package/src/lib/level-duplication.ts +32 -2
- package/src/lib/linear-display.test.ts +59 -0
- package/src/lib/linear-display.ts +39 -0
- package/src/lib/local-guide-image.test.ts +24 -0
- package/src/lib/local-guide-image.ts +34 -1
- package/src/lib/node-action-movement.ts +21 -0
- package/src/lib/portable-export.test.ts +89 -0
- package/src/lib/portable-export.ts +84 -11
- package/src/lib/print-shell-compiler-manifold-core.ts +59 -10
- package/src/lib/print-shell-compiler-manifold-worker.ts +14 -1
- package/src/lib/print-shell-compiler-manifold.worker.ts +1 -1
- package/src/lib/print-shell-compiler-protocol.ts +8 -0
- package/src/lib/scene-clipboard.ts +1 -1
- package/src/lib/send-to-app.test.ts +108 -0
- package/src/lib/send-to-app.ts +121 -0
- package/src/lib/sfx/movement-tick.ts +37 -0
- package/src/lib/surface-attachment.ts +77 -0
- package/src/lib/units.ts +254 -0
- package/src/lib/usdz-export.ts +43 -16
- package/src/lib/use-linear-display.ts +8 -32
- package/src/store/use-handle-group.ts +4 -0
- package/src/store/use-send-to-app.ts +21 -0
|
@@ -0,0 +1,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
|
+
}
|
|
@@ -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
|
+
}
|
package/src/lib/units.ts
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AnyNode,
|
|
3
|
+
type AnyNodeId,
|
|
4
|
+
type BuildingNode,
|
|
5
|
+
type LevelNode,
|
|
6
|
+
pointInPolygon2D,
|
|
7
|
+
resolveAutoZonePolygon,
|
|
8
|
+
snapWorldXZToBuildingLocal,
|
|
9
|
+
UnitNode,
|
|
10
|
+
useScene,
|
|
11
|
+
type ZoneNode,
|
|
12
|
+
} from '@pascal-app/core'
|
|
13
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
14
|
+
import { useEffect } from 'react'
|
|
15
|
+
import useEditor, { type StructureLayer } from '../store/use-editor'
|
|
16
|
+
import { getActiveBuildingPose } from './world-grid-snap'
|
|
17
|
+
|
|
18
|
+
type Nodes = Readonly<Record<AnyNodeId, AnyNode>>
|
|
19
|
+
|
|
20
|
+
// Unit focus is a session gesture: the layer the user was on when focus began
|
|
21
|
+
// comes back when focus ends, unless they switched layers by hand meanwhile.
|
|
22
|
+
let restoreLayer: StructureLayer | null = null
|
|
23
|
+
let entering = false
|
|
24
|
+
let leaving = false
|
|
25
|
+
|
|
26
|
+
export function focusedUnitNode(): UnitNode | null {
|
|
27
|
+
const id = useViewer.getState().focusedUnitId
|
|
28
|
+
const node = id ? useScene.getState().nodes[id] : undefined
|
|
29
|
+
return node?.type === 'unit' ? node : null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Levels carrying member zones, lowest ordinal first. */
|
|
33
|
+
export function unitMemberLevels(unit: UnitNode, nodes: Nodes): LevelNode[] {
|
|
34
|
+
const levels = new Map<LevelNode['id'], LevelNode>()
|
|
35
|
+
for (const zoneId of unit.members) {
|
|
36
|
+
const zone = nodes[zoneId]
|
|
37
|
+
const level =
|
|
38
|
+
zone?.type === 'zone' && zone.parentId ? nodes[zone.parentId as AnyNodeId] : undefined
|
|
39
|
+
if (level?.type === 'level') levels.set(level.id, level)
|
|
40
|
+
}
|
|
41
|
+
return [...levels.values()].sort((a, b) => a.level - b.level)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function enterUnitFocus(unitId: UnitNode['id']): void {
|
|
45
|
+
if (entering) return
|
|
46
|
+
entering = true
|
|
47
|
+
try {
|
|
48
|
+
const nodes = useScene.getState().nodes
|
|
49
|
+
const unit = nodes[unitId]
|
|
50
|
+
if (unit?.type !== 'unit') return
|
|
51
|
+
const editor = useEditor.getState()
|
|
52
|
+
if (restoreLayer === null) restoreLayer = editor.structureLayer
|
|
53
|
+
if (editor.phase !== 'structure') editor.setPhase('structure')
|
|
54
|
+
if (useEditor.getState().structureLayer !== 'zones') {
|
|
55
|
+
useEditor.getState().setStructureLayer('zones')
|
|
56
|
+
}
|
|
57
|
+
const viewer = useViewer.getState()
|
|
58
|
+
if (viewer.focusedUnitId !== unitId) viewer.setFocusedUnit(unitId)
|
|
59
|
+
|
|
60
|
+
const { selection, setSelection } = useViewer.getState()
|
|
61
|
+
const levels = unitMemberLevels(unit, nodes)
|
|
62
|
+
const levelId = levels.some((level) => level.id === selection.levelId)
|
|
63
|
+
? selection.levelId
|
|
64
|
+
: (levels[0]?.id ?? selection.levelId)
|
|
65
|
+
const buildingId = (unit.parentId as BuildingNode['id'] | null) ?? selection.buildingId
|
|
66
|
+
const unitSelected = selection.selectedIds.length === 1 && selection.selectedIds[0] === unitId
|
|
67
|
+
if (selection.buildingId !== buildingId || selection.levelId !== levelId || !unitSelected) {
|
|
68
|
+
setSelection({ buildingId, levelId, selectedIds: [unitId] })
|
|
69
|
+
}
|
|
70
|
+
} finally {
|
|
71
|
+
entering = false
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function finishLeave(unitId: UnitNode['id'], keepLayer: boolean) {
|
|
76
|
+
const layer = restoreLayer
|
|
77
|
+
restoreLayer = null
|
|
78
|
+
const editor = useEditor.getState()
|
|
79
|
+
if (
|
|
80
|
+
!keepLayer &&
|
|
81
|
+
layer &&
|
|
82
|
+
layer !== 'zones' &&
|
|
83
|
+
editor.phase === 'structure' &&
|
|
84
|
+
editor.structureLayer === 'zones'
|
|
85
|
+
) {
|
|
86
|
+
editor.setStructureLayer(layer)
|
|
87
|
+
}
|
|
88
|
+
const { selection, setSelection } = useViewer.getState()
|
|
89
|
+
if (selection.selectedIds.length === 1 && selection.selectedIds[0] === unitId) {
|
|
90
|
+
setSelection({ selectedIds: [] })
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Ends unit focus. Returns false when no unit was focused. */
|
|
95
|
+
export function leaveUnitFocus(options?: { keepLayer?: boolean }): boolean {
|
|
96
|
+
const viewer = useViewer.getState()
|
|
97
|
+
const unitId = viewer.focusedUnitId
|
|
98
|
+
if (!unitId) return false
|
|
99
|
+
leaving = true
|
|
100
|
+
try {
|
|
101
|
+
viewer.setFocusedUnit(null)
|
|
102
|
+
} finally {
|
|
103
|
+
leaving = false
|
|
104
|
+
}
|
|
105
|
+
finishLeave(unitId, options?.keepLayer ?? false)
|
|
106
|
+
return true
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Puts a zone in exactly one unit (or none): it leaves every other unit in the
|
|
111
|
+
* same store update, so the move is one undo step.
|
|
112
|
+
*/
|
|
113
|
+
export function assignZoneToUnit(zoneId: ZoneNode['id'], unitId: UnitNode['id'] | null): void {
|
|
114
|
+
const { nodes, updateNodes } = useScene.getState()
|
|
115
|
+
const updates: Array<{ id: AnyNodeId; data: Partial<AnyNode> }> = []
|
|
116
|
+
for (const node of Object.values(nodes)) {
|
|
117
|
+
if (node.type !== 'unit' || node.id === unitId || !node.members.includes(zoneId)) continue
|
|
118
|
+
updates.push({ id: node.id, data: { members: node.members.filter((id) => id !== zoneId) } })
|
|
119
|
+
}
|
|
120
|
+
const target = unitId ? nodes[unitId] : undefined
|
|
121
|
+
if (target?.type === 'unit' && !target.members.includes(zoneId)) {
|
|
122
|
+
updates.push({ id: target.id, data: { members: [...target.members, zoneId] } })
|
|
123
|
+
}
|
|
124
|
+
if (updates.length > 0) updateNodes(updates)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function toggleZoneMembership(unitId: UnitNode['id'], zoneId: ZoneNode['id']): void {
|
|
128
|
+
const unit = useScene.getState().nodes[unitId]
|
|
129
|
+
if (unit?.type !== 'unit') return
|
|
130
|
+
assignZoneToUnit(zoneId, unit.members.includes(zoneId) ? null : unitId)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The zone under a point on the current level, in building-local XZ. A 3D
|
|
135
|
+
* click lands on whatever surface is closest (a floor slab sits above the
|
|
136
|
+
* zone fill), so painting resolves the zone from the hit point instead of the
|
|
137
|
+
* hit mesh. Nested zones resolve to the smallest one.
|
|
138
|
+
*/
|
|
139
|
+
export function zoneAtLevelPoint(x: number, z: number): ZoneNode | null {
|
|
140
|
+
const levelId = useViewer.getState().selection.levelId
|
|
141
|
+
if (!levelId) return null
|
|
142
|
+
const nodes = useScene.getState().nodes
|
|
143
|
+
const resolve = (id: AnyNodeId) => nodes[id]
|
|
144
|
+
let best: ZoneNode | null = null
|
|
145
|
+
let bestArea = Number.POSITIVE_INFINITY
|
|
146
|
+
for (const node of Object.values(nodes)) {
|
|
147
|
+
if (node.type !== 'zone' || node.parentId !== levelId) continue
|
|
148
|
+
const polygon = resolveAutoZonePolygon(node, resolve)
|
|
149
|
+
if (!pointInPolygon2D([x, z], polygon)) continue
|
|
150
|
+
let area = 0
|
|
151
|
+
for (let i = 0; i < polygon.length; i++) {
|
|
152
|
+
const a = polygon[i]!
|
|
153
|
+
const b = polygon[(i + 1) % polygon.length]!
|
|
154
|
+
area += a[0] * b[1] - b[0] * a[1]
|
|
155
|
+
}
|
|
156
|
+
area = Math.abs(area) / 2
|
|
157
|
+
if (area < bestArea) {
|
|
158
|
+
best = node
|
|
159
|
+
bestArea = area
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return best
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function zoneAtWorldPoint(worldX: number, worldZ: number): ZoneNode | null {
|
|
166
|
+
const pose = getActiveBuildingPose()
|
|
167
|
+
const [x, z] = pose
|
|
168
|
+
? snapWorldXZToBuildingLocal(worldX, worldZ, pose.position, pose.rotationY, 0).local
|
|
169
|
+
: [worldX, worldZ]
|
|
170
|
+
return zoneAtLevelPoint(x, z)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export const ZONE_PAINT_DELAY_MS = 250
|
|
174
|
+
const pendingPaints = new Map<ZoneNode['id'], ReturnType<typeof setTimeout>>()
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The canvas paint gesture. The toggle waits out the double-click window so
|
|
178
|
+
* the second click of a double-click cancels it instead of toggling twice;
|
|
179
|
+
* the double-click itself then selects the zone.
|
|
180
|
+
*/
|
|
181
|
+
export function paintZoneMembership(unitId: UnitNode['id'], zoneId: ZoneNode['id']): void {
|
|
182
|
+
if (cancelPendingZonePaint(zoneId)) return
|
|
183
|
+
pendingPaints.set(
|
|
184
|
+
zoneId,
|
|
185
|
+
setTimeout(() => {
|
|
186
|
+
pendingPaints.delete(zoneId)
|
|
187
|
+
toggleZoneMembership(unitId, zoneId)
|
|
188
|
+
}, ZONE_PAINT_DELAY_MS),
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function cancelPendingZonePaint(zoneId: ZoneNode['id']): boolean {
|
|
193
|
+
const pending = pendingPaints.get(zoneId)
|
|
194
|
+
if (!pending) return false
|
|
195
|
+
clearTimeout(pending)
|
|
196
|
+
pendingPaints.delete(zoneId)
|
|
197
|
+
return true
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function createUnitInBuilding(buildingId: BuildingNode['id']): UnitNode['id'] | null {
|
|
201
|
+
const scene = useScene.getState()
|
|
202
|
+
const building = scene.nodes[buildingId]
|
|
203
|
+
if (building?.type !== 'building') return null
|
|
204
|
+
const unitCount = building.children.filter((id) => scene.nodes[id]?.type === 'unit').length
|
|
205
|
+
const unit = UnitNode.parse({ name: `Unit ${unitCount + 1}` })
|
|
206
|
+
scene.createNode(unit, buildingId)
|
|
207
|
+
enterUnitFocus(unit.id)
|
|
208
|
+
return unit.id
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Keeps unit focus coherent with the rest of the editor: a focus set from
|
|
213
|
+
* anywhere (the unit inspector calls the viewer store directly) gets the full
|
|
214
|
+
* enter treatment, and focus ends when the user switches layer or phase by
|
|
215
|
+
* hand, selects anything other than the focused unit, or the unit is deleted.
|
|
216
|
+
*/
|
|
217
|
+
export function useUnitFocusRules(): void {
|
|
218
|
+
useEffect(() => {
|
|
219
|
+
const unsubscribeViewer = useViewer.subscribe((state, prev) => {
|
|
220
|
+
if (state.focusedUnitId !== prev.focusedUnitId) {
|
|
221
|
+
if (state.focusedUnitId) enterUnitFocus(state.focusedUnitId)
|
|
222
|
+
else if (prev.focusedUnitId && !leaving) finishLeave(prev.focusedUnitId, false)
|
|
223
|
+
return
|
|
224
|
+
}
|
|
225
|
+
const unitId = state.focusedUnitId
|
|
226
|
+
if (!unitId || state.selection === prev.selection) return
|
|
227
|
+
// Selecting a zone keeps focus: renaming or recolouring a zone row is
|
|
228
|
+
// part of arranging the unit, and zones live on the layer focus uses.
|
|
229
|
+
const { selectedIds } = state.selection
|
|
230
|
+
const selectsOther =
|
|
231
|
+
selectedIds.length > 0 && !(selectedIds.length === 1 && selectedIds[0] === unitId)
|
|
232
|
+
if (selectsOther) leaveUnitFocus()
|
|
233
|
+
})
|
|
234
|
+
const unsubscribeEditor = useEditor.subscribe((state, prev) => {
|
|
235
|
+
if (state.structureLayer === prev.structureLayer && state.phase === prev.phase) return
|
|
236
|
+
if (!useViewer.getState().focusedUnitId) return
|
|
237
|
+
if (state.phase !== 'structure' || state.structureLayer !== 'zones') {
|
|
238
|
+
leaveUnitFocus({ keepLayer: true })
|
|
239
|
+
}
|
|
240
|
+
})
|
|
241
|
+
const unsubscribeScene = useScene.subscribe((state, prev) => {
|
|
242
|
+
if (state.nodes === prev.nodes) return
|
|
243
|
+
const unitId = useViewer.getState().focusedUnitId
|
|
244
|
+
if (unitId && state.nodes[unitId]?.type !== 'unit') leaveUnitFocus()
|
|
245
|
+
})
|
|
246
|
+
return () => {
|
|
247
|
+
unsubscribeViewer()
|
|
248
|
+
unsubscribeEditor()
|
|
249
|
+
unsubscribeScene()
|
|
250
|
+
for (const pending of pendingPaints.values()) clearTimeout(pending)
|
|
251
|
+
pendingPaints.clear()
|
|
252
|
+
}
|
|
253
|
+
}, [])
|
|
254
|
+
}
|
package/src/lib/usdz-export.ts
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
import type { AnyNode } from '@pascal-app/core'
|
|
2
2
|
import type { Object3D } from 'three'
|
|
3
3
|
import { USDZExporter } from 'three/examples/jsm/exporters/USDZExporter.js'
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
4
|
+
import { createExportTextureUtils } from './export-texture-utils'
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_MODEL_EXPORT_TIMEOUT_MS,
|
|
7
|
+
type GlbExport,
|
|
8
|
+
type GlbExportOptions,
|
|
9
|
+
preparePortableSceneFromViewer,
|
|
10
|
+
withExportDeadline,
|
|
11
|
+
} from './glb-export'
|
|
6
12
|
import { createUsdzScene, disposeExportResources } from './portable-export'
|
|
7
13
|
|
|
8
14
|
export type UsdzExportOptions = Pick<
|
|
9
15
|
GlbExportOptions,
|
|
10
|
-
'excludedNodeTypes' | 'includedPresentationIds' | 'onlyVisible' | 'onWarning'
|
|
16
|
+
'excludedNodeTypes' | 'includedPresentationIds' | 'onlyVisible' | 'onWarning' | 'timeoutMs'
|
|
11
17
|
>
|
|
12
18
|
|
|
13
19
|
/** Export a native, self-contained USDZ with no glTF conversion fallback. */
|
|
@@ -16,23 +22,44 @@ export async function exportSceneToUsdz(
|
|
|
16
22
|
nodes: Record<string, AnyNode>,
|
|
17
23
|
options: UsdzExportOptions = {},
|
|
18
24
|
): Promise<Uint8Array<ArrayBuffer>> {
|
|
19
|
-
const
|
|
25
|
+
const textureUtils = createExportTextureUtils()
|
|
26
|
+
const preparation = preparePortableSceneFromViewer(sceneGroup, nodes, {
|
|
20
27
|
...options,
|
|
21
28
|
textures: 'embed',
|
|
29
|
+
animations: 'none',
|
|
30
|
+
textureUtils,
|
|
22
31
|
})
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
32
|
+
// Assigned inside the raced closure, so plain `let`s narrow to `never` in
|
|
33
|
+
// the `finally`.
|
|
34
|
+
const run: { prepared: GlbExport | null; scene: Object3D | null; abandoned: boolean } = {
|
|
35
|
+
prepared: null,
|
|
36
|
+
scene: null,
|
|
37
|
+
abandoned: false,
|
|
38
|
+
}
|
|
26
39
|
try {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
40
|
+
return await withExportDeadline(
|
|
41
|
+
(async () => {
|
|
42
|
+
run.prepared = await preparation
|
|
43
|
+
if (run.abandoned) throw new Error('USDZ export abandoned')
|
|
44
|
+
for (const warning of run.prepared.warnings) options.onWarning?.(warning)
|
|
45
|
+
run.scene = createUsdzScene(run.prepared.scene)
|
|
46
|
+
const exporter = new USDZExporter()
|
|
47
|
+
exporter.textureUtils = textureUtils as unknown as USDZExporter['textureUtils']
|
|
48
|
+
return exporter.parseAsync(run.scene, {
|
|
49
|
+
onlyVisible: options.onlyVisible ?? true,
|
|
50
|
+
quickLookCompatible: true,
|
|
51
|
+
})
|
|
52
|
+
})(),
|
|
53
|
+
options.timeoutMs ?? DEFAULT_MODEL_EXPORT_TIMEOUT_MS,
|
|
54
|
+
'USDZ',
|
|
55
|
+
() => {
|
|
56
|
+
run.abandoned = true
|
|
57
|
+
},
|
|
58
|
+
)
|
|
34
59
|
} finally {
|
|
35
|
-
if (scene) disposeExportResources(scene, { textures: false })
|
|
36
|
-
prepared.dispose()
|
|
60
|
+
if (run.scene) disposeExportResources(run.scene, { textures: false })
|
|
61
|
+
if (run.prepared) run.prepared.dispose()
|
|
62
|
+
else preparation.then((late) => late.dispose()).catch(() => {})
|
|
63
|
+
await textureUtils.dispose()
|
|
37
64
|
}
|
|
38
65
|
}
|