@pascal-app/editor 1.0.0-beta.3 → 1.0.0-beta.5

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 (73) hide show
  1. package/package.json +7 -6
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +12 -3
  3. package/src/components/editor/editor-layout-v2.tsx +8 -3
  4. package/src/components/editor/elevation-3d-guide-layer.tsx +3 -2
  5. package/src/components/editor/first-person-controls.tsx +2 -45
  6. package/src/components/editor/floating-action-menu.tsx +13 -4
  7. package/src/components/editor/floorplan-background-selection.test.ts +31 -4
  8. package/src/components/editor/floorplan-background-selection.ts +27 -7
  9. package/src/components/editor/floorplan-panel.tsx +5 -45
  10. package/src/components/editor/group-floating-action-menu.tsx +30 -15
  11. package/src/components/editor/index.tsx +112 -25
  12. package/src/components/editor/measurement-pill.tsx +12 -12
  13. package/src/components/editor/node-action-menu.tsx +29 -1
  14. package/src/components/editor/opening-guides-3d-layer.tsx +13 -4
  15. package/src/components/editor/selection-manager.tsx +28 -5
  16. package/src/components/editor/site-edge-labels.tsx +2 -1
  17. package/src/components/editor/snapshot-capture-overlay.tsx +11 -2
  18. package/src/components/editor/thumbnail-generator.tsx +27 -11
  19. package/src/components/editor/wall-measurement-label.tsx +3 -2
  20. package/src/components/editor-2d/floorplan-group-action-menu.tsx +23 -16
  21. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +26 -84
  22. package/src/components/tools/item/placement-strategies.test.ts +167 -0
  23. package/src/components/tools/item/placement-strategies.ts +57 -22
  24. package/src/components/tools/item/use-placement-coordinator.tsx +19 -27
  25. package/src/components/tools/shared/placement-box.tsx +9 -4
  26. package/src/components/tools/site/terrain-brush-cursor.tsx +2 -1
  27. package/src/components/tools/wall/wall-drafting.test.ts +207 -2
  28. package/src/components/tools/wall/wall-drafting.ts +84 -400
  29. package/src/components/ui/command-palette/editor-commands.tsx +3 -1
  30. package/src/components/ui/floating-level-selector.tsx +1 -1
  31. package/src/components/ui/panels/multi-selection-panel.tsx +54 -13
  32. package/src/components/ui/panels/panel-wrapper.tsx +228 -12
  33. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +7 -2
  34. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +11 -0
  35. package/src/components/ui/sidebar/panels/site-panel/index.tsx +12 -0
  36. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +8 -1
  37. package/src/components/ui/sidebar/tab-bar.tsx +7 -1
  38. package/src/components/viewer/floorplan-compass-button.tsx +50 -0
  39. package/src/components/viewer/floorplan-preview-geometry.test.ts +107 -0
  40. package/src/components/viewer/floorplan-preview-geometry.ts +281 -0
  41. package/src/components/viewer/floorplan-preview-navigation.test.ts +43 -0
  42. package/src/components/viewer/floorplan-preview-navigation.ts +62 -0
  43. package/src/components/viewer/floorplan-preview.tsx +1069 -0
  44. package/src/components/viewer/use-viewer-camera-navigation-sync.ts +140 -0
  45. package/src/components/viewer/viewer-stage-modes.test.ts +38 -0
  46. package/src/components/viewer/viewer-stage-modes.ts +49 -0
  47. package/src/components/viewer/viewer-stage-switcher.tsx +95 -0
  48. package/src/components/viewer/viewer-stage.test.tsx +47 -0
  49. package/src/components/viewer/viewer-stage.tsx +218 -0
  50. package/src/components/viewer-overlay.tsx +12 -8
  51. package/src/hooks/use-auto-save.test.ts +50 -1
  52. package/src/hooks/use-auto-save.ts +44 -9
  53. package/src/hooks/use-keyboard.ts +35 -1
  54. package/src/index.tsx +27 -0
  55. package/src/lib/contextual-help.test.ts +8 -0
  56. package/src/lib/contextual-help.ts +8 -0
  57. package/src/lib/floorplan/floorplan-readonly.test.ts +47 -0
  58. package/src/lib/floorplan/floorplan-readonly.ts +84 -0
  59. package/src/lib/history.test.ts +75 -10
  60. package/src/lib/history.ts +58 -13
  61. package/src/lib/inspector-card-mode.test.ts +92 -0
  62. package/src/lib/inspector-card-mode.ts +60 -0
  63. package/src/lib/measurement-parser.ts +3 -0
  64. package/src/lib/measurements.test.ts +4 -0
  65. package/src/lib/paint-scope.test.ts +58 -2
  66. package/src/lib/paint-scope.ts +8 -2
  67. package/src/lib/selection-routing.test.ts +20 -5
  68. package/src/lib/selection-routing.ts +15 -1
  69. package/src/lib/session-groups.test.ts +88 -0
  70. package/src/lib/session-groups.ts +201 -0
  71. package/src/lib/structured-clone-fallback.test.ts +45 -0
  72. package/src/lib/structured-clone-fallback.ts +24 -0
  73. package/src/store/use-session-groups.ts +103 -0
