@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.
Files changed (97) hide show
  1. package/package.json +7 -6
  2. package/src/components/editor/bake-exporter.tsx +4 -0
  3. package/src/components/editor/bake-thumbnail.tsx +4 -1
  4. package/src/components/editor/floating-action-menu.tsx +14 -1
  5. package/src/components/editor/floorplan-panel.tsx +23 -3
  6. package/src/components/editor/handles/handle-placement.test.ts +111 -0
  7. package/src/components/editor/handles/handle-placement.ts +69 -0
  8. package/src/components/editor/handles/linear-resize-drag.ts +13 -2
  9. package/src/components/editor/handles/use-handle-drag.ts +5 -2
  10. package/src/components/editor/index.tsx +12 -4
  11. package/src/components/editor/node-arrow-handles.tsx +21 -62
  12. package/src/components/editor/selection-manager.tsx +49 -0
  13. package/src/components/editor/thumbnail-generator.tsx +11 -2
  14. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
  15. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +46 -3
  16. package/src/components/procedural-items/inspector.tsx +223 -0
  17. package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
  18. package/src/components/tools/item/item-surface-placement.test.ts +175 -0
  19. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +210 -0
  20. package/src/components/tools/item/placement-strategies.ts +169 -169
  21. package/src/components/tools/item/placement-types.ts +4 -0
  22. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
  23. package/src/components/tools/item/use-draft-node.ts +95 -45
  24. package/src/components/tools/item/use-placement-coordinator.tsx +247 -106
  25. package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
  26. package/src/components/tools/registry/item-surface-move.test.ts +501 -0
  27. package/src/components/tools/registry/item-surface-move.ts +441 -0
  28. package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
  29. package/src/components/tools/registry/move-registry-node-tool.tsx +215 -70
  30. package/src/components/tools/shared/pointer-support-cap.ts +11 -9
  31. package/src/components/tools/shared/shelf-stickiness.ts +54 -0
  32. package/src/components/tools/shared/surface-hit.test.ts +56 -0
  33. package/src/components/tools/shared/surface-hit.ts +23 -0
  34. package/src/components/tools/shared/surface-rejection.test.tsx +106 -0
  35. package/src/components/tools/shared/surface-rejection.tsx +29 -0
  36. package/src/components/tools/zone/zone-tool.tsx +13 -4
  37. package/src/components/ui/command-palette/editor-commands.tsx +54 -1
  38. package/src/components/ui/controls/metric-control.tsx +29 -13
  39. package/src/components/ui/controls/slider-control.tsx +25 -18
  40. package/src/components/ui/floating-level-selector.tsx +81 -5
  41. package/src/components/ui/panels/multi-parametric-inspector.tsx +6 -3
  42. package/src/components/ui/panels/parametric-field-control.tsx +1 -1
  43. package/src/components/ui/panels/parametric-inspector.tsx +6 -2
  44. package/src/components/ui/panels/reference-panel.tsx +3 -3
  45. package/src/components/ui/primitives/checkbox.tsx +34 -0
  46. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +120 -3
  47. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +31 -4
  48. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +29 -10
  49. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
  50. package/src/components/ui/sidebar/panels/site-panel/index.tsx +106 -4
  51. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
  52. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
  53. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +6 -1
  54. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
  55. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
  56. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
  57. package/src/components/ui/snap-target-badge.tsx +2 -0
  58. package/src/components/viewer/viewer-scene-header.tsx +3 -0
  59. package/src/components/viewer/viewer-units-panel.tsx +116 -0
  60. package/src/hooks/use-ceiling-events.test.ts +24 -2
  61. package/src/hooks/use-ceiling-events.ts +5 -1
  62. package/src/hooks/use-keyboard.ts +3 -1
  63. package/src/index.tsx +5 -0
  64. package/src/lib/export-texture-utils.test.ts +167 -0
  65. package/src/lib/export-texture-utils.ts +121 -0
  66. package/src/lib/export-user-data.test.ts +48 -0
  67. package/src/lib/export-user-data.ts +69 -0
  68. package/src/lib/floorplan/floorplan-export.test.ts +139 -1
  69. package/src/lib/floorplan/floorplan-export.tsx +9 -3
  70. package/src/lib/floorplan/floorplan-readonly.ts +4 -0
  71. package/src/lib/fresh-planar-placement.ts +13 -5
  72. package/src/lib/glb-export-serialize.test.ts +280 -0
  73. package/src/lib/glb-export.test.ts +206 -0
  74. package/src/lib/glb-export.ts +171 -24
  75. package/src/lib/level-duplication.test.ts +73 -0
  76. package/src/lib/level-duplication.ts +32 -2
  77. package/src/lib/linear-display.test.ts +59 -0
  78. package/src/lib/linear-display.ts +39 -0
  79. package/src/lib/local-guide-image.test.ts +24 -0
  80. package/src/lib/local-guide-image.ts +34 -1
  81. package/src/lib/node-action-movement.ts +21 -0
  82. package/src/lib/portable-export.test.ts +89 -0
  83. package/src/lib/portable-export.ts +84 -11
  84. package/src/lib/print-shell-compiler-manifold-core.ts +59 -10
  85. package/src/lib/print-shell-compiler-manifold-worker.ts +14 -1
  86. package/src/lib/print-shell-compiler-manifold.worker.ts +1 -1
  87. package/src/lib/print-shell-compiler-protocol.ts +8 -0
  88. package/src/lib/scene-clipboard.ts +1 -1
  89. package/src/lib/send-to-app.test.ts +108 -0
  90. package/src/lib/send-to-app.ts +121 -0
  91. package/src/lib/sfx/movement-tick.ts +37 -0
  92. package/src/lib/surface-attachment.ts +77 -0
  93. package/src/lib/units.ts +254 -0
  94. package/src/lib/usdz-export.ts +43 -16
  95. package/src/lib/use-linear-display.ts +8 -32
  96. package/src/store/use-handle-group.ts +4 -0
  97. 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
+ }
@@ -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
+ }
@@ -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 * as WebGPUTextureUtils from 'three/examples/jsm/utils/WebGPUTextureUtils.js'
5
- import { type GlbExportOptions, preparePortableSceneFromViewer } from './glb-export'
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 prepared = await preparePortableSceneFromViewer(sceneGroup, nodes, {
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
- for (const warning of prepared.warnings) options.onWarning?.(warning)
24
-
25
- let scene: Object3D | null = null
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
- scene = createUsdzScene(prepared.scene)
28
- const exporter = new USDZExporter()
29
- exporter.textureUtils = WebGPUTextureUtils
30
- return await exporter.parseAsync(scene, {
31
- onlyVisible: options.onlyVisible ?? true,
32
- quickLookCompatible: true,
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
  }