@pascal-app/editor 1.0.0 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/package.json +12 -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/custom-camera-controls.tsx +22 -74
  5. package/src/components/editor/editor-layout-mobile.tsx +1 -0
  6. package/src/components/editor/floating-action-menu.tsx +14 -1
  7. package/src/components/editor/floorplan-panel.tsx +233 -166
  8. package/src/components/editor/grid.tsx +31 -7
  9. package/src/components/editor/group-actions.ts +5 -9
  10. package/src/components/editor/group-floating-action-menu.tsx +19 -9
  11. package/src/components/editor/group-move-3d.ts +4 -6
  12. package/src/components/editor/group-rotate-handle.tsx +81 -35
  13. package/src/components/editor/group-selection-box-3d.tsx +25 -17
  14. package/src/components/editor/group-transform-shared.test.ts +126 -1
  15. package/src/components/editor/group-transform-shared.ts +79 -73
  16. package/src/components/editor/handles/handle-arrow.tsx +72 -2
  17. package/src/components/editor/handles/handle-placement.test.ts +111 -0
  18. package/src/components/editor/handles/handle-placement.ts +69 -0
  19. package/src/components/editor/handles/linear-resize-drag.ts +13 -2
  20. package/src/components/editor/handles/use-handle-drag.ts +80 -8
  21. package/src/components/editor/index.tsx +36 -13
  22. package/src/components/editor/node-action-menu.tsx +2 -0
  23. package/src/components/editor/node-arrow-handles.tsx +162 -69
  24. package/src/components/editor/registry-action-contributions.tsx +53 -0
  25. package/src/components/editor/selection-manager.tsx +49 -0
  26. package/src/components/editor/thumbnail-generator.tsx +11 -2
  27. package/src/components/editor/wall-move-side-handles.tsx +71 -21
  28. package/src/components/editor/wall-opening-highlights.tsx +2 -3
  29. package/src/components/editor-2d/floorplan-group-move.tsx +17 -24
  30. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +33 -12
  31. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
  32. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +154 -49
  33. package/src/components/editor-2d/renderers/floorplan-draft-wall-measurement.tsx +166 -0
  34. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +49 -0
  35. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +113 -24
  36. package/src/components/procedural-items/inspector.tsx +223 -0
  37. package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
  38. package/src/components/systems/zone/zone-system.tsx +8 -4
  39. package/src/components/tools/__fixtures__/hosting.ts +10 -0
  40. package/src/components/tools/item/item-surface-placement.test.ts +169 -0
  41. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +204 -0
  42. package/src/components/tools/item/placement-strategies.ts +212 -200
  43. package/src/components/tools/item/placement-types.ts +4 -0
  44. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
  45. package/src/components/tools/item/use-draft-node.ts +136 -47
  46. package/src/components/tools/item/use-placement-coordinator.tsx +391 -157
  47. package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
  48. package/src/components/tools/registry/item-surface-move.test.ts +509 -0
  49. package/src/components/tools/registry/item-surface-move.ts +463 -0
  50. package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
  51. package/src/components/tools/registry/move-registry-node-tool.tsx +237 -78
  52. package/src/components/tools/shared/affordance-dispatch.test.ts +24 -0
  53. package/src/components/tools/shared/affordance-dispatch.ts +13 -0
  54. package/src/components/tools/shared/draft-measurement-label.tsx +38 -0
  55. package/src/components/tools/shared/pointer-support-cap.test.ts +27 -0
  56. package/src/components/tools/shared/pointer-support-cap.ts +27 -13
  57. package/src/components/tools/shared/shelf-stickiness.ts +54 -0
  58. package/src/components/tools/shared/surface-hit.test.ts +56 -0
  59. package/src/components/tools/shared/surface-hit.ts +23 -0
  60. package/src/components/tools/shared/surface-rejection.test.tsx +107 -0
  61. package/src/components/tools/shared/surface-rejection.tsx +43 -0
  62. package/src/components/tools/tool-manager.tsx +36 -3
  63. package/src/components/tools/zone/zone-tool.tsx +13 -4
  64. package/src/components/ui/command-palette/editor-commands.tsx +54 -1
  65. package/src/components/ui/controls/material-paint-panel.tsx +6 -67
  66. package/src/components/ui/controls/material-picker.tsx +5 -67
  67. package/src/components/ui/controls/metric-control.tsx +29 -13
  68. package/src/components/ui/controls/slider-control.tsx +87 -35
  69. package/src/components/ui/floating-level-selector.tsx +81 -5
  70. package/src/components/ui/helpers/helper-manager.tsx +24 -6
  71. package/src/components/ui/helpers/registered-tool-helper.tsx +2 -32
  72. package/src/components/ui/item-catalog/item-catalog.tsx +5 -30
  73. package/src/components/ui/panels/multi-height-mode.tsx +27 -21
  74. package/src/components/ui/panels/multi-parametric-inspector.tsx +6 -3
  75. package/src/components/ui/panels/parametric-field-control.tsx +1 -1
  76. package/src/components/ui/panels/parametric-inspector.tsx +9 -17
  77. package/src/components/ui/panels/reference-panel.tsx +3 -3
  78. package/src/components/ui/primitives/checkbox.tsx +34 -0
  79. package/src/components/ui/primitives/shortcut-token.tsx +4 -0
  80. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
  81. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +669 -336
  82. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  83. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +31 -4
  84. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +30 -11
  85. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
  86. package/src/components/ui/sidebar/panels/site-panel/index.tsx +106 -4
  87. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
  88. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
  89. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +6 -1
  90. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
  91. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
  92. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
  93. package/src/components/ui/snap-target-badge.tsx +2 -0
  94. package/src/components/viewer/floorplan-preview-visibility.test.ts +35 -0
  95. package/src/components/viewer/floorplan-preview-visibility.ts +19 -0
  96. package/src/components/viewer/floorplan-preview.tsx +1 -12
  97. package/src/components/viewer/viewer-scene-header.tsx +3 -0
  98. package/src/components/viewer/viewer-units-panel.tsx +116 -0
  99. package/src/components/viewer-zone-system.tsx +7 -2
  100. package/src/hooks/use-auto-save.integration.test.tsx +197 -0
  101. package/src/hooks/use-auto-save.ts +21 -6
  102. package/src/hooks/use-ceiling-events.test.ts +25 -2
  103. package/src/hooks/use-ceiling-events.ts +7 -1
  104. package/src/hooks/use-grid-events.test.ts +2 -0
  105. package/src/hooks/use-grid-events.ts +16 -11
  106. package/src/hooks/use-keyboard.test.ts +7 -0
  107. package/src/hooks/use-keyboard.ts +43 -28
  108. package/src/index.tsx +68 -3
  109. package/src/lib/catalog-panel-model.ts +39 -0
  110. package/src/lib/catalog-selection.test.ts +27 -0
  111. package/src/lib/catalog-selection.ts +8 -0
  112. package/src/lib/contextual-help.test.ts +27 -1
  113. package/src/lib/contextual-help.ts +16 -9
  114. package/src/lib/export-texture-utils.test.ts +167 -0
  115. package/src/lib/export-texture-utils.ts +121 -0
  116. package/src/lib/export-user-data.test.ts +48 -0
  117. package/src/lib/export-user-data.ts +69 -0
  118. package/src/lib/floorplan/floorplan-export.test.ts +142 -2
  119. package/src/lib/floorplan/floorplan-export.tsx +9 -3
  120. package/src/lib/floorplan/floorplan-extension.ts +16 -0
  121. package/src/lib/floorplan/floorplan-readonly.ts +4 -0
  122. package/src/lib/fresh-planar-placement.ts +173 -33
  123. package/src/lib/glb-export-serialize.test.ts +280 -0
  124. package/src/lib/glb-export.test.ts +265 -16
  125. package/src/lib/glb-export.ts +197 -37
  126. package/src/lib/grid-frame.test.ts +19 -0
  127. package/src/lib/grid-frame.ts +18 -0
  128. package/src/lib/interaction/scope.ts +9 -1
  129. package/src/lib/keyboard-pan.test.ts +62 -0
  130. package/src/lib/keyboard-pan.ts +89 -0
  131. package/src/lib/level-duplication.test.ts +73 -0
  132. package/src/lib/level-duplication.ts +32 -2
  133. package/src/lib/linear-display.test.ts +59 -0
  134. package/src/lib/linear-display.ts +39 -0
  135. package/src/lib/local-guide-image.test.ts +24 -0
  136. package/src/lib/local-guide-image.ts +34 -1
  137. package/src/lib/material-catalog-model.ts +72 -0
  138. package/src/lib/material-paint-panel-model.ts +76 -0
  139. package/src/lib/node-action-movement.ts +21 -0
  140. package/src/lib/overlay-transform.test.ts +28 -0
  141. package/src/lib/overlay-transform.ts +12 -0
  142. package/src/lib/panel-rows.ts +75 -0
  143. package/src/lib/panel-tool-options.test.ts +34 -0
  144. package/src/lib/panel-tool-options.ts +46 -0
  145. package/src/lib/parametric-node-update.ts +37 -0
  146. package/src/lib/portable-export.test.ts +89 -0
  147. package/src/lib/portable-export.ts +84 -11
  148. package/src/lib/print-shell-compiler-manifold-core.ts +59 -10
  149. package/src/lib/print-shell-compiler-manifold-worker.ts +14 -1
  150. package/src/lib/print-shell-compiler-manifold.worker.ts +1 -1
  151. package/src/lib/print-shell-compiler-protocol.ts +8 -0
  152. package/src/lib/scene-clipboard.ts +1 -1
  153. package/src/lib/scene.test.ts +99 -0
  154. package/src/lib/scene.ts +21 -3
  155. package/src/lib/send-to-app.test.ts +108 -0
  156. package/src/lib/send-to-app.ts +121 -0
  157. package/src/lib/sfx/movement-tick.ts +37 -0
  158. package/src/lib/snapping-mode.test.ts +13 -2
  159. package/src/lib/snapping-mode.ts +11 -6
  160. package/src/lib/spatial-drag-plane.test.ts +53 -0
  161. package/src/lib/spatial-drag-plane.ts +19 -0
  162. package/src/lib/spatial-pointer-input.test.ts +72 -0
  163. package/src/lib/spatial-pointer-input.ts +62 -0
  164. package/src/lib/spatial-snap.test.ts +47 -0
  165. package/src/lib/surface-attachment.ts +77 -0
  166. package/src/lib/terrain-panel-model.ts +117 -0
  167. package/src/lib/units.ts +254 -0
  168. package/src/lib/usdz-export.ts +43 -16
  169. package/src/lib/use-linear-display.ts +8 -32
  170. package/src/lib/world-grid-snap.test.ts +48 -0
  171. package/src/lib/world-grid-snap.ts +9 -5
  172. package/src/store/rotation-snapping.test.ts +53 -0
  173. package/src/store/use-editor.tsx +10 -2
  174. package/src/store/use-floorplan-draft-preview.ts +10 -1
  175. package/src/store/use-handle-group.ts +4 -0
  176. package/src/store/use-interaction-scope.ts +155 -6
  177. 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
