@pascal-app/editor 0.7.0 → 0.8.0

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 (103) hide show
  1. package/package.json +6 -6
  2. package/src/components/editor/custom-camera-controls.tsx +2 -1
  3. package/src/components/editor/editor-layout-v2.tsx +4 -3
  4. package/src/components/editor/first-person/build-collider-world.ts +5 -7
  5. package/src/components/editor/first-person/bvh-ecctrl.tsx +119 -54
  6. package/src/components/editor/first-person-controls.tsx +11 -11
  7. package/src/components/editor/floating-action-menu.tsx +0 -0
  8. package/src/components/editor/floorplan-panel.tsx +44 -37
  9. package/src/components/editor/index.tsx +68 -53
  10. package/src/components/editor/selection-manager.tsx +2 -2
  11. package/src/components/editor/snapshot-capture-overlay.tsx +465 -0
  12. package/src/components/editor/thumbnail-generator.tsx +18 -61
  13. package/src/components/editor/use-floorplan-background-placement.ts +3 -3
  14. package/src/components/editor/wall-measurement-label.tsx +0 -0
  15. package/src/components/editor-2d/renderers/floorplan-draft-layer.tsx +6 -1
  16. package/src/components/editor-2d/renderers/floorplan-measurements-layer.tsx +6 -1
  17. package/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +5 -5
  18. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +10 -12
  19. package/src/components/systems/roof/roof-edit-system.tsx +1 -1
  20. package/src/components/systems/stair/stair-edit-system.tsx +1 -1
  21. package/src/components/systems/zone/zone-label-editor-system.tsx +0 -0
  22. package/src/components/systems/zone/zone-system.tsx +0 -0
  23. package/src/components/tools/ceiling/move-ceiling-tool.tsx +9 -2
  24. package/src/components/tools/fence/curve-fence-tool.tsx +4 -5
  25. package/src/components/tools/fence/fence-tool.tsx +2 -2
  26. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +11 -8
  27. package/src/components/tools/fence/move-fence-tool.tsx +13 -9
  28. package/src/components/tools/item/move-tool.tsx +3 -6
  29. package/src/components/tools/item/placement-math.ts +2 -4
  30. package/src/components/tools/item/placement-strategies.ts +11 -10
  31. package/src/components/tools/item/use-draft-node.ts +0 -1
  32. package/src/components/tools/item/use-placement-coordinator.tsx +9 -111
  33. package/src/components/tools/roof/move-roof-tool.tsx +7 -2
  34. package/src/components/tools/select/box-select-tool.tsx +12 -17
  35. package/src/components/tools/shared/segment-angle.ts +1 -1
  36. package/src/components/tools/tool-manager.tsx +12 -12
  37. package/src/components/tools/wall/curve-wall-tool.tsx +8 -6
  38. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +11 -8
  39. package/src/components/tools/wall/move-wall-tool.tsx +6 -4
  40. package/src/components/tools/wall/wall-drafting.ts +0 -0
  41. package/src/components/tools/wall/wall-tool.tsx +3 -3
  42. package/src/components/tools/zone/zone-tool.tsx +20 -5
  43. package/src/components/ui/action-menu/camera-actions.tsx +0 -0
  44. package/src/components/ui/action-menu/control-modes.tsx +7 -1
  45. package/src/components/ui/action-menu/furnish-tools.tsx +6 -92
  46. package/src/components/ui/action-menu/index.tsx +35 -86
  47. package/src/components/ui/action-menu/view-toggles.tsx +19 -31
  48. package/src/components/ui/command-palette/editor-commands.tsx +6 -4
  49. package/src/components/ui/command-palette/index.tsx +4 -255
  50. package/src/components/ui/controls/material-picker.tsx +8 -5
  51. package/src/components/ui/floating-level-selector.tsx +1 -1
  52. package/src/components/ui/helpers/helper-manager.tsx +5 -0
  53. package/src/components/ui/item-catalog/catalog-items.tsx +1742 -315
  54. package/src/components/ui/item-catalog/item-catalog.tsx +88 -46
  55. package/src/components/ui/level-duplicate-dialog.tsx +3 -5
  56. package/src/components/ui/panels/ceiling-panel.tsx +2 -3
  57. package/src/components/ui/panels/column-panel.tsx +62 -18
  58. package/src/components/ui/panels/door-panel.tsx +272 -265
  59. package/src/components/ui/panels/fence-panel.tsx +0 -5
  60. package/src/components/ui/panels/paint-panel.tsx +66 -41
  61. package/src/components/ui/panels/panel-manager.tsx +3 -32
  62. package/src/components/ui/panels/reference-panel.tsx +28 -13
  63. package/src/components/ui/panels/roof-panel.tsx +52 -2
  64. package/src/components/ui/panels/roof-segment-panel.tsx +0 -0
  65. package/src/components/ui/panels/slab-panel.tsx +0 -0
  66. package/src/components/ui/panels/spawn-panel.tsx +10 -4
  67. package/src/components/ui/panels/stair-panel.tsx +66 -14
  68. package/src/components/ui/panels/wall-panel.tsx +97 -1
  69. package/src/components/ui/panels/window-panel.tsx +13 -5
  70. package/src/components/ui/primitives/number-input.tsx +1 -1
  71. package/src/components/ui/primitives/sidebar.tsx +0 -0
  72. package/src/components/ui/sidebar/app-sidebar.tsx +0 -0
  73. package/src/components/ui/sidebar/icon-rail.tsx +0 -0
  74. package/src/components/ui/sidebar/panels/items-panel/index.tsx +330 -0
  75. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +0 -0
  76. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +0 -0
  77. package/src/components/ui/sidebar/panels/site-panel/index.tsx +4 -6
  78. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +0 -0
  79. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -7
  80. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +3 -1
  81. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -1
  82. package/src/components/ui/sidebar/panels/zone-panel/index.tsx +1 -1
  83. package/src/components/ui/slider.tsx +1 -1
  84. package/src/components/viewer-overlay.tsx +0 -0
  85. package/src/components/viewer-zone-system.tsx +0 -0
  86. package/src/hooks/use-auto-save.ts +14 -0
  87. package/src/hooks/use-keyboard.ts +10 -0
  88. package/src/index.tsx +8 -1
  89. package/src/lib/level-duplication.test.ts +0 -2
  90. package/src/lib/level-duplication.ts +1 -1
  91. package/src/lib/material-paint.ts +1 -1
  92. package/src/lib/roof-duplication.ts +1 -1
  93. package/src/lib/scene-bounds.ts +1 -1
  94. package/src/lib/scene.ts +0 -0
  95. package/src/lib/sfx-bus.ts +2 -0
  96. package/src/lib/sfx-player.ts +5 -5
  97. package/src/lib/stair-duplication.ts +2 -2
  98. package/src/store/use-editor.tsx +27 -59
  99. package/tsconfig.json +2 -1
  100. package/src/components/feedback-dialog.tsx +0 -265
  101. package/src/components/pascal-radio.tsx +0 -280
  102. package/src/components/preview-button.tsx +0 -16
  103. package/src/components/ui/viewer-toolbar.tsx +0 -436
