@pascal-app/editor 0.9.1 → 0.9.2

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 (262) hide show
  1. package/package.json +12 -9
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +10 -2
  3. package/src/components/editor/bake-exporter.tsx +47 -0
  4. package/src/components/editor/custom-camera-controls.tsx +63 -25
  5. package/src/components/editor/editor-layout-mobile.tsx +5 -0
  6. package/src/components/editor/editor-layout-v2.tsx +18 -0
  7. package/src/components/editor/export-manager.tsx +71 -63
  8. package/src/components/editor/fence-tangent-lines-3d.tsx +87 -0
  9. package/src/components/editor/first-person/build-collider-world.test.ts +36 -3
  10. package/src/components/editor/first-person/build-collider-world.ts +32 -5
  11. package/src/components/editor/first-person-controls.tsx +118 -7
  12. package/src/components/editor/floating-action-menu.tsx +410 -33
  13. package/src/components/editor/floating-building-action-menu.tsx +1 -1
  14. package/src/components/editor/floorplan-background-selection.test.ts +58 -0
  15. package/src/components/editor/floorplan-background-selection.ts +3 -2
  16. package/src/components/editor/floorplan-panel.tsx +1804 -849
  17. package/src/components/editor/grid.tsx +166 -39
  18. package/src/components/editor/group-actions.ts +457 -0
  19. package/src/components/editor/group-floating-action-menu.tsx +130 -0
  20. package/src/components/editor/group-move-3d.ts +397 -0
  21. package/src/components/editor/group-rotate-handle.tsx +102 -64
  22. package/src/components/editor/group-selection-box-3d.tsx +173 -0
  23. package/src/components/editor/group-transform-shared.test.ts +192 -1
  24. package/src/components/editor/group-transform-shared.ts +240 -7
  25. package/src/components/editor/handles/handle-arrow.tsx +81 -23
  26. package/src/components/editor/handles/use-handle-drag.ts +37 -4
  27. package/src/components/editor/index.tsx +248 -27
  28. package/src/components/editor/measurement-pill.tsx +62 -22
  29. package/src/components/editor/node-action-menu.tsx +14 -1
  30. package/src/components/editor/node-arrow-handles.tsx +366 -173
  31. package/src/components/editor/opening-guides-3d-layer.tsx +144 -0
  32. package/src/components/editor/quick-measurement-card.tsx +70 -0
  33. package/src/components/editor/quick-measurement-hud.tsx +28 -0
  34. package/src/components/editor/riser-diagram-panel.tsx +137 -0
  35. package/src/components/editor/selection-manager.tsx +812 -466
  36. package/src/components/editor/site-edge-labels.tsx +4 -4
  37. package/src/components/editor/slab-hole-highlights.tsx +13 -5
  38. package/src/components/editor/snapshot-capture-overlay.tsx +255 -115
  39. package/src/components/editor/three-context-bridge.ts +22 -0
  40. package/src/components/editor/thumbnail-generator.tsx +136 -43
  41. package/src/components/editor/use-floorplan-background-placement.ts +113 -45
  42. package/src/components/editor/use-floorplan-scene-data.ts +5 -5
  43. package/src/components/editor/use-mesh-settle-epoch.ts +26 -0
  44. package/src/components/editor/wall-measurement-label.tsx +3 -13
  45. package/src/components/editor/wall-move-side-handles.tsx +36 -21
  46. package/src/components/editor/wall-snap-beacon-layer.tsx +160 -6
  47. package/src/components/editor-2d/floorplan-action-menu-layer.tsx +9 -5
  48. package/src/components/editor-2d/floorplan-cursor-indicator-overlay.tsx +1 -1
  49. package/src/components/editor-2d/floorplan-group-action-menu.tsx +87 -0
  50. package/src/components/editor-2d/floorplan-group-move.tsx +701 -0
  51. package/src/components/editor-2d/floorplan-measurement-tool-layer.test.ts +113 -0
  52. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1656 -0
  53. package/src/components/editor-2d/floorplan-quick-measure-layer.tsx +212 -0
  54. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +169 -4
  55. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +300 -59
  56. package/src/components/editor-2d/floorplan-snap-beacon-layer.tsx +5 -3
  57. package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +15 -0
  58. package/src/components/editor-2d/renderers/floorplan-label-angle.ts +14 -0
  59. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +4 -1
  60. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +308 -0
  61. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +1704 -342
  62. package/src/components/editor-2d/renderers/floorplan-voronoi-layer.tsx +128 -0
  63. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +419 -60
  64. package/src/components/systems/ceiling/ceiling-system.tsx +97 -4
  65. package/src/components/systems/roof/roof-edit-system.tsx +1768 -13
  66. package/src/components/systems/selection-affordance-manager.tsx +38 -0
  67. package/src/components/systems/zone/zone-label-editor-system.tsx +14 -1
  68. package/src/components/systems/zone/zone-system.tsx +42 -13
  69. package/src/components/tools/elevator/elevator-tool.tsx +34 -8
  70. package/src/components/tools/elevator/move-elevator-tool.tsx +13 -2
  71. package/src/components/tools/fence/fence-drafting.ts +80 -8
  72. package/src/components/tools/item/move-tool.tsx +17 -13
  73. package/src/components/tools/item/placement-math.test.ts +13 -1
  74. package/src/components/tools/item/placement-math.ts +28 -2
  75. package/src/components/tools/item/placement-strategies.ts +246 -5
  76. package/src/components/tools/item/placement-types.ts +13 -1
  77. package/src/components/tools/item/use-draft-node.ts +31 -1
  78. package/src/components/tools/item/use-placement-coordinator.tsx +761 -121
  79. package/src/components/tools/registry/move-registry-node-tool.tsx +561 -99
  80. package/src/components/tools/roof/roof-tool.tsx +320 -94
  81. package/src/components/tools/select/box-select-state.ts +18 -7
  82. package/src/components/tools/select/box-select-tool.tsx +176 -40
  83. package/src/components/tools/select/marquee-geometry.test.ts +161 -0
  84. package/src/components/tools/select/marquee-geometry.ts +155 -0
  85. package/src/components/tools/select/plane-box-select-tool.tsx +1 -8
  86. package/src/components/tools/select/select-candidates.ts +1 -1
  87. package/src/components/tools/shared/cursor-sphere.tsx +62 -26
  88. package/src/components/tools/shared/drag-bounding-box.tsx +28 -4
  89. package/src/components/tools/shared/facing-indicator.tsx +87 -0
  90. package/src/components/tools/shared/facing-pose-indicator.tsx +54 -0
  91. package/src/components/tools/shared/placement-box.tsx +183 -1
  92. package/src/components/tools/shared/polygon-editor.tsx +276 -35
  93. package/src/components/tools/site/site-boundary-editor.tsx +88 -36
  94. package/src/components/tools/stair/stair-defaults.ts +1 -0
  95. package/src/components/tools/stair/stair-tool.tsx +82 -11
  96. package/src/components/tools/tool-manager.tsx +86 -25
  97. package/src/components/tools/wall/wall-drafting.test.ts +330 -0
  98. package/src/components/tools/wall/wall-drafting.ts +180 -103
  99. package/src/components/tools/wall/wall-snap-geometry.test.ts +44 -0
  100. package/src/components/tools/wall/wall-snap-geometry.ts +67 -6
  101. package/src/components/tools/zone/zone-boundary-editor.tsx +5 -1
  102. package/src/components/tools/zone/zone-tool.tsx +82 -88
  103. package/src/components/ui/action-menu/camera-actions.tsx +24 -17
  104. package/src/components/ui/action-menu/control-modes.tsx +40 -93
  105. package/src/components/ui/action-menu/furnish-tools.tsx +5 -5
  106. package/src/components/ui/action-menu/index.tsx +2 -4
  107. package/src/components/ui/action-menu/measurement-control.tsx +188 -0
  108. package/src/components/ui/action-menu/structure-tools.tsx +22 -13
  109. package/src/components/ui/action-menu/view-toggles.tsx +42 -90
  110. package/src/components/ui/command-palette/editor-commands.tsx +2 -2
  111. package/src/components/ui/command-palette/index.tsx +4 -3
  112. package/src/components/ui/controls/material-paint-panel.tsx +86 -17
  113. package/src/components/ui/controls/material-picker.tsx +83 -152
  114. package/src/components/ui/controls/material-properties-editor.tsx +108 -0
  115. package/src/components/ui/controls/metric-control.tsx +118 -44
  116. package/src/components/ui/controls/scene-material-list.tsx +247 -0
  117. package/src/components/ui/controls/slider-control.tsx +88 -38
  118. package/src/components/ui/floating-level-selector.tsx +1 -1
  119. package/src/components/ui/helpers/building-helper.tsx +10 -23
  120. package/src/components/ui/helpers/contextual-helper-panel.tsx +428 -0
  121. package/src/components/ui/helpers/helper-manager.tsx +227 -14
  122. package/src/components/ui/helpers/item-helper.tsx +28 -32
  123. package/src/components/ui/helpers/registered-tool-helper.tsx +45 -11
  124. package/src/components/ui/helpers/roof-helper.tsx +10 -12
  125. package/src/components/ui/icon-ref.tsx +47 -0
  126. package/src/components/ui/item-catalog/catalog-items.tsx +39 -1
  127. package/src/components/ui/item-catalog/item-catalog.tsx +13 -36
  128. package/src/components/ui/level-duplicate-dialog.tsx +1 -1
  129. package/src/components/ui/panels/node-display.ts +17 -17
  130. package/src/components/ui/panels/panel-manager.tsx +22 -13
  131. package/src/components/ui/panels/panel-wrapper.tsx +24 -3
  132. package/src/components/ui/panels/parametric-inspector.tsx +15 -2
  133. package/src/components/ui/panels/reference-panel.tsx +54 -19
  134. package/src/components/ui/primitives/shortcut-token.tsx +39 -3
  135. package/src/components/ui/primitives/sidebar.tsx +1 -0
  136. package/src/components/ui/sidebar/app-sidebar.tsx +5 -1
  137. package/src/components/ui/sidebar/icon-rail.tsx +50 -31
  138. package/src/components/ui/sidebar/panels/plugins-panel.tsx +218 -0
  139. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +71 -40
  140. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +60 -6
  141. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +12 -4
  142. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +4 -3
  143. package/src/components/ui/sidebar/panels/site-panel/chimney-tree-node.tsx +10 -7
  144. package/src/components/ui/sidebar/panels/site-panel/column-tree-node.tsx +1 -1
  145. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +7 -2
  146. package/src/components/ui/sidebar/panels/site-panel/dormer-tree-node.tsx +10 -7
  147. package/src/components/ui/sidebar/panels/site-panel/elevator-tree-node.tsx +1 -1
  148. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +1 -1
  149. package/src/components/ui/sidebar/panels/site-panel/gutter-tree-node.tsx +10 -7
  150. package/src/components/ui/sidebar/panels/site-panel/index.tsx +40 -17
  151. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +31 -17
  152. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
  153. package/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +72 -25
  154. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +7 -3
  155. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +12 -8
  156. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +4 -3
  157. package/src/components/ui/sidebar/panels/site-panel/solar-panel-tree-node.tsx +10 -7
  158. package/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx +1 -1
  159. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +2 -2
  160. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +44 -3
  161. package/src/components/ui/sidebar/panels/site-panel/tree-structure.ts +36 -0
  162. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +1 -1
  163. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +7 -2
  164. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +3 -3
  165. package/src/components/ui/sidebar/tab-bar.tsx +42 -28
  166. package/src/components/ui/sidebar/use-plugin-panels.tsx +132 -0
  167. package/src/components/ui/snap-target-badge.test.tsx +40 -0
  168. package/src/components/ui/snap-target-badge.tsx +88 -0
  169. package/src/components/viewer-overlay.tsx +85 -10
  170. package/src/components/viewer-zone-system.tsx +6 -1
  171. package/src/hooks/use-auto-save.test.ts +14 -0
  172. package/src/hooks/use-auto-save.ts +54 -7
  173. package/src/hooks/use-ceiling-events.ts +3 -2
  174. package/src/hooks/use-drag-action.ts +4 -1
  175. package/src/hooks/use-keyboard.ts +385 -40
  176. package/src/index.tsx +219 -6
  177. package/src/lib/active-placement-surface.ts +35 -0
  178. package/src/lib/ceiling-plan-snap.ts +24 -0
  179. package/src/lib/contextual-help.test.ts +112 -0
  180. package/src/lib/contextual-help.ts +144 -0
  181. package/src/lib/continuation.ts +64 -0
  182. package/src/lib/direct-manipulation.test.ts +192 -0
  183. package/src/lib/direct-manipulation.ts +109 -0
  184. package/src/lib/editor-api.ts +23 -35
  185. package/src/lib/floorplan/apply-alignment.test.ts +25 -0
  186. package/src/lib/floorplan/apply-alignment.ts +18 -12
  187. package/src/lib/floorplan/floorplan-export.tsx +364 -0
  188. package/src/lib/floorplan/geometry.ts +53 -0
  189. package/src/lib/floorplan/index.ts +3 -0
  190. package/src/lib/floorplan/items.ts +5 -2
  191. package/src/lib/floorplan/plan-coords.ts +21 -0
  192. package/src/lib/fresh-planar-placement.test.ts +240 -3
  193. package/src/lib/fresh-planar-placement.ts +68 -1
  194. package/src/lib/glb-export.test.ts +441 -0
  195. package/src/lib/glb-export.ts +874 -0
  196. package/src/lib/history.ts +9 -0
  197. package/src/lib/interaction/hot-set.test.ts +133 -0
  198. package/src/lib/interaction/hot-set.ts +67 -0
  199. package/src/lib/interaction/overlay-policy.test.ts +51 -0
  200. package/src/lib/interaction/overlay-policy.ts +59 -0
  201. package/src/lib/interaction/scope.ts +211 -0
  202. package/src/lib/level-selection.ts +2 -2
  203. package/src/lib/material-paint.ts +36 -49
  204. package/src/lib/measurement-kind.test.ts +30 -0
  205. package/src/lib/measurement-kind.ts +19 -0
  206. package/src/lib/measurement-label.test.ts +47 -0
  207. package/src/lib/measurement-label.ts +68 -0
  208. package/src/lib/measurement-parser.ts +116 -0
  209. package/src/lib/measurements.test.ts +183 -0
  210. package/src/lib/measurements.ts +199 -0
  211. package/src/lib/paint-scope.test.ts +454 -0
  212. package/src/lib/paint-scope.ts +461 -0
  213. package/src/lib/placement-drag-release.ts +23 -0
  214. package/src/lib/planar-cursor-placement.test.ts +57 -0
  215. package/src/lib/plugin-panels.test.ts +19 -0
  216. package/src/lib/plugin-panels.ts +91 -0
  217. package/src/lib/quick-measurement.test.ts +77 -0
  218. package/src/lib/quick-measurement.ts +109 -0
  219. package/src/lib/roof-duplication.ts +6 -6
  220. package/src/lib/roof-hover-outline-proxy.ts +22 -0
  221. package/src/lib/roof-wall-hit.ts +164 -0
  222. package/src/lib/scene-clipboard.test.ts +196 -0
  223. package/src/lib/scene-clipboard.ts +73 -8
  224. package/src/lib/scene.ts +28 -5
  225. package/src/lib/selection-routing.test.ts +298 -0
  226. package/src/lib/selection-routing.ts +175 -0
  227. package/src/lib/sfx/index.ts +1 -0
  228. package/src/lib/sfx/movement-tick.test.ts +65 -0
  229. package/src/lib/sfx/movement-tick.ts +18 -0
  230. package/src/lib/sfx-bus.ts +50 -13
  231. package/src/lib/sfx-player.test.ts +124 -0
  232. package/src/lib/sfx-player.ts +111 -66
  233. package/src/lib/slab-plan-snap.test.ts +93 -0
  234. package/src/lib/slab-plan-snap.ts +108 -0
  235. package/src/lib/snapping-mode.test.ts +138 -0
  236. package/src/lib/snapping-mode.ts +179 -0
  237. package/src/lib/stair-duplication.ts +4 -4
  238. package/src/lib/surface-plan-snap.test.ts +71 -0
  239. package/src/lib/surface-plan-snap.ts +256 -0
  240. package/src/lib/use-linear-display.ts +40 -0
  241. package/src/lib/world-grid-snap.ts +37 -5
  242. package/src/store/use-direct-manipulation-feedback.ts +20 -0
  243. package/src/store/use-editor.tsx +484 -114
  244. package/src/store/use-facing-pose.ts +44 -0
  245. package/src/store/use-fence-curve-draft.ts +21 -0
  246. package/src/store/use-floorplan-draft-preview.ts +101 -0
  247. package/src/store/use-floorplan-marquee.ts +50 -0
  248. package/src/store/use-interaction-scope.test.ts +186 -0
  249. package/src/store/use-interaction-scope.ts +132 -0
  250. package/src/store/use-measurement-draft.test.ts +530 -0
  251. package/src/store/use-measurement-draft.ts +543 -0
  252. package/src/store/use-opening-guides.ts +41 -0
  253. package/src/store/use-placement-preview.ts +11 -3
  254. package/src/store/use-quick-measurement-hud.test.ts +53 -0
  255. package/src/store/use-quick-measurement-hud.ts +77 -0
  256. package/src/store/use-segment-draft-chain.ts +28 -0
  257. package/src/store/use-stair-build-preview.ts +43 -0
  258. package/src/store/use-wall-snap-indicator.ts +2 -0
  259. package/src/components/editor/first-person/bvh-ecctrl.tsx +0 -860
  260. package/src/components/editor/group-move-handle.tsx +0 -316
  261. package/src/components/ui/panels/paint-panel.tsx +0 -163
  262. package/src/lib/level-name.ts +0 -11