@@ -6,6 +6,9 @@ import {
6
6
  createSnapshotPipeline,
7
7
  GRID_LAYER,
8
8
  heroCameraPose,
9
+ SNAPSHOT_MAX_EDGE,
10
+ SNAPSHOT_MIME,
11
+ SNAPSHOT_QUALITY,
9
12
  type SnapshotPipeline,
10
13
  snapLevelsToTruePositions,
11
14
  THUMBNAIL_HEIGHT,
@@ -33,6 +36,14 @@ interface ThumbnailGeneratorProps {
33
36
  onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void
34
37
  }
35
38
 
39
+ function clampSnapshotSize(width: number, height: number): { w: number; h: number } {
40
+ const maxEdge = Math.max(width, height)
41
+ if (maxEdge <= SNAPSHOT_MAX_EDGE) return { w: width, h: height }
42
+
43
+ const scale = SNAPSHOT_MAX_EDGE / maxEdge
44
+ return { w: Math.round(width * scale), h: Math.round(height * scale) }
45
+ }
46
+
36
47
  export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorProps) => {
37
48
  const gl = useThree((state) => state.gl)
38
49
  const scene = useThree((state) => state.scene)
@@ -234,33 +245,37 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
234
245
  let outH: number
235
246
 
236
247
  if (captureMode === 'viewport') {
237
- outW = width
238
- outH = height
248
+ ;({ w: outW, h: outH } = clampSnapshotSize(width, height))
239
249
  const offscreen = document.createElement('canvas')
240
250
  offscreen.width = outW
241
251
  offscreen.height = outH
242
- offscreen.getContext('2d')!.drawImage(gl.domElement, 0, 0)
252
+ const ctx = offscreen.getContext('2d')!
253
+ if (outW !== width || outH !== height) ctx.imageSmoothingQuality = 'high'
254
+ ctx.drawImage(gl.domElement, 0, 0, width, height, 0, 0, outW, outH)
243
255
  blob = await new Promise<Blob>((resolve, reject) =>
244
256
  offscreen.toBlob(
245
257
  (b) => (b ? resolve(b) : reject(new Error('Canvas capture failed'))),
246
- 'image/png',
258
+ SNAPSHOT_MIME,
259
+ SNAPSHOT_QUALITY,
247
260
  ),
248
261
  )
249
262
  } else if (captureMode === 'area' && cropRegion) {
250
263
  const sx = Math.round(cropRegion.x * width)
251
264
  const sy = Math.round(cropRegion.y * height)
252
- outW = Math.round(cropRegion.width * width)
253
- outH = Math.round(cropRegion.height * height)
265
+ const sourceW = Math.round(cropRegion.width * width)
266
+ const sourceH = Math.round(cropRegion.height * height)
267
+ ;({ w: outW, h: outH } = clampSnapshotSize(sourceW, sourceH))
254
268
  const offscreen = document.createElement('canvas')
255
269
  offscreen.width = outW
256
270
  offscreen.height = outH
257
- offscreen
258
- .getContext('2d')!
259
- .drawImage(gl.domElement, sx, sy, outW, outH, 0, 0, outW, outH)
271
+ const ctx = offscreen.getContext('2d')!
272
+ if (outW !== sourceW || outH !== sourceH) ctx.imageSmoothingQuality = 'high'
273
+ ctx.drawImage(gl.domElement, sx, sy, sourceW, sourceH, 0, 0, outW, outH)
260
274
  blob = await new Promise<Blob>((resolve, reject) =>
261
275
  offscreen.toBlob(
262
276
  (b) => (b ? resolve(b) : reject(new Error('Canvas capture failed'))),
263
- 'image/png',
277
+ SNAPSHOT_MIME,
278
+ SNAPSHOT_QUALITY,
264
279
  ),
265
280
  )
266
281
  } else {
@@ -288,7 +303,8 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
288
303
  blob = await new Promise<Blob>((resolve, reject) =>
289
304
  offscreen.toBlob(
290
305
  (b) => (b ? resolve(b) : reject(new Error('Canvas capture failed'))),
291
- 'image/png',
306
+ SNAPSHOT_MIME,
307
+ SNAPSHOT_QUALITY,
292
308
  ),
293
309
  )
294
310
  }
@@ -510,6 +510,7 @@ function SelectedMeasurementAnnotation({ node }: { node: WallNode | ItemNode })
510
510
  function WallMeasurementAnnotation({ wall }: { wall: WallNode }) {
511
511
  const nodes = useScene((state) => state.nodes)
512
512
  const unit = useViewer((state) => state.unit)
513
+ const metricNotation = useViewer((state) => state.metricNotation)
513
514
  const isNight = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark')
514
515
  const color = isNight ? '#ffffff' : '#111111'
515
516
  const shadowColor = isNight ? '#111111' : '#ffffff'
@@ -528,9 +529,9 @@ function WallMeasurementAnnotation({ wall }: { wall: WallNode }) {
528
529
  }
529
530
  return total
530
531
  }, [guide, wall])