@@ -125,7 +125,12 @@ export const FloorplanMeasurementsLayer = memo(function FloorplanMeasurementsLay
125
125
  return (
126
126
  <>
127
127
  {measurements.map((measurement) => (
128
- <g className={className} key={measurement.id} pointerEvents="none" style={{ userSelect: 'none' }}>
128
+ <g
129
+ className={className}
130
+ key={measurement.id}
131
+ pointerEvents="none"
132
+ style={{ userSelect: 'none' }}
133
+ >
129
134
  <FloorplanMeasurementLine
130
135
  dashed={measurement.dashedExtensions ?? true}
131
136
  isSelected={measurement.isSelected}
@@ -396,8 +396,8 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
396
396
  <>
397
397
  <polyline
398
398
  fill="none"
399
- points={formatSvgPolygonPoints(arrow.polyline)}
400
399
  pointerEvents="none"
400
+ points={formatSvgPolygonPoints(arrow.polyline)}
401
401
  stroke={straightAccent}
402
402
  strokeWidth="1.15"
403
403
  vectorEffect="non-scaling-stroke"
@@ -411,8 +411,8 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
411
411
  />
412
412
  <polygon
413
413
  fill={straightAccent}
414
- points={formatSvgPolygonPoints(arrow.head)}
415
414
  pointerEvents="none"
415
+ points={formatSvgPolygonPoints(arrow.head)}
416
416
  />
417
417
  </>
418
418
  ) : null}
@@ -438,8 +438,6 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
438
438
  }
439
439
  : undefined
440
440
  }
441
- onPointerEnter={canSelectStairs ? () => onStairHoverEnter(stair.id) : undefined}
442
- onPointerLeave={canSelectStairs ? () => onStairHoverChange(null) : undefined}
443
441
  onPointerDown={
444
442
  canFocusStairs && stairSelected
445
443
  ? (event) => {
@@ -449,6 +447,8 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
449
447
  }
450
448
  : undefined
451
449
  }
450
+ onPointerEnter={canSelectStairs ? () => onStairHoverEnter(stair.id) : undefined}
451
+ onPointerLeave={canSelectStairs ? () => onStairHoverChange(null) : undefined}
452
452
  pointerEvents={canSelectStairs ? undefined : 'none'}
453
453
  style={canSelectStairs ? { cursor } : undefined}
454
454
  >
@@ -456,8 +456,8 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
456
456
  <polygon
457
457
  fill="transparent"
458
458
  key={`${stair.id}:hit:${polygonIndex}`}
459
- points={formatSvgPolygonPoints(polygon)}
460
459
  pointerEvents={canSelectStairs ? 'all' : 'none'}
460
+ points={formatSvgPolygonPoints(polygon)}
461
461
  stroke="transparent"
462
462
  strokeLinejoin="round"
463
463
  strokeWidth={hitStrokeWidth}
@@ -75,7 +75,9 @@ const CeilingSelectionAffordance = ({
75
75
  ceiling: CeilingNode
76
76
  levelId: string
77
77
  }) => {
78
- const [levelObject, setLevelObject] = useState<Object3D | null>(() => sceneRegistry.nodes.get(levelId) ?? null)
78
+ const [levelObject, setLevelObject] = useState<Object3D | null>(
79
+ () => sceneRegistry.nodes.get(levelId) ?? null,
80
+ )
79
81
 
80
82
  const corners = useMemo(() => buildCornerBrackets(ceiling.polygon), [ceiling.polygon])
81
83
 
@@ -110,11 +112,7 @@ const CeilingSelectionAffordance = ({
110
112
  return createPortal(
111
113
  <group position={[0, (ceiling.height ?? 2.5) + BRACKET_Y_OFFSET, 0]}>
112
114
  {corners.map((corner, index) => (
113
- <CornerBracket
114
- ceiling={ceiling}
115
- corner={corner}
116
- key={`${ceiling.id}-corner-${index}`}
117
- />
115
+ <CornerBracket ceiling={ceiling} corner={corner} key={`${ceiling.id}-corner-${index}`} />
118
116
  ))}
119
117
  </group>,
120
118
  levelObject,
@@ -210,11 +208,7 @@ const BracketLeg = ({
210
208
  ]
211
209
 
212
210
  return (
213
- <mesh
214
- onClick={onClick}
215
- position={position}
216
- rotation={[0, angle, 0]}
217
- >
211
+ <mesh onClick={onClick} position={position} rotation={[0, angle, 0]}>
218
212
  <boxGeometry args={[length, BRACKET_HEIGHT, BRACKET_THICKNESS]} />
219
213
  <meshBasicMaterial color={color} depthWrite={false} opacity={opacity} transparent />
220
214
  </mesh>
@@ -234,7 +228,11 @@ function buildCornerBrackets(polygon: Array<[number, number]>): CornerBracketDat
234
228
 
235
229
  const incomingLength = Math.hypot(incomingVector[0], incomingVector[1])
236
230
  const outgoingLength = Math.hypot(outgoingVector[0], outgoingVector[1])
237
- const cornerStrength = 1 - Math.abs(incomingDirection[0] * outgoingDirection[0] + incomingDirection[1] * outgoingDirection[1])
231
+ const cornerStrength =
232
+ 1 -
233
+ Math.abs(
234
+ incomingDirection[0] * outgoingDirection[0] + incomingDirection[1] * outgoingDirection[1],
235
+ )
238
236
 
239
237
  return {
240
238
  corner,
@@ -6,7 +6,7 @@ import { useEffect, useRef } from 'react'
6
6
  * Imperatively toggles the Three.js visibility of roof objects based on the
7
7
  * editor selection — without causing React re-renders in RoofRenderer.
8
8
  *
9
- * When a roof-segment is selected:
9
+ * When a roof (or one of its segments) is selected:
10
10
  * - merged-roof mesh is hidden
11
11
  * - segments-wrapper group is shown (individual segments visible for editing)
12
12
  * - all children are marked dirty so RoofSystem rebuilds their geometry
@@ -68,7 +68,7 @@ export const StairEditSystem = () => {
68
68
  const segmentsWrapper = group.getObjectByName('segments-wrapper')
69
69
  const isActive = activeStairIds.has(stairId)
70
70
 
71
- if (mergedMesh) mergedMesh.visible = !isActive && !isCurved
71
+ if (mergedMesh) mergedMesh.visible = !(isActive || isCurved)
72
72
  if (segmentsWrapper) segmentsWrapper.visible = isActive && !isCurved
73
73
 
74
74
  if (stairNode?.children?.length) {
File without changes
@@ -1,13 +1,19 @@
1
1
  'use client'
2
2
 
3
- import { type AnyNodeId, emitter, type GridEvent, useScene, type CeilingNode } from '@pascal-app/core'
3
+ import {
4
+ type AnyNodeId,
5
+ type CeilingNode,
6
+ emitter,
7
+ type GridEvent,
8
+ useScene,
9
+ } from '@pascal-app/core'
4
10
  import { useViewer } from '@pascal-app/viewer'
5
11
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
12
+ import { BufferGeometry, DoubleSide, Path, Shape, ShapeGeometry, Vector3 } from 'three'
6
13
  import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
7
14
  import { sfxEmitter } from '../../../lib/sfx-bus'
8
15
  import useEditor from '../../../store/use-editor'
9
16
  import { CursorSphere } from '../shared/cursor-sphere'
10
- import { BufferGeometry, DoubleSide, Path, Shape, ShapeGeometry, Vector3 } from 'three'
11
17
 
12
18
  function snap(value: number) {
13
19
  return Math.round(value * 2) / 2
@@ -202,6 +208,7 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
202
208
  transparent
203
209
  />
204
210
  </mesh>
211
+ {/* @ts-ignore */}
205
212
  <line geometry={previewOutlineGeometry} position={[0, (node.height ?? 2.5) + 0.02, 0]}>
206
213
  <lineBasicMaterial color="#ffffff" depthWrite={false} opacity={0.95} transparent />
207
214
  </line>
@@ -83,11 +83,10 @@ export const CurveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
83
83
  ? event.localPosition[2]
84
84
  : snapScalarToGrid(event.localPosition[2], snapStep)
85
85
 
86
- const offsetFromMidpoint =
87
- -(
88
- (localX - chord.midpoint.x) * chord.normal.x +
89
- (localZ - chord.midpoint.y) * chord.normal.y
90
- )
86
+ const offsetFromMidpoint = -(
87
+ (localX - chord.midpoint.x) * chord.normal.x +
88
+ (localZ - chord.midpoint.y) * chord.normal.y
89
+ )
91
90
  const snappedOffset = shiftPressedRef.current
92
91
  ? offsetFromMidpoint
93
92
  : snapScalarToGrid(offsetFromMidpoint, snapStep)
@@ -297,7 +297,7 @@ export const FenceTool: React.FC = () => {
297
297
 
298
298
  return (
299
299
  <group>
300
- <CursorSphere ref={cursorRef} height={FENCE_PREVIEW_HEIGHT} />
300
+ <CursorSphere height={FENCE_PREVIEW_HEIGHT} ref={cursorRef} />
301
301
  <mesh layers={EDITOR_LAYER} ref={previewRef} renderOrder={1} visible={false}>
302
302
  <shapeGeometry />
303
303
  <meshBasicMaterial
@@ -338,7 +338,7 @@ function DraftMeasurementLabel({
338
338
  }) {
339
339
  return (
340
340
  <Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}>
341
- <div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono text-[11px] font-semibold text-foreground shadow-lg backdrop-blur-md">
341
+ <div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono font-semibold text-[11px] text-foreground shadow-lg backdrop-blur-md">
342
342
  {label}
343
343
  </div>
344
344
  </Html>
@@ -131,10 +131,12 @@ function getLinkedFenceSnapshots(args: {
131
131
  }
132
132
 
133
133
  if (
134
- !samePoint(node.start, originalStart) &&
135
- !samePoint(node.start, originalEnd) &&
136
- !samePoint(node.end, originalStart) &&
137
- !samePoint(node.end, originalEnd)
134
+ !(
135
+ samePoint(node.start, originalStart) ||
136
+ samePoint(node.start, originalEnd) ||
137
+ samePoint(node.end, originalStart) ||
138
+ samePoint(node.end, originalEnd)
139
+ )
138
140
  ) {
139
141
  continue
140
142
  }
@@ -303,8 +305,9 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
303
305
  }
304
306
 
305
307
  const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
306
- const hasChanged =
307
- !samePoint(preview.start, originalStart) || !samePoint(preview.end, originalEnd)
308
+ const hasChanged = !(
309
+ samePoint(preview.start, originalStart) && samePoint(preview.end, originalEnd)
310
+ )
308
311
 
309
312
  if (hasChanged && isWallLongEnough(preview.start, preview.end)) {
310
313
  wasCommitted = true
@@ -406,7 +409,7 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
406
409
  >
407
410
  <div className="translate-y-10">
408
411
  <div
409
- className={`whitespace-nowrap rounded-full border px-2 py-1 text-[11px] font-medium shadow-lg backdrop-blur-md transition-colors ${
412
+ className={`whitespace-nowrap rounded-full border px-2 py-1 font-medium text-[11px] shadow-lg backdrop-blur-md transition-colors ${
410
413
  altPressed
411
414
  ? 'border-amber-500/70 bg-amber-500/15 text-amber-100'
412
415
  : 'border-border/70 bg-background/90 text-foreground/80'
@@ -430,7 +433,7 @@ function EndpointAngleLabel({
430
433
  }) {
431
434
  return (
432
435
  <Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}>
433
- <div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono text-[11px] font-semibold text-foreground shadow-lg backdrop-blur-md">
436
+ <div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono font-semibold text-[11px] text-foreground shadow-lg backdrop-blur-md">
434
437
  {label}
435
438
  </div>
436
439
  </Html>
@@ -17,8 +17,8 @@ import type * as THREE from 'three'
17
17
  import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
18
18
  import { sfxEmitter } from '../../../lib/sfx-bus'
19
19
  import useEditor from '../../../store/use-editor'
20
- import { snapFenceDraftPoint } from './fence-drafting'
21
20
  import { CursorSphere } from '../shared/cursor-sphere'
21
+ import { snapFenceDraftPoint } from './fence-drafting'
22
22
 
23
23
  function samePoint(a: [number, number], b: [number, number]) {
24
24
  return a[0] === b[0] && a[1] === b[1]
@@ -50,10 +50,12 @@ function getLinkedFenceSnapshots(args: {
50
50
  }
51
51
 
52
52
  if (
53
- !samePoint(node.start, originalStart) &&
54
- !samePoint(node.start, originalEnd) &&
55
- !samePoint(node.end, originalStart) &&
56
- !samePoint(node.end, originalEnd)
53
+ !(
54
+ samePoint(node.start, originalStart) ||
55
+ samePoint(node.start, originalEnd) ||
56
+ samePoint(node.end, originalStart) ||
57
+ samePoint(node.end, originalEnd)
58
+ )
57
59
  ) {
58
60
  continue
59
61
  }
@@ -164,7 +166,9 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
164
166
  }
165
167
  }
166
168
 
167
- const applyNodePreview = (updates: Array<{ id: FenceNode['id']; start: [number, number]; end: [number, number] }>) => {
169
+ const applyNodePreview = (
170
+ updates: Array<{ id: FenceNode['id']; start: [number, number]; end: [number, number] }>,
171
+ ) => {
168
172
  useScene.getState().updateNodes(
169
173
  updates.map((entry) => ({
170
174
  id: entry.id as AnyNodeId,
@@ -275,13 +279,13 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
275
279
  emitter.on('tool:cancel', onCancel)
276
280
 
277
281
  return () => {
278
- if (!wasCommitted) {
279
- clearPreviewState()
280
- } else {
282
+ if (wasCommitted) {
281
283
  useLiveTransforms.getState().clear(nodeId)
282
284
  for (const linkedFence of linkedOriginalsRef.current) {
283
285
  useLiveTransforms.getState().clear(linkedFence.id)
284
286
  }
287
+ } else {
288
+ clearPreviewState()
285
289
  }
286
290
  useScene.temporal.getState().resume()
287
291
  emitter.off('grid:move', onGridMove)
@@ -90,9 +90,7 @@ function MoveItemContent({ movingNode }: { movingNode: ItemNode }) {
90
90
  return <>{cursor}</>
91
91
  }
92
92
 
93
- export const MoveTool: React.FC<{
94
- onSpawnMoved?: (nodeId: SpawnNode['id']) => void
95
- }> = ({ onSpawnMoved }) => {
93
+ export const MoveTool: React.FC = () => {
96
94
  const movingNode = useEditor((state) => state.movingNode)
97
95
 
98
96
  if (!movingNode) return null
@@ -100,15 +98,14 @@ export const MoveTool: React.FC<{
100
98
  return <MoveBuildingContent node={movingNode as BuildingNode} />
101
99
  if (movingNode.type === 'door') return <MoveDoorTool node={movingNode as DoorNode} />
102
100
  if (movingNode.type === 'window') return <MoveWindowTool node={movingNode as WindowNode} />
103
- if (movingNode.type === 'fence') return <MoveFenceTool node={movingNode as FenceNode} />
104
101
  if (movingNode.type === 'ceiling') return <MoveCeilingTool node={movingNode as CeilingNode} />
105
102
  if (movingNode.type === 'column') return <MoveColumnTool node={movingNode as ColumnNode} />
106
103
  if (movingNode.type === 'slab') return <MoveSlabTool node={movingNode as SlabNode} />
107
104
  if (movingNode.type === 'wall') return <MoveWallTool node={movingNode as WallNode} />
105
+ if (movingNode.type === 'fence') return <MoveFenceTool node={movingNode as FenceNode} />
108
106
  if (movingNode.type === 'roof' || movingNode.type === 'roof-segment')
109
107
  return <MoveRoofTool node={movingNode as RoofNode | RoofSegmentNode} />
110
- if (movingNode.type === 'spawn')
111
- return <MoveSpawnTool node={movingNode as SpawnNode} onCommitted={onSpawnMoved} />
108
+ if (movingNode.type === 'spawn') return <MoveSpawnTool node={movingNode as SpawnNode} />
112
109
  if (movingNode.type === 'stair' || movingNode.type === 'stair-segment')
113
110
  return <MoveRoofTool node={movingNode as StairNode | StairSegmentNode} />
114
111
  return <MoveItemContent movingNode={movingNode as ItemNode} />
@@ -10,9 +10,7 @@ function positiveModulo(value: number, divisor: number): number {
10
10
  }
11
11
 
12
12
  /**
13
- * Snaps a position to 0.5 grid, with an offset to align item edges to grid lines.
14
- * For items with dimensions like 2.5, the center would be at 1.25 from the edge,
15
- * which doesn't align with 0.5 grid. This adds an offset so edges align instead.
13
+ * Snaps a position to the active grid step, aligning item edges to grid lines.
16
14
  */
17
15
  export function snapToGrid(position: number, dimension: number, step = getGridSnapStep()): number {
18
16
  const halfDim = dimension / 2
@@ -21,7 +19,7 @@ export function snapToGrid(position: number, dimension: number, step = getGridSn
21
19
  }
22
20
 
23
21
  /**
24
- * Snap a value to 0.5 increments (used for wall-local positions).
22
+ * Snap a value to the active grid step (used for wall-local positions).
25
23
  */
26
24
  export function snapToHalf(value: number, step = getGridSnapStep()): number {
27
25
  return Math.round(value / step) * step
@@ -506,21 +506,22 @@ export const itemSurfaceStrategy = {
506
506
 
507
507
  const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z))
508
508
 
509
+ // Counter-rotate so the draft's world Y rotation stays continuous when
510
+ // the user drags onto a rotated surface item. The cursor wireframe
511
+ // already shows the user's intended world rotation; we just need to
512
+ // store the right local value relative to the new parent.
513
+ const surfaceQuat = new Quaternion()
514
+ surfaceMesh.getWorldQuaternion(surfaceQuat)
515
+ const surfaceWorldY = new Euler().setFromQuaternion(surfaceQuat, 'YXZ').y
516
+ const localRotationY = ctx.currentCursorRotationY - surfaceWorldY
517
+ const draftRotation = ctx.draftItem?.rotation ?? [0, 0, 0]
518
+
509
519
  return {
510
520
  stateUpdate: { surface: 'item-surface', surfaceItemId: surfaceItem.id },
511
521
  nodeUpdate: {
512
522
  position: [x, y, z],
513
523
  parentId: surfaceItem.id,
514
- rotation: [
515
- (ctx.draftItem?.rotation ?? [0, 0, 0])[0],
516
- (() => {
517
- const surfaceQuat = new Quaternion()
518
- surfaceMesh.getWorldQuaternion(surfaceQuat)
519
- const surfaceWorldY = new Euler().setFromQuaternion(surfaceQuat, 'YXZ').y
520
- return ctx.currentCursorRotationY - surfaceWorldY
521
- })(),
522
- (ctx.draftItem?.rotation ?? [0, 0, 0])[2],
523
- ] as [number, number, number],
524
+ rotation: [draftRotation[0], localRotationY, draftRotation[2]],
524
525
  },
525
526
  cursorRotationY: ctx.currentCursorRotationY,
526
527
  gridPosition: [x, y, z],
@@ -130,7 +130,6 @@ export function useDraftNode(): DraftNodeHandle {
130
130
  useScene.getState().updateNode(draft.id, {
131
131
  position: updateProps.position ?? draft.position,
132
132
  rotation: updateProps.rotation ?? draft.rotation,
133
- scale: updateProps.scale ?? draft.scale,
134
133
  side: updateProps.side ?? draft.side,
135
134
  metadata: updateProps.metadata ?? stripTransient(draft.metadata),
136
135
  parentId: parentId as string,
@@ -20,15 +20,12 @@ import { Html } from '@react-three/drei'
20
20
  import { useFrame } from '@react-three/fiber'
21
21
  import { useEffect, useMemo, useRef, useState } from 'react'
22
22
  import {
23
- Box3,
24
23
  BufferGeometry,
25
24
  Euler,
26
25
  Float32BufferAttribute,
27
26
  type Group,
28
27
  type LineSegments,
29
- Matrix4,
30
28
  type Mesh,
31
- type Object3D,
32
29
  PlaneGeometry,
33
30
  Quaternion,
34
31
  Vector3,
@@ -117,79 +114,6 @@ function expandBoundsToGrid(
117
114
  }
118
115
  }
119
116
 
120
- function getPreviewBoundsFromObject(object: Object3D | null): PreviewBounds | null {
121
- if (!object) return null
122
-
123
- object.updateWorldMatrix(true, true)
124
-
125
- const inverseRootMatrix = new Matrix4().copy(object.matrixWorld).invert()
126
- const localMatrix = new Matrix4()
127
- const localBounds = new Box3()
128
- const scratchBounds = new Box3()
129
- const hasBounds = { current: false }
130
- const registeredNodeObjects = new Set(sceneRegistry.nodes.values())
131
-
132
- const expandBounds = (child: Object3D) => {
133
- if (child !== object && registeredNodeObjects.has(child)) {
134
- return
135
- }
136
-
137
- const mesh = child as Object3D & {
138
- isMesh?: boolean
139
- name?: string
140
- geometry?: {
141
- boundingBox: Box3 | null
142
- computeBoundingBox?: () => void
143
- }
144
- }
145
-
146
- if (mesh.isMesh && mesh.name !== 'cutout' && mesh.geometry) {
147
- if (!mesh.geometry.boundingBox && mesh.geometry.computeBoundingBox) {
148
- mesh.geometry.computeBoundingBox()
149
- }
150
-
151
- if (mesh.geometry.boundingBox) {
152
- localMatrix.copy(inverseRootMatrix).multiply(mesh.matrixWorld)
153
- scratchBounds.copy(mesh.geometry.boundingBox).applyMatrix4(localMatrix)
154
- if (Number.isFinite(scratchBounds.min.x) && Number.isFinite(scratchBounds.max.x)) {
155
- if (!hasBounds.current) {
156
- localBounds.copy(scratchBounds)
157
- hasBounds.current = true
158
- } else {
159
- localBounds.union(scratchBounds)
160
- }
161
- }
162
- }
163
- }
164
-
165
- for (const grandchild of child.children) {
166
- expandBounds(grandchild)
167
- }
168
- }
169
-
170
- for (const child of object.children) {
171
- expandBounds(child)
172
- }
173
-
174
- if (!hasBounds.current) return null
175
-
176
- const size = new Vector3()
177
- const center = new Vector3()
178
- localBounds.getSize(size)
179
- localBounds.getCenter(center)
180
-
181
- if (size.x <= 0 || size.y <= 0 || size.z <= 0) {
182
- return null
183
- }
184
-
185
- return {
186
- min: [localBounds.min.x, localBounds.min.y, localBounds.min.z],
187
- max: [localBounds.max.x, localBounds.max.y, localBounds.max.z],
188
- dimensions: [size.x, size.y, size.z],
189
- center: [center.x, center.y, center.z],
190
- }
191
- }
192
-
193
117
  function getFallbackPreviewBounds(
194
118
  item: import('@pascal-app/core').ItemNode | null,
195
119
  asset: AssetInput,
@@ -366,7 +290,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
366
290
  )
367
291
  const shiftFreeRef = useRef(false)
368
292
  const previewBoundsSignatureRef = useRef<string | null>(null)
369
- const meshPreviewAppliedRef = useRef(false)
370
293
  const [dimensionBounds, setDimensionBounds] = useState<PreviewBounds | null>(null)
371
294
 
372
295
  // Store config callbacks in refs to avoid re-running effect when they change
@@ -503,7 +426,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
503
426
  useEffect(() => {
504
427
  if (!asset) return
505
428
  useScene.temporal.getState().pause()
506
- meshPreviewAppliedRef.current = false
507
429
 
508
430
  const validators = { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling }
509
431
 
@@ -1264,21 +1186,17 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1264
1186
  window.addEventListener('contextmenu', onContextMenu)
1265
1187
 
1266
1188
  // ---- Bounding box geometry ----
1189
+ // Always derive the wireframe from `asset.dimensions × scale` rather than
1190
+ // the rendered mesh bounds. Asset dimensions describe the item's footprint
1191
+ // (e.g. only the trunk for a palm tree), while the mesh bbox would include
1192
+ // foliage or other visual overhang the snap logic intentionally ignores.
1267
1193
 
1268
1194
  const draft = draftNode.current
1269
- const fallbackBounds = expandBoundsToGrid(
1195
+ const previewBounds = expandBoundsToGrid(
1270
1196
  getFallbackPreviewBounds(draft, asset, asset.attachTo),
1271
1197
  asset.attachTo,
1272
1198
  gridSnapStep,
1273
1199
  )
1274
- const previewBounds = draft
1275
- ? expandBoundsToGrid(
1276
- getPreviewBoundsFromObject(sceneRegistry.nodes.get(draft.id) ?? null) ??
1277
- getFallbackPreviewBounds(draft, asset, asset.attachTo),
1278
- asset.attachTo,
1279
- gridSnapStep,
1280
- )
1281
- : fallbackBounds
1282
1200
  updatePreviewGeometry(previewBounds)
1283
1201
  updateDimensionGuides(previewBounds)
1284
1202
 
@@ -1324,7 +1242,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1324
1242
 
1325
1243
  return () => {
1326
1244
  tearingDown = true
1327
- meshPreviewAppliedRef.current = false
1328
1245
  unsubDraftWatch()
1329
1246
  // Clear live transform for any remaining draft
1330
1247
  if (draftNode.current) {
@@ -1358,17 +1275,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1358
1275
  useEffect(() => {
1359
1276
  if (!asset) return
1360
1277
  const draft = draftNode.current
1361
- const fallbackBounds = expandBoundsToGrid(
1278
+ const previewBounds = expandBoundsToGrid(
1362
1279
  getFallbackPreviewBounds(draft, asset, asset.attachTo),
1363
1280
  asset.attachTo,
1364
1281
  gridSnapStep,
1365
1282
  )
1366
- const meshBounds = draft
1367
- ? getPreviewBoundsFromObject(sceneRegistry.nodes.get(draft.id) ?? null)
1368
- : null
1369
- const previewBounds = meshBounds
1370
- ? expandBoundsToGrid(meshBounds, asset.attachTo, gridSnapStep)
1371
- : fallbackBounds
1372
1283
  updatePreviewGeometry(previewBounds)
1373
1284
  updateDimensionGuides(previewBounds)
1374
1285
  }, [gridSnapStep, asset, draftNode])
@@ -1388,19 +1299,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1388
1299
  if (!draftNode.current) return
1389
1300
  const mesh = sceneRegistry.nodes.get(draftNode.current.id)
1390
1301
  if (!mesh) return
1391
- if (!meshPreviewAppliedRef.current) {
1392
- const previewBounds = getPreviewBoundsFromObject(mesh)
1393
- if (previewBounds) {
1394
- const expandedBounds = expandBoundsToGrid(
1395
- previewBounds,
1396
- asset.attachTo,
1397
- useEditor.getState().gridSnapStep,
1398
- )
1399
- updatePreviewGeometry(expandedBounds)
1400
- updateDimensionGuides(expandedBounds)
1401
- meshPreviewAppliedRef.current = true
1402
- }
1403
- }
1404
1302
 
1405
1303
  // Hide wall/ceiling-attached items when between surfaces (only cursor visible)
1406
1304
  if (asset.attachTo && placementState.current.surface === 'floor') {
@@ -1490,22 +1388,22 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
1490
1388
  const measurementContent = (
1491
1389
  <>
1492
1390
  <lineSegments
1493
- layers={EDITOR_LAYER}
1494
1391
  geometry={initialWidthGuideGeometry}
1392
+ layers={EDITOR_LAYER}
1495
1393
  material={measurementMaterial}
1496
1394
  ref={measurementWidthRef}
1497
1395
  renderOrder={998}
1498
1396
  />
1499
1397
  <lineSegments
1500
- layers={EDITOR_LAYER}
1501
1398
  geometry={initialDepthGuideGeometry}
1399
+ layers={EDITOR_LAYER}
1502
1400
  material={measurementMaterial}
1503
1401
  ref={measurementDepthRef}
1504
1402
  renderOrder={998}
1505
1403
  />
1506
1404
  <lineSegments
1507
- layers={EDITOR_LAYER}
1508
1405
  geometry={initialHeightGuideGeometry}
1406
+ layers={EDITOR_LAYER}
1509
1407
  material={measurementMaterial}
1510
1408
  ref={measurementHeightRef}
1511
1409
  renderOrder={998}
@@ -158,7 +158,9 @@ export const MoveRoofTool: React.FC<{
158
158
 
159
159
  const localToWorldPoint = (localPoint: WallPlanPoint, y: number): [number, number, number] => {
160
160
  if (buildingObj) {
161
- const worldPoint = buildingObj.localToWorld(new THREE.Vector3(localPoint[0], y, localPoint[1]))
161
+ const worldPoint = buildingObj.localToWorld(
162
+ new THREE.Vector3(localPoint[0], y, localPoint[1]),
163
+ )
162
164
  return [worldPoint.x, worldPoint.y, worldPoint.z]
163
165
  }
164
166
 
@@ -211,7 +213,10 @@ export const MoveRoofTool: React.FC<{
211
213
  })
212
214
  const [gridX, , gridZ] = localToWorldPoint(snappedLocal, y)
213
215
 
214
- if (previousGridPosRef.current && (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])) {
216
+ if (
217
+ previousGridPosRef.current &&
218
+ (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
219
+ ) {
215
220
  sfxEmitter.emit('sfx:grid-snap')
216
221
  }
217
222