@@ -0,0 +1,38 @@
1
+ 'use client'
2
+
3
+ import { type AnyNodeId, useScene } from '@pascal-app/core'
4
+ import { useViewer } from '@pascal-app/viewer'
5
+ import { type ComponentType, Suspense, useMemo } from 'react'
6
+ import { getRegistryAffordanceTool } from '../tools/shared/affordance-dispatch'
7
+
8
+ /**
9
+ * Editor-mounted dispatcher for a kind's selection-time editing UI.
10
+ *
11
+ * Some kinds expose drag-to-edit affordances that should appear only
12
+ * while a single node of that kind is selected — duct / pipe / lineset
13
+ * path-point handles, fitting Alt-axis-cycling listeners. These read
14
+ * `useEditor` (grid snap step, rotation axis) and render the editor's
15
+ * `DimensionPill`, so they must NOT ride in `def.system` (which the
16
+ * viewer package mounts for the read-only route). The kind declares the
17
+ * component under `def.affordanceTools.selection` and this manager —
18
+ * mounted inside the editor only — loads it for the selected kind.
19
+ */
20
+ export function SelectionAffordanceManager() {
21
+ const selectedIds = useViewer((s) => s.selection.selectedIds)
22
+ const selectedKind = useScene((s) => {
23
+ if (selectedIds.length !== 1) return null
24
+ return s.nodes[selectedIds[0] as AnyNodeId]?.type ?? null
25
+ })
26
+
27
+ const Component = useMemo<ComponentType | null>(() => {
28
+ if (!selectedKind) return null
29
+ return getRegistryAffordanceTool(selectedKind, 'selection')
30
+ }, [selectedKind])
31
+
32
+ if (!Component) return null
33
+ return (
34
+ <Suspense fallback={null}>
35
+ <Component />
36
+ </Suspense>
37
+ )
38
+ }
@@ -6,8 +6,10 @@ import { Check, Pencil } from 'lucide-react'
6
6
  import { useCallback, useEffect, useRef, useState } from 'react'