+ }
@@ -1,5 +1,9 @@
1
1
  import { describe, expect, it } from 'bun:test'
2
- import { GROUP_MOVE_DRAG_LABEL, ROTATE_HANDLE_DRAG_LABEL } from './contextual-help'
2
+ import {
3
+ GROUP_MOVE_DRAG_LABEL,
4
+ GROUP_ROTATE_DRAG_LABEL,
5
+ ROTATE_HANDLE_DRAG_LABEL,
6
+ } from './contextual-help'
3
7
  import {
4
8
  cycleSnappingModeIn,
5
9
  DEFAULT_SNAPPING_MODE,
@@ -49,6 +53,12 @@ describe('resolveSnapFlags', () => {
49
53
  })
50
54
 
51
55
  describe('per-context snapping', () => {
56
+ it('rotation defaults to angles and cycles independently to free rotation', () => {
57
+ expect(defaultSnappingModeFor('rotation')).toBe('angles')
58
+ expect(snappingModesFor('rotation')).toEqual(['angles', 'off'])
59
+ expect(cycleSnappingModeIn('rotation', 'angles')).toBe('off')
60
+ expect(cycleSnappingModeIn('rotation', 'off')).toBe('angles')
61
+ })
52
62
  it('items default to magnetic alignment with no angle lock', () => {
53
63
  expect(defaultSnappingModeFor('item')).toBe('lines')
54
64
  expect(snappingModesFor('item')).toEqual(['lines', 'grid', 'off'])
@@ -132,7 +142,8 @@ describe('snapContextOf (profile-driven, node-declared)', () => {
132
142
  expect(ctx({ kind: 'handle-drag', nodeId: 'unknown_1' })).toBeNull()
133
143
  expect(
134
144
  ctx({ kind: 'handle-drag', nodeId: 'cabinet-module_1', handle: ROTATE_HANDLE_DRAG_LABEL }),
135
- ).toBeNull()
145
+ ).toBe('rotation')
146
+ expect(ctx({ kind: 'handle-drag', handle: GROUP_ROTATE_DRAG_LABEL })).toBe('rotation')
136
147
  })
137
148
 
138
149
  it('gives mesh rotation the angle context and other edit operations polygon snapping', () => {
@@ -1,5 +1,9 @@
1
1
  import type { SnapProfile } from '@pascal-app/core'
2
- import { GROUP_MOVE_DRAG_LABEL, ROTATE_HANDLE_DRAG_LABEL } from './contextual-help'
2
+ import {
3
+ GROUP_MOVE_DRAG_LABEL,
4
+ GROUP_ROTATE_DRAG_LABEL,
5
+ ROTATE_HANDLE_DRAG_LABEL,
6
+ } from './contextual-help'
3
7
 
4
8
  /**
5
9
  * Snapping mode is a single global, user-cyclable control that maps onto the
@@ -68,14 +72,13 @@ export function nextSnappingMode(mode: SnappingMode): SnappingMode {
68
72
  // alignment lines). The mode is remembered per context and shown live, so it's
69
73
  // never a silent surprise — it just matches what you're doing.
70
74
 
71
- export type SnapContext = 'wall' | 'item' | 'polygon'
75
+ export type SnapContext = 'wall' | 'item' | 'polygon' | 'rotation'
72
76
 
73
77
  // The cyclable mode-set for a context (distinct from the node's `SnapProfile`).
74
78
  type SnapModeSet = { modes: SnappingMode[]; default: SnappingMode }
75
79
 
76
80
  // `modes[0]` is the cycle's first entry; `default` is what a context starts at.
77
- // The 'wall' set is the ONLY one with an angle lock — it applies solely when
78
- // you're setting a segment's DIRECTION (wall/fence drafting + endpoint drag).
81
+ // The 'wall' set includes angle lock when setting a segment's direction.
79
82
  // Translating a whole wall, curving it, or drawing/moving a slab can't change an
80
83
  // angle, so those use the no-angle 'polygon' set.
81
84
  const SNAP_PROFILES: Record<SnapContext, SnapModeSet> = {
@@ -88,9 +91,10 @@ const SNAP_PROFILES: Record<SnapContext, SnapModeSet> = {
88
91
  // whole wall/fence translate, curve reshape, polygon boundary edit. Grid by
89
92
  // default, NO angle lock.
90
93
  polygon: { modes: ['grid', 'lines', 'off'], default: 'grid' },
94
+ rotation: { modes: ['angles', 'off'], default: 'angles' },
91
95
  }
92
96
 
93
- export const SNAP_CONTEXTS: SnapContext[] = ['wall', 'item', 'polygon']
97
+ export const SNAP_CONTEXTS: SnapContext[] = ['wall', 'item', 'polygon', 'rotation']
94
98
 
95
99
  export function snappingModesFor(context: SnapContext): SnappingMode[] {
96
100
  return SNAP_PROFILES[context].modes
@@ -171,7 +175,8 @@ export function snapContextOf(args: {
171
175
  ? contextForProfile(profileOfNode?.(scope.nodeId), scope.operator === 'rotate')
172
176
  : null
173
177
  case 'handle-drag':
174
- if (scope.handle === ROTATE_HANDLE_DRAG_LABEL) return null
178
+ if (scope.handle === ROTATE_HANDLE_DRAG_LABEL || scope.handle === GROUP_ROTATE_DRAG_LABEL)
179
+ return 'rotation'
175
180
  return scope.nodeId ? contextForProfile(profileOfNode?.(scope.nodeId), false) : null
176
181
  case 'placing':
177
182
  case 'moving':
@@ -0,0 +1,53 @@
1
+ import { expect, test } from 'bun:test'
2
+ import { Matrix4, Ray, Vector3 } from 'three'
3
+ import {
4
+ createSpatialDragPlane,
5
+ intersectSpatialDragPlane,
6
+ spatialDragLocalY,
7
+ } from './spatial-drag-plane'
8
+
9
+ test('controller translation gives continuous motion at the grabbed height', () => {
10
+ const start = new Vector3(2, 2.4, 1)
11
+ const ray = new Ray(new Vector3(2, 3.4, 2), new Vector3(0, -1, -1).normalize())
12
+ const plane = createSpatialDragPlane(start, ray)
13
+ for (let step = 0; step <= 10; step++) {
14
+ const moved = ray.clone()
15
+ moved.origin.z += step / 10
16
+ const hit = intersectSpatialDragPlane(moved, plane, new Vector3())!
17
+ expect(hit.x).toBeCloseTo(2)
18
+ expect(hit.y).toBeCloseTo(2.4)
19
+ expect(hit.z).toBeCloseTo(1 + step / 10)
20
+ }
21
+ })
22
+
23
+ test('horizontal rays use a stable facing plane and grazing rays are ignored', () => {
24
+ const ray = new Ray(new Vector3(0, 1, 2), new Vector3(0, 0, -1))
25
+ const plane = createSpatialDragPlane(new Vector3(0, 1, 0), ray)
26
+ ray.origin.x = 0.1
27
+ expect(intersectSpatialDragPlane(ray, plane, new Vector3())?.x).toBeCloseTo(0.1)
28
+ ray.direction.set(1, 0, -0.001).normalize()
29
+ expect(intersectSpatialDragPlane(ray, plane, new Vector3())).toBeNull()
30
+ })
31
+
32
+ test('drag plane follows a rotated, scaled building frame', () => {
33
+ const transform = new Matrix4().makeRotationZ(Math.PI / 4).scale(new Vector3(0.2, 0.2, 0.2))
34
+ const point = new Vector3(0, 2, 0).applyMatrix4(transform)
35
+ const up = new Vector3(0, 1, 0).transformDirection(transform)
36
+ const ray = new Ray(point.clone().add(up), up.clone().negate())
37
+ const plane = createSpatialDragPlane(point, ray, transform)
38
+ expect(intersectSpatialDragPlane(ray, plane, new Vector3())?.distanceTo(point)).toBeLessThan(1e-8)
39
+ })
40
+
41
+ test('height drag uses model metres at human and miniature scales without an initial jump', () => {
42
+ for (const scale of [1, 0.2, 2]) {
43
+ const frame = new Matrix4().makeRotationY(0.7).scale(new Vector3(scale, scale, scale))
44
+ frame.setPosition(2, 4, -3)
45
+ const inverse = frame.clone().invert()
46
+ const start = new Vector3(1, 2.5, 3).applyMatrix4(frame)
47
+ const initialY = spatialDragLocalY(start, inverse)
48
+ for (let step = 0; step <= 10; step++) {
49
+ const moved = start.clone().add(new Vector3(0, (scale * step) / 10, 0))
50
+ expect(2.5 + spatialDragLocalY(moved, inverse) - initialY).toBeCloseTo(2.5 + step / 10)
51
+ }
52
+ }
53
+ })
@@ -0,0 +1,19 @@
1
+ import { type Matrix4, Plane, type Ray, Vector3 } from 'three'
2
+
3
+ export function createSpatialDragPlane(point: Vector3, ray: Ray, localToWorld?: Matrix4) {
4
+ const normal = new Vector3(0, 1, 0)
5
+ if (localToWorld) normal.transformDirection(localToWorld)
6
+ // A nearly horizontal controller ray cannot reliably intersect a floor plane.
7
+ if (Math.abs(normal.dot(ray.direction)) < 0.2) normal.copy(ray.direction)
8
+ return new Plane().setFromNormalAndCoplanarPoint(normal, point)
9
+ }
10
+
11
+ export function intersectSpatialDragPlane(ray: Ray, plane: Plane, target: Vector3) {
12
+ if (Math.abs(plane.normal.dot(ray.direction)) < 0.05) return null
13
+ return ray.intersectPlane(plane, target)
14
+ }
15
+
16
+ // Freeze this frame at grab time; world metres differ from model metres in God view.
17
+ export function spatialDragLocalY(worldPoint: Vector3, worldToLocal: Matrix4) {
18
+ return worldPoint.clone().applyMatrix4(worldToLocal).y
19
+ }
@@ -0,0 +1,72 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { Ray, Vector3 } from 'three'
3
+ import { getSpatialPointerId, SpatialPointerInput } from './spatial-pointer-input'
4
+
5
+ describe('SpatialPointerInput', () => {
6
+ test('recognizes current and legacy XR native event shapes', () => {
7
+ const source = {}
8
+ expect(getSpatialPointerId({ inputSource: source })).toBe(source)
9
+ expect(getSpatialPointerId({ pointerState: { inputSource: source } })).toBe(source)
10
+ expect(getSpatialPointerId({ pointerType: 'mouse' })).toBeNull()
11
+ })
12
+
13
+ test('keeps move and release bound to the pointer that captured a handle', () => {
14
+ const input = new SpatialPointerInput()
15
+ const moves: Ray[] = []
16
+ let releases = 0
17
+ input.capture(42, {
18
+ onMove: (ray) => moves.push(ray.clone()),
19
+ onRelease: () => releases++,
20
+ onCancel: () => undefined,
21
+ })
22
+ const ray = new Ray(new Vector3(1, 2, 3), new Vector3(0, 1, 0))
23
+ expect(input.move(7, ray)).toBe(false)
24
+ expect(input.move(42, ray)).toBe(true)
25
+ expect(input.release(7)).toBe(false)
26
+ expect(input.release(42)).toBe(true)
27
+ expect(moves).toHaveLength(1)
28
+ expect(moves[0]?.origin.toArray()).toEqual([1, 2, 3])
29
+ expect(releases).toBe(1)
30
+ expect(input.move(42, ray)).toBe(false)
31
+ })
32
+
33
+ test('cancels a captured handle without releasing it', () => {
34
+ const input = new SpatialPointerInput()
35
+ let cancels = 0
36
+ let releases = 0
37
+
38
+ input.capture(9, {
39
+ onMove: () => undefined,
40
+ onRelease: () => releases++,
41
+ onCancel: () => cancels++,
42
+ })
43
+
44
+ expect(input.cancel(9)).toBe(true)
45
+ expect(cancels).toBe(1)
46
+ expect(releases).toBe(0)
47
+ })
48
+
49
+ test('cleans up a replaced capture without letting its stale disposer clear the replacement', () => {
50
+ const input = new SpatialPointerInput()
51
+ const ray = new Ray(new Vector3(), new Vector3(0, 0, -1))
52
+ let replacements = 0
53
+ let replacementMoves = 0
54
+
55
+ const disposeOriginal = input.capture(12, {
56
+ onMove: () => undefined,
57
+ onRelease: () => undefined,
58
+ onCancel: () => undefined,
59
+ onReplace: () => replacements++,
60
+ })
61
+ input.capture(12, {
62
+ onMove: () => replacementMoves++,
63
+ onRelease: () => undefined,
64
+ onCancel: () => undefined,
65
+ })
66
+
67
+ expect(replacements).toBe(1)
68
+ disposeOriginal()
69
+ expect(input.move(12, ray)).toBe(true)
70
+ expect(replacementMoves).toBe(1)
71
+ })
72
+ })
@@ -0,0 +1,62 @@
1
+ import type { Ray } from 'three'
2
+
3
+ type SpatialPointerCapture = {
4
+ onMove: (ray: Ray) => void
5
+ onRelease: () => void
6
+ onCancel: () => void
7
+ onReplace?: () => void
8
+ }
9
+
10
+ export type SpatialPointerId = object | number | string
11
+
12
+ export function getSpatialPointerId(nativeEvent: unknown): SpatialPointerId | null {
13
+ if (!nativeEvent || typeof nativeEvent !== 'object') return null
14
+ const event = nativeEvent as {
15
+ inputSource?: object
16
+ pointerState?: { inputSource?: object }
17
+ }
18
+ return event.inputSource ?? event.pointerState?.inputSource ?? null
19
+ }
20
+
21
+ export class SpatialPointerInput {
22
+ private readonly captures = new Map<SpatialPointerId, SpatialPointerCapture>()
23
+
24
+ capture(pointerId: SpatialPointerId, capture: SpatialPointerCapture): () => void {
25
+ const previous = this.captures.get(pointerId)
26
+ if (previous && previous !== capture) {
27
+ this.captures.delete(pointerId)
28
+ ;(previous.onReplace ?? previous.onCancel)()
29
+ }
30
+ this.captures.set(pointerId, capture)
31
+ return () => {
32
+ if (this.captures.get(pointerId) === capture) {
33
+ this.captures.delete(pointerId)
34
+ }
35
+ }
36
+ }
37
+
38
+ move(pointerId: SpatialPointerId, ray: Ray): boolean {
39
+ const capture = this.captures.get(pointerId)
40
+ if (!capture) return false
41
+ capture.onMove(ray)
42
+ return true
43
+ }
44
+
45
+ release(pointerId: SpatialPointerId): boolean {
46
+ const capture = this.captures.get(pointerId)
47
+ if (!capture) return false
48
+ this.captures.delete(pointerId)
49
+ capture.onRelease()
50
+ return true
51
+ }
52
+
53
+ cancel(pointerId: SpatialPointerId): boolean {
54
+ const capture = this.captures.get(pointerId)
55
+ if (!capture) return false
56
+ this.captures.delete(pointerId)
57
+ capture.onCancel()
58
+ return true
59
+ }
60
+ }
61
+
62
+ export const spatialPointerInput = new SpatialPointerInput()
@@ -0,0 +1,47 @@
1
+ import { test } from 'bun:test'
2
+ import assert from 'node:assert/strict'
3
+ import { snapLocalXZInWorld } from '@pascal-app/core'
4
+ import { Group, Vector3 } from 'three'
5
+
6
+ const close = (a: number, b: number) => assert.ok(Math.abs(a - b) < 1e-8)
7
+
8
+ test('free placement stays under the same hit through God-mode zoom and rotation', () => {
9
+ const pose = { position: [5, 0, -3] as [number, number, number], rotationY: 0.7 }
10
+ const world = new Group()
11
+ const building = new Group()
12
+ building.position.fromArray(pose.position)
13
+ building.rotation.y = pose.rotationY
14
+ world.add(building)
15
+ for (const scale of [0.01, 0.1, 1, 4]) {
16
+ world.scale.setScalar(scale)
17
+ world.position.set(8, 1, -4)
18
+ world.rotation.y = 1.2
19
+ world.updateWorldMatrix(true, true)
20
+ const hit = building.localToWorld(new Vector3(2, 0, -6))
21
+ const local = building.worldToLocal(hit.clone())
22
+ const snapped = snapLocalXZInWorld([local.x, local.z], pose, (value) => value)
23
+ const placed = building.localToWorld(new Vector3(snapped[0], 0, snapped[1]))
24
+ close(placed.distanceTo(hit), 0)
25
+ }
26
+ })
27
+
28
+ test('grid snapping still uses model units and the item footprint', () => {
29
+ const dimensions: number[] = []
30
+ const result = snapLocalXZInWorld([1.23, -2.16], null, (value, axis) => {
31
+ dimensions.push([1.8, 0.6][axis]!)
32
+ return Math.round(value / 0.1) * 0.1
33
+ })
34
+ close(result[0], 1.2)
35
+ close(result[1], -2.2)
36
+ assert.deepEqual(dimensions, [1.8, 0.6])
37
+ })
38
+
39
+ test('rotated buildings snap in model-world axes then return building-local coordinates', () => {
40
+ const result = snapLocalXZInWorld(
41
+ [1.2, 2.3],
42
+ { position: [10, 0, 20], rotationY: Math.PI / 2 },
43
+ Math.round,
44
+ )
45
+ close(result[0], 1)
46
+ close(result[1], 2)
47
+ })