531
- const label = formatLinearMeasurement(length, unit)
532
+ const label = formatLinearMeasurement(length, unit, metricNotation)
532
533
  const height = useMemo(() => getWallEffectiveHeightForNodes(wall, nodes), [nodes, wall])
533
- const heightLabel = `H ${formatLinearMeasurement(height, unit)}`
534
+ const heightLabel = `H ${formatLinearMeasurement(height, unit, metricNotation)}`
534
535
 
535
536
  if (!(guide && Number.isFinite(length) && length >= 0.01)) return null
536
537
 
@@ -1,11 +1,17 @@
1
1
  'use client'
2
2
 
3
+ import { useScene } from '@pascal-app/core'
3
4
  import { useViewer } from '@pascal-app/viewer'
4
- import { useEffect, useState } from 'react'
5
+ import { useEffect, useMemo, useState } from 'react'
5
6
  import { createPortal } from 'react-dom'
6
7
  import { isActive } from '../../lib/interaction/scope'
8
+ import { canCreateSessionGroup, selectionIntersectsSessionGroup } from '../../lib/session-groups'
7
9
  import useEditor from '../../store/use-editor'
8
10
  import useInteractionScope, { useMovingNode } from '../../store/use-interaction-scope'
11
+ import useSessionGroups, {
12
+ groupCurrentSelection,
13
+ ungroupCurrentSelection,
14
+ } from '../../store/use-session-groups'
9
15
  import {
10
16
  deleteSelection,
11
17
  duplicateSelectionAndPickUp,
@@ -14,25 +20,27 @@ import {
14
20
  import { NodeActionMenu } from '../editor/node-action-menu'
15
21
 
16
22
  /**
17
- * Floating Move / Duplicate / Delete pill for a MULTI-selection in the 2D
18
- * floor plan — the group sibling of `FloorplanRegistryActionMenu` (which is
19
- * sole-selection only). Anchored above the dashed group selection box; every
20
- * action targets the whole selection: Move picks the group up (it rides the
21
- * cursor until a click places it), Duplicate clones the selection and picks
22
- * the clones up, Delete removes everything selected.
23
- *
24
- * Gated on floorplan hover so it never coexists with the 3D group menu in
25
- * split view (that one hides while the floor plan is hovered), and hidden
26
- * during any active interaction so it never competes with a live drag.
23
+ * Floating multi-select pill on the floor plan: Move, Group, Ungroup, Duplicate, Delete.
27
24
  */
28
25
  export function FloorplanGroupActionMenu() {
29
- const isMultiSelect = useViewer((s) => s.selection.selectedIds.length > 1)
26
+ const selectedIds = useViewer((s) => s.selection.selectedIds)
27
+ const isMultiSelect = selectedIds.length > 1
30
28
  const movingNode = useMovingNode()
31
29
  const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
32
30
  const scopeActive = useInteractionScope((s) => isActive(s.scope))
31
+ const sessionGroups = useSessionGroups((s) => s.groups)
32
+ const sceneNodes = useScene((s) => s.nodes)
33
+ const liveIds = useMemo(() => new Set(Object.keys(sceneNodes)), [sceneNodes])
34
+ const showGroup = useMemo(
35
+ () => canCreateSessionGroup(sessionGroups, selectedIds, liveIds),
36
+ [sessionGroups, selectedIds, liveIds],
37
+ )
38
+ const showUngroup = useMemo(
39
+ () => selectionIntersectsSessionGroup(sessionGroups, selectedIds, liveIds),
40
+ [sessionGroups, selectedIds, liveIds],
41
+ )
33
42
 
34
43
  const [position, setPosition] = useState<{ left: number; top: number } | null>(null)
35
-
36
44
  const isVisible = isMultiSelect && !movingNode && isFloorplanHovered && !scopeActive
37
45
 
38
46
  useEffect(() => {
@@ -43,9 +51,6 @@ export function FloorplanGroupActionMenu() {
43
51
  let raf = 0
44
52
  const tick = () => {
45
53
  raf = requestAnimationFrame(tick)
46
- // The dashed group box exists exactly while the multi-selection has
47
- // transformable participants — anchor to its top edge. Only publish
48
- // actual changes so the idle poll doesn't re-render every frame.
49
54
  const box = document.querySelector('[data-group-selection-box]') as SVGGElement | null
50
55
  if (!box) {
51
56
  setPosition((prev) => (prev === null ? prev : null))
@@ -77,9 +82,11 @@ export function FloorplanGroupActionMenu() {
77
82
  <NodeActionMenu
78
83
  onDelete={() => deleteSelection()}
79
84
  onDuplicate={() => duplicateSelectionAndPickUp()}
85
+ onGroup={showGroup ? () => groupCurrentSelection() : undefined}
80
86
  onMove={() => startGroupPickUp()}
81
87
  onPointerDown={(event) => event.stopPropagation()}
82
88
  onPointerUp={(event) => event.stopPropagation()}
89
+ onUngroup={showUngroup ? () => ungroupCurrentSelection() : undefined}
83
90
  />
84
91
  </div>,
85
92
  document.body,
@@ -67,6 +67,10 @@ import {
67
67
  resolveFloorplanAnnotationVisibility,
68
68
  resolveFloorplanWallDimensionReference,
69
69
  } from '../../../lib/floorplan/floorplan-mode'
70
+ import {
71
+ buildFloorplanContext,
72
+ floorplanLayerRank,
73
+ } from '../../../lib/floorplan/floorplan-readonly'
70
74
  import { clientToPlan } from '../../../lib/floorplan/plan-coords'
71
75
  import {
72
76
  type ActiveInteractionScope,
@@ -91,6 +95,7 @@ import useInteractionScope, {
91
95
  useEndpointReshape,
92
96
  useMovingNode,
93
97
  } from '../../../store/use-interaction-scope'
98
+ import { expandSessionSelectionForNode } from '../../../store/use-session-groups'
94
99
  import { startGroupPickUp } from '../../editor/group-actions'
95
100
  import { classifyParticipant } from '../../editor/group-transform-shared'
96
101
  import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state'
@@ -589,13 +594,19 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
589
594
  }, [bumpAffectedSiblingEpochs])
590
595
 
591
596
  const applyEntrySelection = useCallback(
592
- (id: AnyNodeId, shouldToggle: boolean) => {
597
+ (id: AnyNodeId, options: { shouldToggle: boolean; isolateMember: boolean }) => {
593
598
  const currentSelectedIds = useViewer.getState().selection.selectedIds
594
- const nextSelectedIds = shouldToggle
595
- ? currentSelectedIds.includes(id)
599
+ let nextSelectedIds: string[]
600
+ if (options.shouldToggle) {
601
+ nextSelectedIds = currentSelectedIds.includes(id)
596
602
  ? currentSelectedIds.filter((selectedId) => selectedId !== id)
597
603
  : [...currentSelectedIds, id]
598
- : [id]
604
+ } else if (options.isolateMember) {
605
+ nextSelectedIds = [id]
606
+ } else {
607
+ const expanded = expandSessionSelectionForNode(id)
608
+ nextSelectedIds = expanded && expanded.length > 1 ? expanded : [id]
609
+ }
599
610
  setSelection({ selectedIds: nextSelectedIds })
600
611
  if (nextSelectedIds.length === 1 && nextSelectedIds[0] === id) {
601
612
  const node = useScene.getState().nodes[id]
@@ -618,7 +629,10 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
618
629
  (id: AnyNodeId, event: React.PointerEvent<SVGGElement>) => {
619
630
  if (event.button !== 0) return
620
631
  event.stopPropagation()
621
- applyEntrySelection(id, event.metaKey || event.ctrlKey || event.shiftKey)
632
+ applyEntrySelection(id, {
633
+ shouldToggle: event.metaKey || event.ctrlKey || event.shiftKey,
634
+ isolateMember: event.altKey && !(event.metaKey || event.ctrlKey || event.shiftKey),
635
+ })
622
636
  },
623
637
  [applyEntrySelection],
624
638
  )
@@ -691,7 +705,11 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
691
705
  if (endEvent.pointerId !== pointerId) return
692
706
  cleanup()
693
707
  if (!engaged) {
694
- applyEntrySelection(id, true)
708
+ // Cmd/Ctrl+click without drag: toggle member (options object, not bare boolean).
709
+ applyEntrySelection(id, {
710
+ shouldToggle: true,
711
+ isolateMember: false,
712
+ })
695
713
  }
696
714
  }
697
715
 
@@ -3144,73 +3162,7 @@ function isFloorplanHierarchyVisible(
3144
3162
  return true
3145
3163
  }
3146
3164
 
3147
- export function buildContext(
3148
- node: AnyNode,
3149
- nodes: Record<string, AnyNode>,
3150
- viewState: {
3151
- automaticDimensions?: boolean
3152
- selected: boolean
3153
- unit: 'metric' | 'imperial'
3154
- metricNotation?: 'meters' | 'millimeters'
3155
- purpose?: 'edit' | 'document'
3156
- wallDimensionReference?: FloorplanWallDimensionReference
3157
- highlighted: boolean
3158
- hovered: boolean
3159
- moving: boolean
3160
- palette: FloorplanPalette | undefined
3161
- },
3162
- levelData?: unknown,
3163
- ): GeometryContext {
3164
- const resolve = <N = AnyNode>(id: AnyNodeId): N | undefined => nodes[id] as N | undefined
3165
-
3166
- const childIds = (node as unknown as { children?: AnyNodeId[] }).children
3167
- const children: AnyNode[] = Array.isArray(childIds)
3168
- ? childIds.map((cid) => nodes[cid]).filter((n): n is AnyNode => n !== undefined)
3169
- : []
3170
-
3171
- const parentId = node.parentId as AnyNodeId | null
3172
- const parent: AnyNode | null = parentId ? (nodes[parentId] ?? null) : null
3173
-
3174
- let siblings: AnyNode[] = []
3175
- if (parent) {
3176
- const parentChildIds = (parent as unknown as { children?: AnyNodeId[] }).children
3177
- if (Array.isArray(parentChildIds)) {
3178
- for (const sid of parentChildIds) {
3179
- if (sid === node.id) continue
3180
- const s = nodes[sid]
3181
- if (s && s.type === node.type) siblings.push(s)
3182
- }
3183
- } else {
3184
- siblings = Object.values(nodes).filter(
3185
- (n) => n !== node && n.type === node.type && n.parentId === parentId,
3186
- )
3187
- }
3188
- }
3189
-
3190
- return {
3191
- resolve,
3192
- children,
3193
- siblings,
3194
- parent,
3195
- levelData,
3196
- extensions: createFloorplanContextExtensions({
3197
- automaticDimensions: viewState.automaticDimensions,
3198
- metricNotation: viewState.metricNotation ?? 'meters',
3199
- purpose: viewState.purpose ?? 'edit',
3200
- wallDimensionReference: viewState.wallDimensionReference,
3201
- }),
3202
- viewState: viewState.palette
3203
- ? {
3204
- selected: viewState.selected,
3205
- unit: viewState.unit,
3206
- highlighted: viewState.highlighted,
3207
- hovered: viewState.hovered,
3208
- moving: viewState.moving,
3209
- palette: viewState.palette,
3210
- }
3211
- : undefined,
3212
- }
3213
- }
3165
+ export const buildContext = buildFloorplanContext
3214
3166
 
3215
3167
  export function collectFloorplanLinkedLevelNodes(
3216
3168
  nodes: Record<string, AnyNode>,
@@ -3512,17 +3464,7 @@ function depsValueEqual(a: unknown, b: unknown): boolean {
3512
3464
  * Sort is stable in modern JS engines, so siblings within the same
3513
3465
  * bucket keep their DFS order (= scene tree order).
3514
3466
  */
3515
- export function floorplanLayerRank(type: string): number {
3516
- switch (type) {
3517
- case 'zone':
3518
- return 0
3519
- case 'slab':
3520
- case 'ceiling':
3521
- return 1
3522
- default:
3523
- return 2
3524
- }
3525
- }
3467
+ export { floorplanLayerRank }
3526
3468
 
3527
3469
  function deepEqual(a: unknown, b: unknown): boolean {
3528
3470
  if (a === b) return true
@@ -0,0 +1,167 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import type { ItemNode, WallEvent, WallNode } from '@pascal-app/core'
3
+ import { Mesh, type Object3D, Vector3 } from 'three'
4
+ import { wallStrategy } from './placement-strategies'
5
+ import type { PlacementContext, SpatialValidators } from './placement-types'
6
+
7
+ /**
8
+ * The wall frame the runtime builds in `updateWallGeometry`: origin at
9
+ * `wall.start` lifted to the supporting slab's elevation, yawed by the wall
10
+ * angle. Wall-local Y is therefore measured from the slab, NOT from world zero
11
+ * — which is exactly why snapping the world hit and the wall-local hit
12
+ * separately used to put the preview box and the item on different points.
13
+ */
14
+ function makeWallFrame(wall: WallNode, slabElevation: number): Mesh {
15
+ const wallMesh = new Mesh()
16
+ wallMesh.position.set(wall.start[0], slabElevation, wall.start[1])
17
+ wallMesh.rotation.y = -Math.atan2(wall.end[1] - wall.start[1], wall.end[0] - wall.start[0])
18
+ const collisionMesh = new Mesh()
19
+ wallMesh.add(collisionMesh)
20
+ wallMesh.updateMatrixWorld(true)
21
+ return collisionMesh
22
+ }
23
+
24
+ function makeWall(overrides: Partial<WallNode> = {}): WallNode {
25
+ return {
26
+ id: 'wall_test',
27
+ type: 'wall',
28
+ parentId: 'level_test',
29
+ children: [],
30
+ start: [2.3, 1.7],
31
+ end: [8.3, 1.7],
32
+ thickness: 0.2,
33
+ ...overrides,
34
+ } as WallNode
35
+ }
36
+
37
+ function makeDraft(): ItemNode {
38
+ return {
39
+ id: 'item_draft',
40
+ type: 'item',
41
+ position: [0, 0, 0],
42
+ rotation: [0, 0, 0],
43
+ scale: [1, 1, 1],
44
+ children: [],
45
+ asset: {
46
+ id: 'asset_hold',
47
+ category: 'sport',
48
+ name: 'Climbing hold',
49
+ thumbnail: '',
50
+ source: 'library',
51
+ src: '',
52
+ dimensions: [0.65, 0.33, 0.63],
53
+ attachTo: 'wall-side',
54
+ offset: [0, 0.165, 0.1],
55
+ rotation: [0, 0, 0],
56
+ scale: [1, 1, 1],
57
+ },
58
+ } as unknown as ItemNode
59
+ }
60
+
61
+ /** Front face of the wall: wall-local +Z. */
62
+ const FRONT_NORMAL: [number, number, number] = [0, 0, 1]
63
+
64
+ function makeWallEvent(wall: WallNode, collisionMesh: Object3D, localHit: Vector3): WallEvent {
65
+ const world = collisionMesh.localToWorld(localHit.clone())
66
+ return {
67
+ node: wall,
68
+ position: [world.x, world.y, world.z],
69
+ localPosition: [localHit.x, localHit.y, localHit.z],
70
+ normal: FRONT_NORMAL,
71
+ object: collisionMesh,
72
+ stopPropagation: () => undefined,
73
+ } as unknown as WallEvent
74
+ }
75
+
76
+ const validators: SpatialValidators = {
77
+ canPlaceOnFloor: () => ({ valid: true }),
78
+ canPlaceOnWall: () => ({ valid: true }),
79
+ canPlaceOnCeiling: () => ({ valid: true }),
80
+ }
81
+
82
+ function makeContext(draft: ItemNode): PlacementContext {
83
+ return {
84
+ asset: draft.asset,
85
+ levelId: 'level_test',
86
+ draftItem: draft,
87
+ gridPosition: new Vector3(),
88
+ state: {
89
+ surface: 'wall',
90
+ wallId: 'wall_test',
91
+ roofSegmentId: null,
92
+ ceilingId: null,
93
+ surfaceItemId: null,
94
+ shelfId: null,
95
+ },
96
+ currentCursorRotationY: 0,
97
+ } as unknown as PlacementContext
98
+ }
99
+
100
+ describe('wallStrategy.move', () => {
101
+ /**
102
+ * The preview wireframe (`cursorPosition`, world) and the committed node
103
+ * (`gridPosition`, wall-local) must describe ONE point. Snapping them
104
+ * independently drifted the box off the item by the slab elevation plus up to
105
+ * a grid step, and the commit then landed where the box was not.
106
+ */
107
+ test.each([
108
+ ['axis-aligned wall on an elevated slab', makeWall(), 0.4],
109
+ [
110
+ 'diagonal wall off the world grid',
111
+ makeWall({ start: [1.15, 0.35], end: [5.15, 4.35] } as Partial<WallNode>),
112
+ 0.15,
113
+ ],
114
+ ])('keeps the preview box on the committed point — %s', (_label, wall, slabElevation) => {
115
+ const collisionMesh = makeWallFrame(wall, slabElevation)
116
+ const draft = makeDraft()
117
+ const event = makeWallEvent(wall, collisionMesh, new Vector3(2.42, 1.38, 0.1))
118
+
119
+ const result = wallStrategy.move(makeContext(draft), event, validators)
120
+ if (!result) throw new Error('expected a placement result')
121
+
122
+ const cursorFromNode = collisionMesh.localToWorld(new Vector3(...result.gridPosition))
123
+ expect(cursorFromNode.x).toBeCloseTo(result.cursorPosition[0], 6)
124
+ expect(cursorFromNode.y).toBeCloseTo(result.cursorPosition[1], 6)
125
+ expect(cursorFromNode.z).toBeCloseTo(result.cursorPosition[2], 6)
126
+ })
127
+
128
+ test('mounts the wall-side preview on the hit face, not through the wall', () => {
129
+ const wall = makeWall()
130
+ const collisionMesh = makeWallFrame(wall, 0.4)
131
+ const draft = makeDraft()
132
+ const event = makeWallEvent(wall, collisionMesh, new Vector3(2.42, 1.38, 0.1))
133
+
134
+ const result = wallStrategy.move(makeContext(draft), event, validators)
135
+ if (!result) throw new Error('expected a placement result')
136
+
137
+ // Front face → wall-local +thickness/2, matching ItemSystem's per-frame push.
138
+ expect(result.gridPosition[2]).toBeCloseTo(0.1, 6)
139
+ // The cursor frame IS the item frame, so the box's +Z (its depth) points out
140
+ // of the same face the item body extends from.
141
+ const outward = new Vector3(0, 0, 1).applyAxisAngle(
142
+ new Vector3(0, 1, 0),
143
+ result.cursorRotationY,
144
+ )
145
+ const wallNormal = new Vector3(0, 0, 1).applyAxisAngle(
146
+ new Vector3(0, 1, 0),
147
+ collisionMesh.parent!.rotation.y,
148
+ )
149
+ expect(outward.dot(wallNormal)).toBeCloseTo(1, 6)
150
+ })
151
+
152
+ test('carries the wall auto-adjusted Y into the preview box', () => {
153
+ const wall = makeWall()
154
+ const collisionMesh = makeWallFrame(wall, 0.4)
155
+ const draft = makeDraft()
156
+ const event = makeWallEvent(wall, collisionMesh, new Vector3(2.42, 1.38, 0.1))
157
+
158
+ const result = wallStrategy.move(makeContext(draft), event, {
159
+ ...validators,
160
+ canPlaceOnWall: () => ({ valid: true, adjustedY: 0.05, wasAdjusted: true }),
161
+ })
162
+ if (!result) throw new Error('expected a placement result')
163
+
164
+ expect(result.gridPosition[1]).toBeCloseTo(0.05, 6)
165
+ expect(result.cursorPosition[1]).toBeCloseTo(0.4 + 0.05, 6)
166
+ })
167
+ })
@@ -30,7 +30,6 @@ import { Euler, Matrix3, Quaternion, Vector3 } from 'three'
30
30
  import { hasRoofFaceChildOverlap, resolveRoofWallHit } from '../../../lib/roof-wall-hit'
31
31
  import { snapWorldXZForActiveBuilding } from '../../../lib/world-grid-snap'
32
32
  import {
33
- calculateCursorRotation,
34
33
  calculateItemRotation,
35
34
  getGridAlignedDimensions,
36
35
  getSideFromNormal,
@@ -178,6 +177,51 @@ export const floorStrategy = {
178
177
  // WALL STRATEGY
179
178
  // ============================================================================
180
179
 
180
+ /**
181
+ * Resolve the wall-local node position AND the world pose of the placement
182
+ * wireframe from ONE wall-local point, so the box can't drift from the item it
183
+ * previews.
184
+ *
185
+ * `event.object` is the wall's collision mesh — the frame `event.localPosition`
186
+ * was measured in — so `localToWorld` is the exact inverse of the hit. Snapping
187
+ * the raw world hit per axis instead (the old path) put the box on a different
188
+ * lattice than the node: wall-local X runs from `wall.start`, and wall-local Y
189
+ * is measured from the supporting slab's elevation, not from world zero.
190
+ *
191
+ * `z` follows the hosting convention rather than the hit depth: `wall` items
192
+ * center in the thickness, `wall-side` items mount on the hit face — mirroring
193
+ * `ItemSystem`'s per-frame push, so the wireframe's `z = 0` face lands flush
194
+ * with the wall instead of extending through it.
195
+ */
196
+ function resolveWallPlacementPose(
197
+ event: WallEvent,
198
+ localX: number,
199
+ localY: number,
200
+ attachTo: 'wall' | 'wall-side',
201
+ side: 'front' | 'back',
202
+ itemRotation: number,
203
+ ): {
204
+ position: [number, number, number]
205
+ cursorPosition: [number, number, number]
206
+ cursorRotationY: number
207
+ } {
208
+ const localZ =
209
+ attachTo === 'wall-side' ? ((event.node.thickness ?? 0.1) / 2) * (side === 'front' ? 1 : -1) : 0
210
+ event.object.updateWorldMatrix(true, false)
211
+ const world = event.object.localToWorld(new Vector3(localX, localY, localZ))
212
+ const wallYaw = -Math.atan2(
213
+ event.node.end[1] - event.node.start[1],
214
+ event.node.end[0] - event.node.start[0],
215
+ )
216
+ return {
217
+ position: [localX, localY, localZ],
218
+ cursorPosition: [world.x, world.y, world.z],
219
+ // Same composition the 2D floorplan resolves a wall child with
220
+ // (`resolveItemTransform`): the cursor frame IS the item frame.
221
+ cursorRotationY: wallYaw + itemRotation,
222
+ }
223
+ }
224
+
181
225
  export const wallStrategy = {
182
226
  /**
183
227
  * Handle wall:enter — transition from floor to wall surface.
@@ -201,11 +245,9 @@ export const wallStrategy = {
201
245
 
202
246
  const side = getSideFromNormal(event.normal)
203
247
  const itemRotation = calculateItemRotation(event.normal)
204
- const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
205
248
 
206
249
  const x = snapToHalf(event.localPosition[0])
207
250
  const y = snapToHalf(event.localPosition[1])
208
- const z = snapToHalf(event.localPosition[2])
209
251
 
210
252
  // Get auto-adjusted Y position from validator
211
253
  const rawDims = ctx.draftItem
@@ -223,11 +265,12 @@ export const wallStrategy = {
223
265
  )
224
266
 
225
267
  const adjustedY = validation.adjustedY ?? y
268
+ const pose = resolveWallPlacementPose(event, x, adjustedY, attachTo, side, itemRotation)
226
269
 
227
270
  return {
228
271
  stateUpdate: { surface: 'wall', wallId: event.node.id, roofSegmentId: null },
229
272
  nodeUpdate: {
230
- position: [x, adjustedY, z],
273
+ position: pose.position,
231
274
  parentId: event.node.id,
232
275
  // The draft may arrive from a roof-segment wall face.
233
276
  roofSegmentId: undefined,
@@ -235,13 +278,9 @@ export const wallStrategy = {
235
278
  side,
236
279
  rotation: [0, itemRotation, 0],
237
280
  },
238
- cursorRotationY: cursorRotation,
239
- gridPosition: [x, adjustedY, z],
240
- cursorPosition: [
241
- snapToHalf(event.position[0]),
242
- snapToHalf(event.position[1]),
243
- snapToHalf(event.position[2]),
244
- ],
281
+ cursorRotationY: pose.cursorRotationY,
282
+ gridPosition: pose.position,
283
+ cursorPosition: pose.cursorPosition,
245
284
  stopPropagation: true,
246
285
  }
247
286
  },
@@ -262,11 +301,10 @@ export const wallStrategy = {
262
301
 
263
302
  const side = getSideFromNormal(event.normal)
264
303
  const itemRotation = calculateItemRotation(event.normal)
265
- const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
304
+ const attachTo = ctx.draftItem.asset.attachTo as 'wall' | 'wall-side'
266
305
 
267
306
  const snappedX = snapToHalf(event.localPosition[0])
268
307
  const snappedY = snapToHalf(event.localPosition[1])
269
- const snappedZ = snapToHalf(event.localPosition[2])
270
308
 
271
309
  // Get auto-adjusted Y position from validator
272
310
  const validation = validators.canPlaceOnWall(
@@ -275,23 +313,20 @@ export const wallStrategy = {
275
313
  snappedX,
276
314
  snappedY,
277
315
  getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), ctx.draftItem.asset.attachTo),
278
- ctx.draftItem.asset.attachTo as 'wall' | 'wall-side',
316
+ attachTo,
279
317
  side,
280
318
  [ctx.draftItem.id],
281
319
  )
282
320
 
283
321
  const adjustedY = validation.adjustedY ?? snappedY
322
+ const pose = resolveWallPlacementPose(event, snappedX, adjustedY, attachTo, side, itemRotation)
284
323
 
285
324
  return {
286
- gridPosition: [snappedX, adjustedY, snappedZ],
287
- cursorPosition: [
288
- snapToHalf(event.position[0]),
289
- snapToHalf(event.position[1]),
290
- snapToHalf(event.position[2]),
291
- ],
292
- cursorRotationY: cursorRotation,
325
+ gridPosition: pose.position,
326
+ cursorPosition: pose.cursorPosition,
327
+ cursorRotationY: pose.cursorRotationY,
293
328
  nodeUpdate: {
294
- position: [snappedX, adjustedY, snappedZ],
329
+ position: pose.position,
295
330
  side,
296
331
  rotation: [0, itemRotation, 0],
297
332
  },