7
7
  import { createPortal } from 'react-dom'
8
8
  import { useShallow } from 'zustand/react/shallow'
9
+ import { resolveOverlayPolicy } from '../../../lib/interaction/overlay-policy'
9
10
  import { sfxEmitter } from '../../../lib/sfx-bus'
10
11
  import useEditor from '../../../store/use-editor'
12
+ import useInteractionScope from '../../../store/use-interaction-scope'
11
13
 
12
14
  // ─── Per-zone label editor ────────────────────────────────────────────────────
13
15
 
@@ -19,6 +21,10 @@ function ZoneLabelEditor({ zoneId }: { zoneId: ZoneNode['id'] }) {
19
21
  const selectedZoneId = useViewer((s) => s.selection.zoneId)
20
22
  const hoveredId = useViewer((s) => s.hoveredId)
21
23
  const mode = useEditor((s) => s.mode)
24
+ // During an active interaction the zone label is a context badge that steps
25
+ // back: faded + non-interactive so it can't be hovered/clicked mid-action.
26
+ const scope = useInteractionScope((s) => s.scope)
27
+ const labelStepBack = resolveOverlayPolicy(scope).contextBadges === 'faded'
22
28
  const isSelected = selectedZoneId === zoneId
23
29
  const isDeleteHovered = mode === 'delete' && hoveredId === zoneId
24
30
  const [editing, setEditing] = useState(false)
@@ -149,7 +155,8 @@ function ZoneLabelEditor({ zoneId }: { zoneId: ZoneNode['id'] }) {
149
155
  fontSize: 14,
150
156
  fontFamily: 'sans-serif',
151
157
  userSelect: 'none',
152
- pointerEvents: 'auto',
158
+ pointerEvents: labelStepBack ? 'none' : 'auto',
159
+ opacity: labelStepBack ? 0.4 : undefined,
153
160
  display: 'inline-flex',
154
161
  alignItems: 'center',
155
162
  gap: 4,
@@ -309,6 +316,12 @@ export function ZoneLabelEditorSystem() {
309
316
  .map((n) => n.id as ZoneNode['id']),
310
317
  ),
311
318
  )
319
+ // The zone renderer unmounts its <Html> label when zones are hidden —
320
+ // unmount the portals with it, or each editor would hold (and rAF-poll for)
321
+ // a detached label element.
322
+ const showZones = useViewer((s) => s.showZones)
323
+
324
+ if (!showZones) return null
312
325
 
313
326
  return (
314
327
  <>
@@ -1,21 +1,46 @@
1
1
  import { sceneRegistry, useScene, type ZoneNode } from '@pascal-app/core'
2
2
  import { useViewer } from '@pascal-app/viewer'
3
3
  import { useFrame } from '@react-three/fiber'
4
+ import { useEffect } from 'react'
4
5
  import { type Group, MathUtils, type Mesh } from 'three'
5
6
  import type { MeshBasicNodeMaterial } from 'three/webgpu'
7
+ import { resolveOverlayPolicy } from '../../../lib/interaction/overlay-policy'
6
8
  import useEditor from '../../../store/use-editor'
9
+ import useInteractionScope from '../../../store/use-interaction-scope'
7
10
 
8
11
  // Disable raycasting on zone geometry so clicks pass through to items underneath.
9
12
  // Zone selection in the editor is handled exclusively via the HTML label overlay.
10
13
  const noopRaycast = () => {}
11
14
 
12
15
  export const ZoneSystem = () => {
16
+ // Outside the zones layer (or during snapshot capture) zones unmount
17
+ // entirely — meshes AND drei <Html> labels, which cost per-frame matrix work
18
+ // + live DOM even at opacity 0. The renderer reads this viewer flag; the
19
+ // unmount cleanup restores the default so preview / first-person surfaces
20
+ // (which swap this system for ViewerZoneSystem) keep their labels.
21
+ const structureLayerState = useEditor((s) => s.structureLayer)
22
+ const isCaptureModeState = useEditor((s) => s.isCaptureMode)
23
+ useEffect(() => {
24
+ useViewer.getState().setShowZones(structureLayerState === 'zones' && !isCaptureModeState)
25
+ return () => useViewer.getState().setShowZones(true)
26
+ }, [structureLayerState, isCaptureModeState])
27
+
13
28
  useFrame((_, delta) => {
29
+ if (!useViewer.getState().showZones) return
30
+
14
31
  const structureLayer = useEditor.getState().structureLayer
15
32
  const editorMode = useEditor.getState().mode
16
33
  const selectedLevelId = useViewer.getState().selection.levelId
17
34
  const selectedZoneId = useViewer.getState().selection.zoneId
18
35
  const hoveredId = useViewer.getState().hoveredId
36
+ // Snapshot capture is a clean, camera-only surface — never show zone
37
+ // geometry or the HTML zone tags in the framed shot.
38
+ const isCaptureMode = useEditor.getState().isCaptureMode
39
+
40
+ // During any active interaction zone labels step back entirely — they are
41
+ // not a primary editing concern and would distract / invite misclicks.
42
+ const zoneLabelsHidden =
43
+ resolveOverlayPolicy(useInteractionScope.getState().scope).zoneLabels === 'hidden'
19
44
 
20
45
  const zoneGeometryVisible = structureLayer === 'zones'
21
46
  const zones = sceneRegistry.byType.zone || new Set()
@@ -35,12 +60,22 @@ export const ZoneSystem = () => {
35
60
  // Keep group visible (so <Html> labels stay active), hide/show meshes only.
36
61
  // Show meshes when: in zone mode, selected, or delete-hovered.
37
62
  if (!obj.visible) obj.visible = true
38
- const meshVisible = zoneGeometryVisible || isSelected || isDeleteHovered
39
- const targetOpacity = isSelected || isDeleteHovered ? 1 : zoneGeometryVisible ? 1 : 0
63
+ const meshVisible = !isCaptureMode && (zoneGeometryVisible || isSelected || isDeleteHovered)
64
+ const targetOpacity = isCaptureMode
65
+ ? 0
66
+ : isSelected || isDeleteHovered
67
+ ? 1
68
+ : zoneGeometryVisible
69
+ ? 1
70
+ : 0
40
71
 
72
+ // Raycast is re-disabled per frame (not once per group): the meshes
73
+ // remount whenever the zones layer toggles, so a one-shot flag on the
74
+ // persistent group would leave fresh meshes clickable.
41
75
  const walls = (obj as Group).getObjectByName('walls') as Mesh | undefined
42
76
  if (walls) {
43
77
  walls.visible = meshVisible
78
+ walls.raycast = noopRaycast
44
79
  const material = walls.material as MeshBasicNodeMaterial
45
80
  if (material?.userData?.uOpacity) {
46
81
  material.userData.uOpacity.value = MathUtils.lerp(
@@ -54,6 +89,7 @@ export const ZoneSystem = () => {
54
89
  const floor = (obj as Group).getObjectByName('floor') as Mesh | undefined
55
90
  if (floor) {
56
91
  floor.visible = meshVisible
92
+ floor.raycast = noopRaycast
57
93
  const material = floor.material as MeshBasicNodeMaterial
58
94
  if (material?.userData?.uOpacity) {
59
95
  material.userData.uOpacity.value = MathUtils.lerp(
@@ -64,17 +100,10 @@ export const ZoneSystem = () => {
64
100
  }
65
101
  }
66
102
 
67
- // Disable raycasting once per zone object so geometry never intercepts clicks
68
- if (!obj.userData.__raycastDisabled) {
69
- obj.raycast = noopRaycast
70
- obj.traverse((child) => {
71
- child.raycast = noopRaycast
72
- })
73
- obj.userData.__raycastDisabled = true
74
- }
75
-
76
- // Labels: always visible on the current level (regardless of mode)
77
- const showLabel = !!selectedLevelId && isOnSelectedLevel
103
+ // Labels: visible on the current level (regardless of mode), but never
104
+ // during snapshot capture.
105
+ const showLabel =
106
+ !isCaptureMode && !zoneLabelsHidden && !!selectedLevelId && isOnSelectedLevel
78
107
  const labelOpacity = showLabel ? '1' : '0'
79
108
  const labelEl = document.getElementById(`${zoneId}-label`)
80
109
  if (labelEl && labelEl.style.opacity !== labelOpacity) {
@@ -9,11 +9,18 @@ import {
9
9
  resolveAlignment,
10
10
  useScene,
11
11
  } from '@pascal-app/core'
12
- import { useAlignmentGuides } from '@pascal-app/editor'
13
12
  import { useEffect, useMemo, useRef } from 'react'
14
13
  import * as THREE from 'three'
15
14
  import { resolveCurrentBuildingId, resolveElevatorSupportY } from '../../../lib/elevator-support'
16
15
  import { sfxEmitter } from '../../../lib/sfx-bus'
16
+
17
+ import useAlignmentGuides from '../../../store/use-alignment-guides'
18
+ import useEditor, {
19
+ isAlignmentGuideActive,
20
+ isGridSnapActive,
21
+ isMagneticSnapActive,
22
+ } from '../../../store/use-editor'
23
+
17
24
  import usePlacementPreview from '../../../store/use-placement-preview'
18
25
  import { CursorSphere } from '../shared/cursor-sphere'
19
26
  import {
@@ -163,13 +170,16 @@ export const ElevatorTool: React.FC<ElevatorToolProps> = ({ buildingId, levelId,
163
170
  // point: resolving against the grid point would only ever catch anchors
164
171
  // that happen to sit on a grid line, so off-grid items (furniture, angled
165
172
  // walls) would never surface a guide. The matched axis locks exactly to the
166
- // candidate's coordinate; the other axis keeps its grid snap. Alt bypasses.
173
+ // candidate's coordinate; the other axis keeps its grid snap. Guides are
174
+ // published in every snapping mode (including Off); the magnetic pull toward
175
+ // them (applySnap) applies only in 'lines' mode.
167
176
  const alignPoint = (
168
177
  gridX: number,
169
178
  gridZ: number,
170
179
  rawX: number,
171
180
  rawZ: number,
172
181
  bypass: boolean,
182
+ applySnap: boolean,
173
183
  ): [number, number] => {
174
184
  if (bypass || alignmentCandidates.length === 0) {
175
185
  useAlignmentGuides.getState().clear()
@@ -185,6 +195,7 @@ export const ElevatorTool: React.FC<ElevatorToolProps> = ({ buildingId, levelId,
185
195
  return [gridX, gridZ]
186
196
  }
187
197
  useAlignmentGuides.getState().set(ar.guides)
198
+ if (!applySnap) return [gridX, gridZ]
188
199
  let x = gridX
189
200
  let z = gridZ
190
201
  for (const guide of ar.guides) {
@@ -195,12 +206,20 @@ export const ElevatorTool: React.FC<ElevatorToolProps> = ({ buildingId, levelId,
195
206
  }
196
207
 
197
208
  const onGridMove = (event: GridEvent) => {
209
+ // Grid snap follows the global mode (live step so the HUD chip is
210
+ // honest); Off keeps the raw cursor. Shift cycles the mode centrally.
211
+ const step = useEditor.getState().gridSnapStep
198
212
  const [gridX, gridZ] = alignPoint(
199
- Math.round(event.localPosition[0] * 2) / 2,
200
- Math.round(event.localPosition[2] * 2) / 2,
213
+ isGridSnapActive()
214
+ ? Math.round(event.localPosition[0] / step) * step
215
+ : event.localPosition[0],
216
+ isGridSnapActive()
217
+ ? Math.round(event.localPosition[2] / step) * step
218
+ : event.localPosition[2],
201
219
  event.localPosition[0],
202
220
  event.localPosition[2],
203
- event.nativeEvent?.altKey === true,
221
+ !isAlignmentGuideActive(),
222
+ isMagneticSnapActive(),
204
223
  )
205
224
  const supportY = resolveElevatorSupportY({
206
225
  buildingId: currentBuildingId,
@@ -220,6 +239,7 @@ export const ElevatorTool: React.FC<ElevatorToolProps> = ({ buildingId, levelId,
220
239
  })
221
240
 
222
241
  if (
242
+ (isGridSnapActive() || isMagneticSnapActive()) &&
223
243
  previousGridPosRef.current &&
224
244
  (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
225
245
  ) {
@@ -237,12 +257,18 @@ export const ElevatorTool: React.FC<ElevatorToolProps> = ({ buildingId, levelId,
237
257
  })
238
258
  if (!latestBuildingId) return
239
259
 
260
+ const step = useEditor.getState().gridSnapStep
240
261
  const [gridX, gridZ] = alignPoint(
241
- Math.round(event.localPosition[0] * 2) / 2,
242
- Math.round(event.localPosition[2] * 2) / 2,
262
+ isGridSnapActive()
263
+ ? Math.round(event.localPosition[0] / step) * step
264
+ : event.localPosition[0],
265
+ isGridSnapActive()
266
+ ? Math.round(event.localPosition[2] / step) * step
267
+ : event.localPosition[2],
243
268
  event.localPosition[0],
244
269
  event.localPosition[2],
245
- event.nativeEvent?.altKey === true,
270
+ !isAlignmentGuideActive(),
271
+ isMagneticSnapActive(),
246
272
  )
247
273
  commitElevatorPlacement(
248
274
  latestBuildingId,
@@ -15,6 +15,7 @@ import {
15
15
  import { useCallback, useEffect, useRef, useState } from 'react'
16
16
  import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
17
17
  import { resolveElevatorSupportY } from '../../../lib/elevator-support'
18
+ import { consumePlacementDragRelease } from '../../../lib/placement-drag-release'
18
19
  import { sfxEmitter } from '../../../lib/sfx-bus'
19
20
  import useEditor from '../../../store/use-editor'
20
21
  import { CursorSphere } from '../shared/cursor-sphere'
@@ -131,8 +132,9 @@ export function MoveElevatorTool({
131
132
  }
132
133
 
133
134
  const onGridMove = (event: GridEvent) => {
134
- const rawX = Math.round(event.localPosition[0] * 2) / 2
135
- const rawZ = Math.round(event.localPosition[2] * 2) / 2
135
+ const bypassSnap = event.nativeEvent?.shiftKey === true
136
+ const rawX = bypassSnap ? event.localPosition[0] : Math.round(event.localPosition[0] * 2) / 2
137
+ const rawZ = bypassSnap ? event.localPosition[2] : Math.round(event.localPosition[2] * 2) / 2
136
138
  const anchor = dragAnchorRef.current ?? [rawX, rawZ]
137
139
  dragAnchorRef.current = anchor
138
140
  const gridX = movingNode.position[0] + (rawX - anchor[0])
@@ -145,6 +147,7 @@ export function MoveElevatorTool({
145
147
  })
146
148
 
147
149
  if (
150
+ !bypassSnap &&
148
151
  previousGridPosRef.current &&
149
152
  (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
150
153
  ) {
@@ -158,6 +161,7 @@ export function MoveElevatorTool({
158
161
  }
159
162
 
160
163
  const onGridClick = (event: GridEvent) => {
164
+ if (wasCommitted) return
161
165
  const nextPosition: ElevatorNode['position'] = [...previewPositionRef.current]
162
166
 
163
167
  wasCommitted = true
@@ -187,6 +191,11 @@ export function MoveElevatorTool({
187
191
  event.nativeEvent?.stopPropagation?.()
188
192
  }
189
193
 
194
+ const onPlacementDragPointerUp = (event: PointerEvent) => {
195
+ if (!consumePlacementDragRelease(event)) return
196
+ onGridClick({ nativeEvent: event } as unknown as GridEvent)
197
+ }
198
+
190
199
  const onCancel = () => {
191
200
  wasCancelled = true
192
201
  clearPreview()
@@ -229,6 +238,7 @@ export function MoveElevatorTool({
229
238
  emitter.on('grid:click', onGridClick)
230
239
  emitter.on('tool:cancel', onCancel)
231
240
  window.addEventListener('keydown', onKeyDown)
241
+ window.addEventListener('pointerup', onPlacementDragPointerUp)
232
242
 
233
243
  return () => {
234
244
  clearPreview()
@@ -245,6 +255,7 @@ export function MoveElevatorTool({
245
255
  emitter.off('grid:click', onGridClick)
246
256
  emitter.off('tool:cancel', onCancel)
247
257
  window.removeEventListener('keydown', onKeyDown)
258
+ window.removeEventListener('pointerup', onPlacementDragPointerUp)
248
259
  }
249
260
  }, [movingNode, exitMoveMode])
250
261
 
@@ -1,8 +1,11 @@
1
1
  import {
2
+ DEFAULT_ANGLE_STEP,
2
3
  FenceNode,
4
+ getTwoPointFenceCurveTangents,
3
5
  getWallCurveFrameAt,
4
6
  getWallCurveLength,
5
7
  isCurvedWall,
8
+ snapPointAlongAngleRay,
6
9
  useScene,
7
10
  type WallNode,
8
11
  } from '@pascal-app/core'
@@ -12,9 +15,7 @@ import useEditor from '../../../store/use-editor'
12
15
  import {
13
16
  findWallSnapTarget,
14
17
  getSegmentGridStep,
15
- getWallAngleSnapStep,
16
18
  isSegmentLongEnough,
17
- snapPointTo45Degrees,
18
19
  snapPointToGrid,
19
20
  type WallPlanPoint,
20
21
  } from '../wall/wall-drafting'
@@ -132,7 +133,9 @@ export function snapFenceDraftPoint(args: {
132
133
  start?: FencePlanPoint
133
134
  angleSnap?: boolean
134
135
  ignoreFenceIds?: string[]
135
- /** Override the grid step (e.g. `WALL_FINE_GRID_STEP` for precision mode). */
136
+ bypassSnap?: boolean
137
+ magnetic?: boolean
138
+ /** Override the grid step. */
136
139
  step?: number
137
140
  /**
138
141
  * Optional grid-snap function. When provided, replaces the default
@@ -142,17 +145,45 @@ export function snapFenceDraftPoint(args: {
142
145
  */
143
146
  gridSnap?: (point: FencePlanPoint) => FencePlanPoint
144
147
  }): FencePlanPoint {
145
- const { point, walls, fences, start, angleSnap = false, ignoreFenceIds, step, gridSnap } = args
148
+ const {
149
+ point,
150
+ walls,
151
+ fences,
152
+ start,
153
+ angleSnap = false,
154
+ ignoreFenceIds,
155
+ bypassSnap = false,
156
+ magnetic = true,
157
+ step,
158
+ gridSnap,
159
+ } = args
160
+ if (bypassSnap) return point
161
+
146
162
  const gridStep = step ?? getSegmentGridStep()
147
- const angleStep = getWallAngleSnapStep(gridStep)
148
- const basePoint =
163
+
164
+ // Magnetic endpoint snap must beat the angle lock, and the lock can pull
165
+ // the cursor far enough off an endpoint that probing the locked point
166
+ // would never engage — so under the lock, probe from the RAW cursor
167
+ // first (mirrors `snapWallDraftPointDetailed`'s special-point pre-pass).
168
+ if (start && angleSnap) {
169
+ const rawTarget =
170
+ magnetic &&
171
+ (findFenceSnapTarget(point, fences, ignoreFenceIds) ?? findWallSnapTarget(point, walls))
172
+ if (rawTarget) return rawTarget
173
+ }
174
+
175
+ // The angle path snaps the distance ALONG the 15° ray — a scalar, the
176
+ // same in world and local frames — so the `gridSnap` world-grid override
177
+ // only applies when the angle lock is off.
178
+ const basePoint: FencePlanPoint =
149
179
  start && angleSnap
150
- ? snapPointTo45Degrees(start, point, gridStep, angleStep, gridSnap)
180
+ ? [...snapPointAlongAngleRay(start, point, DEFAULT_ANGLE_STEP, gridStep)]
151
181
  : gridSnap
152
182
  ? gridSnap(point)
153
183
  : snapPointToGrid(point, gridStep)
154
- const fenceSnapTarget = findFenceSnapTarget(basePoint, fences, ignoreFenceIds)
184
+ if (!magnetic) return basePoint
155
185
 
186
+ const fenceSnapTarget = findFenceSnapTarget(basePoint, fences, ignoreFenceIds)
156
187
  return fenceSnapTarget ?? findWallSnapTarget(basePoint, walls) ?? basePoint
157
188
  }
158
189
 
@@ -184,3 +215,44 @@ export function createFenceOnCurrentLevel(
184
215
 
185
216
  return fence
186
217
  }
218
+
219
+ /**
220
+ * Commit a smooth spline fence from a list of drawn control points. The
221
+ * centerline becomes a Catmull-Rom curve through `path`; `start`/`end` are
222
+ * pinned to the first/last point so endpoint handles, bbox, and miter
223
+ * references stay valid. Requires >= 2 points spanning a usable distance.
224
+ */
225
+ export function createSplineFenceOnCurrentLevel(
226
+ path: FencePlanPoint[],
227
+ tangents = getTwoPointFenceCurveTangents(path),
228
+ ): FenceNode | null {
229
+ const currentLevelId = useViewer.getState().selection.levelId
230
+ const { createNode, nodes } = useScene.getState()
231
+
232
+ if (!currentLevelId || path.length < 2) {
233
+ return null
234
+ }
235
+ const start = path[0]!
236
+ const end = path[path.length - 1]!
237
+ // A degenerate single-point-ish path (all clicks on one spot) is rejected
238
+ // the same way a too-short straight segment is.
239
+ if (!isSegmentLongEnough(start, end) && path.length < 3) {
240
+ return null
241
+ }
242
+
243
+ const fenceCount = Object.values(nodes).filter((node) => node.type === 'fence').length
244
+ const defaults = useEditor.getState().toolDefaults.fence ?? {}
245
+ const fence = FenceNode.parse({
246
+ ...defaults,
247
+ name: `Fence ${fenceCount + 1}`,
248
+ start,
249
+ end,
250
+ path,
251
+ tangents,
252
+ })
253
+
254
+ createNode(fence, currentLevelId)
255
+ sfxEmitter.emit('sfx:structure-build')
256
+
257
+ return fence
258
+ }
@@ -1,7 +1,7 @@
1
1
  import type { AnyNodeId, ElevatorNode, SpawnNode } from '@pascal-app/core'
2
2
  import { nodeRegistry } from '@pascal-app/core'
3
3
  import { Suspense } from 'react'
4
- import useEditor from '../../../store/use-editor'
4
+ import { useMovingNode } from '../../../store/use-interaction-scope'
5
5
  import { MoveElevatorTool } from '../elevator/move-elevator-tool'
6
6
  import { MoveRegistryNodeTool } from '../registry/move-registry-node-tool'
7
7
  import { getRegistryAffordanceTool } from '../shared/affordance-dispatch'
@@ -9,14 +9,17 @@ import { getRegistryAffordanceTool } from '../shared/affordance-dispatch'
9
9
  /**
10
10
  * MoveTool dispatcher. Routes to (in order):
11
11
  *
12
- * 1. `MoveRegistryNodeTool` — generic translate-on-XZ for kinds that
13
- * declare `capabilities.movable` (shelf, spawn, item-with-floor-attach,
14
- * ).
15
- * 2. `def.affordanceTools.move` kind-owned move component, lazy-loaded
16
- * via `getRegistryAffordanceTool`. Covers both generic movers
17
- * (slab / ceiling / wall / fence / column / item / door / window) and
18
- * the bespoke roof / roof-segment / stair / stair-segment / building
19
- * movers ported into `@pascal-app/nodes`.
12
+ * 1. `def.affordanceTools.move` — kind-owned move component, lazy-loaded
13
+ * via `getRegistryAffordanceTool`. Covers generic movers
14
+ * (slab / ceiling / wall / fence / column / item / door / window), the
15
+ * bespoke roof / roof-segment / stair / stair-segment / building
16
+ * movers, and the polyline / fitting ghost-placement movers
17
+ * (duct-segment / duct-fitting). A kind that ships its own mover wins
18
+ * even if it also declares `capabilities.movable` (duct-fitting keeps
19
+ * `movable` for the inspector / hint readers but places via its ghost).
20
+ * 2. `MoveRegistryNodeTool` — generic translate-on-XZ for kinds that only
21
+ * declare `capabilities.movable` (shelf, spawn, duct-terminal,
22
+ * hvac-equipment, …).
20
23
  * 3. `elevator` is the lone remaining legacy arm — its bespoke cab/shaft
21
24
  * mover hasn't been ported to a kind-owned affordance yet.
22
25
  */
@@ -24,14 +27,11 @@ export const MoveTool: React.FC<{
24
27
  onNodeMoved?: (nodeId: AnyNodeId) => void
25
28
  onSpawnMoved?: (nodeId: SpawnNode['id']) => void
26
29
  }> = ({ onNodeMoved }) => {
27
- const movingNode = useEditor((state) => state.movingNode)
30
+ const movingNode = useMovingNode()
28
31
 
29
32
  if (!movingNode) return null
30
33
 
31
34
  const def = nodeRegistry.get(movingNode.type)
32
- if (def?.capabilities?.movable) {
33
- return <MoveRegistryNodeTool node={movingNode} />
34
- }
35
35
 
36
36
  const RegistryMove = getRegistryAffordanceTool(movingNode.type, 'move')
37
37
  if (RegistryMove) {
@@ -42,6 +42,10 @@ export const MoveTool: React.FC<{
42
42
  )
43
43
  }
44
44
 
45
+ if (def?.capabilities?.movable) {
46
+ return <MoveRegistryNodeTool node={movingNode} />
47
+ }
48
+
45
49
  if (movingNode.type === 'elevator')
46
50
  return <MoveElevatorTool node={movingNode as ElevatorNode} onCommitted={onNodeMoved} />
47
51
  return null
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
- import { stripTransient } from './placement-math'
2
+ import { getDetachedAttachmentPreviewLift, stripTransient } from './placement-math'
3
3
 
4
4
  describe('stripTransient', () => {
5
5
  test('removes placement-only metadata flags before commit', () => {
@@ -8,3 +8,15 @@ describe('stripTransient', () => {
8
8
  })
9
9
  })
10
10
  })
11
+
12
+ describe('getDetachedAttachmentPreviewLift', () => {
13
+ test('raises attach-only item previews while they are detached from their host', () => {
14
+ expect(getDetachedAttachmentPreviewLift('wall')).toBeGreaterThan(0)
15
+ expect(getDetachedAttachmentPreviewLift('wall-side')).toBeGreaterThan(0)
16
+ expect(getDetachedAttachmentPreviewLift('ceiling')).toBeGreaterThan(0)
17
+ })
18
+
19
+ test('keeps floor item previews on the floor', () => {
20
+ expect(getDetachedAttachmentPreviewLift(undefined)).toBe(0)
21
+ })
22
+ })
@@ -1,9 +1,26 @@
1
1
  import { type AssetInput, isObject } from '@pascal-app/core'
2
2
  import { Euler, Matrix3, type Matrix4, Quaternion, Vector3 } from 'three'
3
- import useEditor from '../../../store/use-editor'
3
+ import { resolveSnapFlags } from '../../../lib/snapping-mode'
4
+ import useEditor, { getActiveSnappingMode } from '../../../store/use-editor'
4
5
 
6
+ // Sentinel returned when the active context's snapping mode disables grid snap.
7
+ // The snap helpers below treat any `step <= 0` as "no grid snap" and pass the
8
+ // raw value through. For items the default mode is now `lines` (grid off), so
9
+ // item placement/move is free + line-snap unless the user opts into `grid`.
5
10
  function getGridSnapStep(): number {
6
- return useEditor.getState().gridSnapStep
11
+ return resolveSnapFlags(getActiveSnappingMode()).grid ? useEditor.getState().gridSnapStep : 0
12
+ }
13
+
14
+ const ROTATION_QUANTUM = Math.PI / 4
15
+
16
+ /**
17
+ * R/T rotation: round the current angle to the nearest 45° then step ONE
18
+ * increment in `direction` (+1 / -1), so the node always lands on a clean 45°
19
+ * multiple regardless of its starting angle (12° → 45°, 40° → 90°) rather than a
20
+ * blind ±45° from an arbitrary angle.
21
+ */
22
+ export function steppedRotation(current: number, direction: 1 | -1): number {
23
+ return (Math.round(current / ROTATION_QUANTUM) + direction) * ROTATION_QUANTUM
7
24
  }
8
25
 
9
26
  function positiveModulo(value: number, divisor: number): number {
@@ -14,6 +31,7 @@ function positiveModulo(value: number, divisor: number): number {
14
31
  * Snaps a position to the active grid step, aligning item edges to grid lines.
15
32
  */
16
33
  export function snapToGrid(position: number, dimension: number, step = getGridSnapStep()): number {
34
+ if (step <= 0) return position
17
35
  const halfDim = dimension / 2
18
36
  const offset = positiveModulo(halfDim, step)
19
37
  return Math.round((position - offset) / step) * step + offset
@@ -23,6 +41,7 @@ export function snapToGrid(position: number, dimension: number, step = getGridSn
23
41
  * Snap a value to the active grid step (used for wall-local positions).
24
42
  */
25
43
  export function snapToHalf(value: number, step = getGridSnapStep()): number {
44
+ if (step <= 0) return value
26
45
  return Math.round(value / step) * step
27
46
  }
28
47
 
@@ -30,6 +49,7 @@ export function snapToHalf(value: number, step = getGridSnapStep()): number {
30
49
  * Round a value up to the next multiple of `step`, with a minimum of `step`.
31
50
  */
32
51
  export function snapUpToGridStep(value: number, step = getGridSnapStep()): number {
52
+ if (step <= 0) return value
33
53
  return Math.max(step, Math.ceil(value / step) * step)
34
54
  }
35
55
 
@@ -55,6 +75,12 @@ export function getGridAlignedDimensions(
55
75
  return [snapUpToGridStep(w, step), h, snapUpToGridStep(d, step)]
56
76
  }
57
77
 
78
+ export function getDetachedAttachmentPreviewLift(
79
+ attachTo: AssetInput['attachTo'] | null | undefined,
80
+ ): number {
81
+ return attachTo ? 0.45 : 0
82
+ }
83
+
58
84
  /**
59
85
  * Calculate cursor rotation in WORLD space from wall normal and orientation.
60
86
  */