@pascal-app/editor 1.0.0 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/package.json +12 -6
  2. package/src/components/editor/bake-exporter.tsx +4 -0
  3. package/src/components/editor/bake-thumbnail.tsx +4 -1
  4. package/src/components/editor/custom-camera-controls.tsx +22 -74
  5. package/src/components/editor/editor-layout-mobile.tsx +1 -0
  6. package/src/components/editor/floating-action-menu.tsx +14 -1
  7. package/src/components/editor/floorplan-panel.tsx +233 -166
  8. package/src/components/editor/grid.tsx +31 -7
  9. package/src/components/editor/group-actions.ts +5 -9
  10. package/src/components/editor/group-floating-action-menu.tsx +19 -9
  11. package/src/components/editor/group-move-3d.ts +4 -6
  12. package/src/components/editor/group-rotate-handle.tsx +81 -35
  13. package/src/components/editor/group-selection-box-3d.tsx +25 -17
  14. package/src/components/editor/group-transform-shared.test.ts +126 -1
  15. package/src/components/editor/group-transform-shared.ts +79 -73
  16. package/src/components/editor/handles/handle-arrow.tsx +72 -2
  17. package/src/components/editor/handles/handle-placement.test.ts +111 -0
  18. package/src/components/editor/handles/handle-placement.ts +69 -0
  19. package/src/components/editor/handles/linear-resize-drag.ts +13 -2
  20. package/src/components/editor/handles/use-handle-drag.ts +80 -8
  21. package/src/components/editor/index.tsx +36 -13
  22. package/src/components/editor/node-action-menu.tsx +2 -0
  23. package/src/components/editor/node-arrow-handles.tsx +162 -69
  24. package/src/components/editor/registry-action-contributions.tsx +53 -0
  25. package/src/components/editor/selection-manager.tsx +49 -0
  26. package/src/components/editor/thumbnail-generator.tsx +11 -2
  27. package/src/components/editor/wall-move-side-handles.tsx +71 -21
  28. package/src/components/editor/wall-opening-highlights.tsx +2 -3
  29. package/src/components/editor-2d/floorplan-group-move.tsx +17 -24
  30. package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +33 -12
  31. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
  32. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +154 -49
  33. package/src/components/editor-2d/renderers/floorplan-draft-wall-measurement.tsx +166 -0
  34. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +49 -0
  35. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +113 -24
  36. package/src/components/procedural-items/inspector.tsx +223 -0
  37. package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
  38. package/src/components/systems/zone/zone-system.tsx +8 -4
  39. package/src/components/tools/__fixtures__/hosting.ts +10 -0
  40. package/src/components/tools/item/item-surface-placement.test.ts +169 -0
  41. package/src/components/tools/item/item-surface-strategy-resolver.test.ts +204 -0
  42. package/src/components/tools/item/placement-strategies.ts +212 -200
  43. package/src/components/tools/item/placement-types.ts +4 -0
  44. package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
  45. package/src/components/tools/item/use-draft-node.ts +136 -47
  46. package/src/components/tools/item/use-placement-coordinator.tsx +391 -157
  47. package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
  48. package/src/components/tools/registry/item-surface-move.test.ts +509 -0
  49. package/src/components/tools/registry/item-surface-move.ts +463 -0
  50. package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
  51. package/src/components/tools/registry/move-registry-node-tool.tsx +237 -78
  52. package/src/components/tools/shared/affordance-dispatch.test.ts +24 -0
  53. package/src/components/tools/shared/affordance-dispatch.ts +13 -0
  54. package/src/components/tools/shared/draft-measurement-label.tsx +38 -0
  55. package/src/components/tools/shared/pointer-support-cap.test.ts +27 -0
  56. package/src/components/tools/shared/pointer-support-cap.ts +27 -13
  57. package/src/components/tools/shared/shelf-stickiness.ts +54 -0
  58. package/src/components/tools/shared/surface-hit.test.ts +56 -0
  59. package/src/components/tools/shared/surface-hit.ts +23 -0
  60. package/src/components/tools/shared/surface-rejection.test.tsx +107 -0
  61. package/src/components/tools/shared/surface-rejection.tsx +43 -0
  62. package/src/components/tools/tool-manager.tsx +36 -3
  63. package/src/components/tools/zone/zone-tool.tsx +13 -4
  64. package/src/components/ui/command-palette/editor-commands.tsx +54 -1
  65. package/src/components/ui/controls/material-paint-panel.tsx +6 -67
  66. package/src/components/ui/controls/material-picker.tsx +5 -67
  67. package/src/components/ui/controls/metric-control.tsx +29 -13
  68. package/src/components/ui/controls/slider-control.tsx +87 -35
  69. package/src/components/ui/floating-level-selector.tsx +81 -5
  70. package/src/components/ui/helpers/helper-manager.tsx +24 -6
  71. package/src/components/ui/helpers/registered-tool-helper.tsx +2 -32
  72. package/src/components/ui/item-catalog/item-catalog.tsx +5 -30
  73. package/src/components/ui/panels/multi-height-mode.tsx +27 -21
  74. package/src/components/ui/panels/multi-parametric-inspector.tsx +6 -3
  75. package/src/components/ui/panels/parametric-field-control.tsx +1 -1
  76. package/src/components/ui/panels/parametric-inspector.tsx +9 -17
  77. package/src/components/ui/panels/reference-panel.tsx +3 -3
  78. package/src/components/ui/primitives/checkbox.tsx +34 -0
  79. package/src/components/ui/primitives/shortcut-token.tsx +4 -0
  80. package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
  81. package/src/components/ui/sidebar/panels/settings-panel/index.tsx +669 -336
  82. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
  83. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +31 -4
  84. package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +30 -11
  85. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
  86. package/src/components/ui/sidebar/panels/site-panel/index.tsx +106 -4
  87. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
  88. package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
  89. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +6 -1
  90. package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
  91. package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
  92. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
  93. package/src/components/ui/snap-target-badge.tsx +2 -0
  94. package/src/components/viewer/floorplan-preview-visibility.test.ts +35 -0
  95. package/src/components/viewer/floorplan-preview-visibility.ts +19 -0
  96. package/src/components/viewer/floorplan-preview.tsx +1 -12
  97. package/src/components/viewer/viewer-scene-header.tsx +3 -0
  98. package/src/components/viewer/viewer-units-panel.tsx +116 -0
  99. package/src/components/viewer-zone-system.tsx +7 -2
  100. package/src/hooks/use-auto-save.integration.test.tsx +197 -0
  101. package/src/hooks/use-auto-save.ts +21 -6
  102. package/src/hooks/use-ceiling-events.test.ts +25 -2
  103. package/src/hooks/use-ceiling-events.ts +7 -1
  104. package/src/hooks/use-grid-events.test.ts +2 -0
  105. package/src/hooks/use-grid-events.ts +16 -11
  106. package/src/hooks/use-keyboard.test.ts +7 -0
  107. package/src/hooks/use-keyboard.ts +43 -28
  108. package/src/index.tsx +68 -3
  109. package/src/lib/catalog-panel-model.ts +39 -0
  110. package/src/lib/catalog-selection.test.ts +27 -0
  111. package/src/lib/catalog-selection.ts +8 -0
  112. package/src/lib/contextual-help.test.ts +27 -1
  113. package/src/lib/contextual-help.ts +16 -9
  114. package/src/lib/export-texture-utils.test.ts +167 -0
  115. package/src/lib/export-texture-utils.ts +121 -0
  116. package/src/lib/export-user-data.test.ts +48 -0
  117. package/src/lib/export-user-data.ts +69 -0
  118. package/src/lib/floorplan/floorplan-export.test.ts +142 -2
  119. package/src/lib/floorplan/floorplan-export.tsx +9 -3
  120. package/src/lib/floorplan/floorplan-extension.ts +16 -0
  121. package/src/lib/floorplan/floorplan-readonly.ts +4 -0
  122. package/src/lib/fresh-planar-placement.ts +173 -33
  123. package/src/lib/glb-export-serialize.test.ts +280 -0
  124. package/src/lib/glb-export.test.ts +265 -16
  125. package/src/lib/glb-export.ts +197 -37
  126. package/src/lib/grid-frame.test.ts +19 -0
  127. package/src/lib/grid-frame.ts +18 -0
  128. package/src/lib/interaction/scope.ts +9 -1
  129. package/src/lib/keyboard-pan.test.ts +62 -0
  130. package/src/lib/keyboard-pan.ts +89 -0
  131. package/src/lib/level-duplication.test.ts +73 -0
  132. package/src/lib/level-duplication.ts +32 -2
  133. package/src/lib/linear-display.test.ts +59 -0
  134. package/src/lib/linear-display.ts +39 -0
  135. package/src/lib/local-guide-image.test.ts +24 -0
  136. package/src/lib/local-guide-image.ts +34 -1
  137. package/src/lib/material-catalog-model.ts +72 -0
  138. package/src/lib/material-paint-panel-model.ts +76 -0
  139. package/src/lib/node-action-movement.ts +21 -0
  140. package/src/lib/overlay-transform.test.ts +28 -0
  141. package/src/lib/overlay-transform.ts +12 -0
  142. package/src/lib/panel-rows.ts +75 -0
  143. package/src/lib/panel-tool-options.test.ts +34 -0
  144. package/src/lib/panel-tool-options.ts +46 -0
  145. package/src/lib/parametric-node-update.ts +37 -0
  146. package/src/lib/portable-export.test.ts +89 -0
  147. package/src/lib/portable-export.ts +84 -11
  148. package/src/lib/print-shell-compiler-manifold-core.ts +59 -10
  149. package/src/lib/print-shell-compiler-manifold-worker.ts +14 -1
  150. package/src/lib/print-shell-compiler-manifold.worker.ts +1 -1
  151. package/src/lib/print-shell-compiler-protocol.ts +8 -0
  152. package/src/lib/scene-clipboard.ts +1 -1
  153. package/src/lib/scene.test.ts +99 -0
  154. package/src/lib/scene.ts +21 -3
  155. package/src/lib/send-to-app.test.ts +108 -0
  156. package/src/lib/send-to-app.ts +121 -0
  157. package/src/lib/sfx/movement-tick.ts +37 -0
  158. package/src/lib/snapping-mode.test.ts +13 -2
  159. package/src/lib/snapping-mode.ts +11 -6
  160. package/src/lib/spatial-drag-plane.test.ts +53 -0
  161. package/src/lib/spatial-drag-plane.ts +19 -0
  162. package/src/lib/spatial-pointer-input.test.ts +72 -0
  163. package/src/lib/spatial-pointer-input.ts +62 -0
  164. package/src/lib/spatial-snap.test.ts +47 -0
  165. package/src/lib/surface-attachment.ts +77 -0
  166. package/src/lib/terrain-panel-model.ts +117 -0
  167. package/src/lib/units.ts +254 -0
  168. package/src/lib/usdz-export.ts +43 -16
  169. package/src/lib/use-linear-display.ts +8 -32
  170. package/src/lib/world-grid-snap.test.ts +48 -0
  171. package/src/lib/world-grid-snap.ts +9 -5
  172. package/src/store/rotation-snapping.test.ts +53 -0
  173. package/src/store/use-editor.tsx +10 -2
  174. package/src/store/use-floorplan-draft-preview.ts +10 -1
  175. package/src/store/use-handle-group.ts +4 -0
  176. package/src/store/use-interaction-scope.ts +155 -6
  177. package/src/store/use-send-to-app.ts +21 -0
@@ -4,6 +4,7 @@ import {
4
4
  type AnyNode,
5
5
  type AnyNodeId,
6
6
  type ArcResizeHandle,
7
+ type CornerRadiusHandle,
7
8
  type Cursor,
8
9
  createSceneApi,
9
10
  DEFAULT_ANGLE_STEP,
@@ -46,7 +47,12 @@ import { RESIZE_HANDLE_DRAG_LABEL, ROTATE_HANDLE_DRAG_LABEL } from '../../lib/co
46
47
  import { createEditorApi } from '../../lib/editor-api'
47
48
  import { sfxEmitter } from '../../lib/sfx-bus'
48
49
  import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback'
49
- import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../store/use-editor'
50
+ import useEditor, {
51
+ isAngleSnapActive,
52
+ isGridSnapActive,
53
+ isMagneticSnapActive,
54
+ } from '../../store/use-editor'
55
+ import { useHandleGroup } from '../../store/use-handle-group'
50
56
  import useInteractionScope, {
51
57
  useEndpointReshape,
52
58
  useIsCurveReshape,
@@ -62,7 +68,12 @@ import {
62
68
  HandleArrow,
63
69
  NO_RAYCAST,
64
70
  } from './handles/handle-arrow'
65
- import { createLinearResizeDragBinding } from './handles/linear-resize-drag'
71
+ import {
72
+ computeFreezeOffset,
73
+ resolveLinearHandlePosition,
74
+ resolveLinearHandleRotation,
75
+ } from './handles/handle-placement'
76
+ import { createLinearResizeDragBinding, linearResizeFactor } from './handles/linear-resize-drag'
66
77
  import { resolveResizeSnapValue } from './handles/resize-snap'
67
78
  import { type HandleDragControls, useHandleDrag } from './handles/use-handle-drag'
68
79
 
@@ -227,6 +238,9 @@ export function NodeArrowHandles() {
227
238
  () => (rawNode && liveOverride ? ({ ...rawNode, ...liveOverride } as AnyNode) : rawNode),
228
239
  [rawNode, liveOverride],
229
240
  )
241
+ const activeGroup = useHandleGroup((s) =>
242
+ s.active && s.active.nodeId === node?.id ? s.active.group : null,
243
+ )
230
244
  const def = node ? nodeRegistry.get(node.type) : null
231
245
  const descriptorSceneApi = useMemo(() => createSceneApi(useScene), [])
232
246
  const descriptors = useMemo(() => {
@@ -236,6 +250,8 @@ export function NodeArrowHandles() {
236
250
  ? def.handles(node as never, descriptorSceneApi)
237
251
  : (def.handles as HandleDescriptor[])
238
252
  return all.filter((descriptor) => {
253
+ if (activeGroup)
254
+ return descriptor.kind === 'linear-resize' && descriptor.latchGroup === activeGroup
239
255
  if (descriptor.kind === 'translate') return false
240
256
  const visible =
241
257
  'visible' in descriptor
@@ -244,7 +260,7 @@ export function NodeArrowHandles() {
244
260
  if ('shape' in descriptor && descriptor.shape === 'move-cross') return visible === true
245
261
  return visible !== false
246
262
  })
247
- }, [node, def, descriptorSceneApi])
263
+ }, [node, def, descriptorSceneApi, activeGroup])
248
264
 
249
265
  const shouldRender =
250
266
  Boolean(node && descriptors?.length) &&
@@ -291,6 +307,9 @@ function NodeArrowHandlesForNode({
291
307
  node: AnyNode
292
308
  descriptors: HandleDescriptor[]
293
309
  }) {
310
+ const controlledGroup = useHandleGroup((s) =>
311
+ s.active?.nodeId === node.id ? s.active.group : null,
312
+ )
294
313
  const parentId = node.parentId ?? null
295
314
 
296
315
  const portalMode: HandlePortal = descriptors.some((d) => d.portal === 'grandparent')
@@ -476,7 +495,8 @@ function NodeArrowHandlesForNode({
476
495
  }
477
496
  // Arrows tagged with a latch group stay hidden until that group is open.
478
497
  const latchGroup = descriptor.kind === 'linear-resize' ? descriptor.latchGroup : undefined
479
- if (latchGroup && !openLatchGroups.has(latchGroup)) return null
498
+ if (latchGroup && latchGroup !== controlledGroup && !openLatchGroups.has(latchGroup))
499
+ return null
480
500
  return (
481
501
  <ArrowHandle
482
502
  activeIndex={activeIndex}
@@ -502,39 +522,6 @@ function NodeArrowHandlesForNode({
502
522
  )
503
523
  }
504
524
 
505
- // Offset, in node-local frame, that compensates for `position` drift on
506
- // the mesh during an asymmetric resize. Width/length L+R recompute
507
- // `position` so the anchored edge stays world-fixed — the renderer
508
- // follows that override, the ride object moves, and every arrow under
509
- // it would drift along with the mesh center. Subtracting this offset
510
- // from a non-active arrow's local placement undoes that drift so it
511
- // stays at its pre-drag world position.
512
- //
513
- // Rotation drags don't change `position`, so the offset collapses to
514
- // zero and non-active arrows naturally rotate with the mesh — which is
515
- // the desired behaviour (the whole rig rotates as a unit).
516
- function computeFreezeOffset(liveNode: AnyNode, preDragNode: AnyNode): [number, number, number] {
517
- // Not every node in the union carries a `position` field (sites are the
518
- // notable holdout — they don't have handles anyway, but TypeScript still
519
- // requires us to discriminate). Guarded access keeps the freeze logic
520
- // safe for the few node kinds that lack the field.
521
- const liveP = (liveNode as { position?: readonly [number, number, number] }).position ?? [0, 0, 0]
522
- const preP = (preDragNode as { position?: readonly [number, number, number] }).position ?? [
523
- 0, 0, 0,
524
- ]
525
- const deltaWorldX = liveP[0] - preP[0]
526
- const deltaWorldY = liveP[1] - preP[1]
527
- const deltaWorldZ = liveP[2] - preP[2]
528
- const rotY = (preDragNode as { rotation?: number }).rotation ?? 0
529
- // World → node-local for Y-axis rotation by rotY (THREE.Object3D
530
- // rotation-y convention): inverse is rotation by -rotY around +Y.
531
- const cosR = Math.cos(rotY)
532
- const sinR = Math.sin(rotY)
533
- const deltaLocalX = cosR * deltaWorldX - sinR * deltaWorldZ
534
- const deltaLocalZ = sinR * deltaWorldX + cosR * deltaWorldZ
535
- return [deltaLocalX, deltaWorldY, deltaLocalZ]
536
- }
537
-
538
525
  function ArrowHandle({
539
526
  descriptor,
540
527
  liveNode,
@@ -592,6 +579,17 @@ function ArrowHandle({
592
579
  />
593
580
  )
594
581
  }
582
+ if (descriptor.kind === 'corner-radius') {
583
+ return (
584
+ <CornerRadiusKnob
585
+ descriptor={descriptor}
586
+ dragControls={dragControls}
587
+ handleIndex={handleIndex}
588
+ node={placementNode}
589
+ rideObject={rideObject}
590
+ />
591
+ )
592
+ }
595
593
  if (descriptor.kind === 'tap-action') {
596
594
  // Tap-action handles (fence side-move arrows, corner pickers) aren't
597
595
  // resize handles, so the freeze-at-pre-drag mechanism — which only
@@ -605,6 +603,119 @@ function ArrowHandle({
605
603
  return null
606
604
  }
607
605
 
606
+ const CORNER_RADIUS_DOT_INSET = 0.2
607
+ const CORNER_RADIUS_POSITION_FACTOR = Math.SQRT2 - 1
608
+
609
+ function CornerRadiusKnob({
610
+ descriptor,
611
+ node,
612
+ handleIndex,
613
+ dragControls,
614
+ rideObject,
615
+ }: {
616
+ descriptor: CornerRadiusHandle<AnyNode>
617
+ node: AnyNode
618
+ handleIndex: number
619
+ dragControls: HandleDragControls
620
+ rideObject: Object3D
621
+ }) {
622
+ const [isHovered, setIsHovered] = useState(false)
623
+ const [isDragging, setIsDragging] = useState(false)
624
+ const { camera } = useThree()
625
+ const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
626
+ const [sx, sy] = descriptor.corner
627
+ const radius = descriptor.currentValue(node)
628
+ const inset = CORNER_RADIUS_DOT_INSET + CORNER_RADIUS_POSITION_FACTOR * radius
629
+ const position: [number, number, number] = [
630
+ sx * (descriptor.width(node) / 2 - inset / Math.SQRT2),
631
+ sy * (descriptor.height(node) / 2 - inset / Math.SQRT2),
632
+ 0,
633
+ ]
634
+
635
+ const activate = useHandleDrag({
636
+ kind: 'drag',
637
+ cursor: 'grabbing',
638
+ dragControls,
639
+ handleIndex,
640
+ node,
641
+ rideObject,
642
+ setIsDragging,
643
+ onStart: ({ initialNode, nodeId, rideObject: dragRideObject, sceneApi: dragSceneApi }) => {
644
+ dragRideObject.updateMatrixWorld(true)
645
+ const worldMatrix = dragRideObject.matrixWorld.clone()
646
+ const inverseWorldMatrix = worldMatrix.clone().invert()
647
+ const center = new Vector3().setFromMatrixPosition(worldMatrix)
648
+ const normal = new Vector3().setFromMatrixColumn(worldMatrix, 2).normalize()
649
+ const plane = new Plane().setFromNormalAndCoplanarPoint(normal, center)
650
+ const maxRadius = resolveBound(
651
+ descriptor.max,
652
+ Number.POSITIVE_INFINITY,
653
+ initialNode,
654
+ dragSceneApi,
655
+ )
656
+ const preview = descriptor.createPreview?.(initialNode as never)
657
+
658
+ return {
659
+ markDirty: !preview,
660
+ commit: preview ? (patch) => preview.commit(patch as never) : undefined,
661
+ onBegin: () => {
662
+ useInteractionScope.getState().begin({
663
+ kind: 'handle-drag',
664
+ nodeId,
665
+ handle: 'Corner radius',
666
+ })
667
+ },
668
+ onCancel: () => preview?.cancel(),
669
+ onEnd: () => {
670
+ useInteractionScope.getState().endIf((scope) => scope.kind === 'handle-drag')
671
+ },
672
+ move: ({ event, modifiers, intersectPlane }) => {
673
+ const hit = new Vector3()
674
+ if (!intersectPlane(event.clientX, event.clientY, plane, hit)) return null
675
+ hit.applyMatrix4(inverseWorldMatrix)
676
+ const diagonalDistance =
677
+ (descriptor.width(initialNode) / 2 +
678
+ descriptor.height(initialNode) / 2 -
679
+ sx * hit.x -
680
+ sy * hit.y) /
681
+ Math.SQRT2
682
+ const next = Math.min(
683
+ maxRadius,
684
+ Math.max(
685
+ 0,
686
+ (diagonalDistance - CORNER_RADIUS_DOT_INSET) / CORNER_RADIUS_POSITION_FACTOR,
687
+ ),
688
+ )
689
+ const patch = descriptor.apply(initialNode as never, next, dragSceneApi, modifiers)
690
+ preview?.preview(patch as never)
691
+ return patch as Partial<AnyNode>
692
+ },
693
+ }
694
+ },
695
+ })
696
+
697
+ return (
698
+ <HandleArrow
699
+ activeCursor="grabbing"
700
+ cursor="grab"
701
+ hover={isHovered || isDragging}
702
+ hoverScale={1.3}
703
+ onHoverChange={setIsHovered}
704
+ onPointerDown={activate}
705
+ placement={{ position, rotation: [0, 0, 0], baseScale: zoom * 0.42 }}
706
+ round
707
+ shape="corner-picker"
708
+ >
709
+ {isHovered || isDragging ? (
710
+ <DimensionLabel
711
+ position={[0, 0.22, 0]}
712
+ text={formatDimension(radius, useViewer.getState().unit)}
713
+ />
714
+ ) : null}
715
+ </HandleArrow>
716
+ )
717
+ }
718
+
608
719
  function pickCursor(descriptor: LinearResizeHandle<AnyNode> | RadialResizeHandle<AnyNode>): Cursor {
609
720
  if (descriptor.kind === 'linear-resize' && descriptor.cursor) return descriptor.cursor
610
721
  return descriptor.axis === 'y' ? 'ns-resize' : 'ew-resize'
@@ -666,7 +777,7 @@ function LinearArrow({
666
777
  // for the edge being resized); cleared when the drag ends.
667
778
  const onDrag = descriptor.kind === 'linear-resize' ? descriptor.onDrag : undefined
668
779
  const placementSceneApi = useMemo(() => createSceneApi(useScene), [])
669
- const basePosition = descriptor.placement.position(node, placementSceneApi)
780
+ const basePosition = resolveLinearHandlePosition(descriptor, node, placementSceneApi, baseScale)
670
781
  // `freezeOffset` (in node-local frame) cancels the mesh's `position`
671
782
  // drift while another arrow is being dragged — `basePosition` is
672
783
  // computed against the pre-drag snapshot, then we subtract the offset
@@ -723,21 +834,17 @@ function LinearArrow({
723
834
  initialNode,
724
835
  nodeId,
725
836
  sceneApi,
726
- initialModifiers: { altKey: event.nativeEvent.altKey },
837
+ initialModifiers: {
838
+ altKey: event.nativeEvent.altKey,
839
+ shiftKey: event.nativeEvent.shiftKey,
840
+ },
727
841
  })
728
842
  : null
729
843
  const overrideId = linearBinding?.overrideId ?? nodeId
730
844
  const initialValue = descriptor.currentValue(initialNode)
731
845
  const minBound = resolveBound(descriptor.min, Number.NEGATIVE_INFINITY, initialNode, sceneApi)
732
846
  const maxBound = resolveBound(descriptor.max, Number.POSITIVE_INFINITY, initialNode, sceneApi)
733
- const factor =
734
- descriptor.kind === 'radial-resize'
735
- ? 1
736
- : descriptor.anchor === 'center'
737
- ? 2
738
- : descriptor.anchor === 'min'
739
- ? 1
740
- : -1
847
+ const factor = linearResizeFactor(descriptor)
741
848
 
742
849
  // Last value an emitted resize tick fired at — a new tick fires only
743
850
  // when the (snapped + clamped) value actually changes, so the cue
@@ -806,23 +913,7 @@ function LinearArrow({
806
913
  },
807
914
  })
808
915
 
809
- // For axis === 'y' (vertical handles), tilt the chevron up via local
810
- // X+Z rotation chain matching DoorHeightArrowHandle. When the handle
811
- // sits below the node (placement Y < 0, e.g. window bottom arrow),
812
- // flip the Z rotation so the chevron points outward (downward).
813
- //
814
- // For axis === 'x' with `faceNormal` (wall-mounted opening width arrows),
815
- // roll the blade 90° about its own pointing (X) axis so it stands up from
816
- // the horizontal XZ plane into the node's facing plane (XY = the wall
817
- // face) — otherwise the blade is seen edge-on from the front.
818
- const faceNormalX =
819
- descriptor.kind === 'linear-resize' && descriptor.axis === 'x' && descriptor.faceNormal === true
820
- const innerRotation: [number, number, number] =
821
- descriptor.axis === 'y'
822
- ? [0, Math.PI / 2, position[1] < 0 ? -Math.PI / 2 : Math.PI / 2]
823
- : faceNormalX
824
- ? [Math.PI / 2, 0, 0]
825
- : [0, 0, 0]
916
+ const innerRotation = resolveLinearHandleRotation(descriptor, position)
826
917
 
827
918
  // Optional guide decoration — linear handles use it for curved-stair
828
919
  // width / inner-radius rings; radial handles use it for the column's
@@ -1260,10 +1351,7 @@ function ArcArrow({
1260
1351
  }
1261
1352
  const initialAngle = angleOf(hitWorld)
1262
1353
 
1263
- // Advertise the rotate interaction so the contextual HUD can surface the
1264
- // Shift = free-rotation toggle (the angle-step bypass below). Resize
1265
- // handles route a measurement label here; rotate gets a sentinel label so
1266
- // the HUD shows the rotate hint, not a dimension pill.
1354
+ // A distinct label selects rotation snapping instead of resize measurements.
1267
1355
  if (isRotateShape) {
1268
1356
  useInteractionScope
1269
1357
  .getState()
@@ -1285,7 +1373,12 @@ function ArcArrow({
1285
1373
  while (delta > Math.PI) delta -= 2 * Math.PI
1286
1374
  while (delta < -Math.PI) delta += 2 * Math.PI
1287
1375
 
1288
- if (!moveEvent.shiftKey && descriptor.shape === 'rotate') {
1376
+ if (
1377
+ !descriptor.continuous &&
1378
+ !moveEvent.altKey &&
1379
+ isAngleSnapActive() &&
1380
+ descriptor.shape === 'rotate'
1381
+ ) {
1289
1382
  delta = Math.round(delta / DEFAULT_ANGLE_STEP) * DEFAULT_ANGLE_STEP
1290
1383
  }
1291
1384
 
@@ -0,0 +1,53 @@
1
+ 'use client'
2
+
3
+ import { type AnyNodeId, nodeRegistry, useScene } from '@pascal-app/core'
4
+ import { useViewer } from '@pascal-app/viewer'
5
+ import { type ComponentType, lazy, Suspense } from 'react'
6
+ import { useShallow } from 'zustand/react/shallow'
7
+ import { getFloorplanNodeExtension } from '../../lib/floorplan/floorplan-extension'
8
+
9
+ type Loader = () => Promise<{ default: ComponentType }>
10
+ const lazyCache = new WeakMap<Loader, ComponentType>()
11
+
12
+ function contribution(kind: string): ComponentType | null {
13
+ const loader = getFloorplanNodeExtension(nodeRegistry.get(kind))?.actionMenu?.actions
14
+ if (!loader) return null
15
+ const cached = lazyCache.get(loader)
16
+ if (cached) return cached
17
+ const component = lazy(loader)
18
+ lazyCache.set(loader, component)
19
+ return component
20
+ }
21
+
22
+ /**
23
+ * The buttons kinds add to the action menu for selections holding them
24
+ * (`extensions['pascal:editor/floorplan'].actionMenu.actions`), in 2D and 3D
25
+ * alike. Each contribution reads the selection and decides its own visibility.
26
+ */
27
+ export function RegistryActionContributions() {
28
+ const selectedIds = useViewer((s) => s.selection.selectedIds)
29
+ const kinds = useScene(
30
+ useShallow((s) =>
31
+ Array.from(
32
+ new Set(
33
+ selectedIds.flatMap((id) => {
34
+ const type = s.nodes[id as AnyNodeId]?.type
35
+ return type ? [type] : []
36
+ }),
37
+ ),
38
+ ).sort(),
39
+ ),
40
+ )
41
+ return (
42
+ <>
43
+ {kinds.map((kind) => {
44
+ const Contribution = contribution(kind)
45
+ return Contribution ? (
46
+ <Suspense fallback={null} key={kind}>
47
+ <Contribution />
48
+ </Suspense>
49
+ ) : null
50
+ })}
51
+ </>
52
+ )
53
+ }
@@ -81,6 +81,12 @@ import {
81
81
  shouldPreserveSelectedRoofHostTarget,
82
82
  } from '../../lib/selection-routing'
83
83
  import { emitDeleteSFX, sfxEmitter } from '../../lib/sfx-bus'
84
+ import {
85
+ cancelPendingZonePaint,
86
+ paintZoneMembership,
87
+ zoneAtLevelPoint,
88
+ zoneAtWorldPoint,
89
+ } from '../../lib/units'
84
90
  import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback'
85
91
  import useEditor, { type MaterialTargetRole } from './../../store/use-editor'
86
92
  import useInteractionScope, {
@@ -1609,6 +1615,23 @@ export const SelectionManager = () => {
1609
1615
  // the click falls through to the item underneath.
1610
1616
  if (node.type === 'ceiling' && !event.viaHandle) return
1611
1617
 
1618
+ // Unit focus turns clicks inside a zone into the paint gesture:
1619
+ // membership toggles, the zone stays unselected, focus stays.
1620
+ const focusedUnitId = useViewer.getState().focusedUnitId
1621
+ if (focusedUnitId) {
1622
+ const zone =
1623
+ node.type === 'zone' ? node : zoneAtWorldPoint(event.position[0], event.position[2])
1624
+ if (zone) {
1625
+ event.stopPropagation()
1626
+ clickHandledRef.current = true
1627
+ setTimeout(() => {
1628
+ clickHandledRef.current = false
1629
+ }, 50)
1630
+ paintZoneMembership(focusedUnitId, zone.id)
1631
+ return
1632
+ }
1633
+ }
1634
+
1612
1635
  let currentPhase = useEditor.getState().phase
1613
1636
  let currentStructureLayer = useEditor.getState().structureLayer
1614
1637
  const selectedIdsBeforeRouting = useViewer.getState().selection.selectedIds
@@ -1809,6 +1832,14 @@ export const SelectionManager = () => {
1809
1832
  if (useInteractionScope.getState().scope.kind === 'mesh-editing') return
1810
1833
  const nativeEvent = event.nativeEvent
1811
1834
  if (nativeEvent?.metaKey || nativeEvent?.ctrlKey || nativeEvent?.shiftKey) return
1835
+ // Unit focus: a ground click inside a zone paints it; elsewhere it
1836
+ // leaves focus and the unit selection alone.
1837
+ const focusedUnitId = useViewer.getState().focusedUnitId
1838
+ if (focusedUnitId) {
1839
+ const zone = zoneAtLevelPoint(event.localPosition[0], event.localPosition[2])
1840
+ if (zone) paintZoneMembership(focusedUnitId, zone.id)
1841
+ return
1842
+ }
1812
1843
  const { phase, structureLayer } = useEditor.getState()
1813
1844
  const activeStrategy = SELECTION_STRATEGIES[phase]
1814
1845
  if (activeStrategy) activeStrategy.handleDeselect()
@@ -1919,6 +1950,24 @@ export const SelectionManager = () => {
1919
1950
  }
1920
1951
  }
1921
1952
 
1953
+ // While a unit is focused a double-click inside a zone selects that
1954
+ // zone (focus stays); the two clicks before it cancel each other's paint.
1955
+ if (useViewer.getState().focusedUnitId) {
1956
+ const zone =
1957
+ node.type === 'zone' ? node : zoneAtWorldPoint(event.position[0], event.position[2])
1958
+ if (zone) {
1959
+ event.stopPropagation()
1960
+ cancelPendingZonePaint(zone.id)
1961
+ SELECTION_STRATEGIES.structure?.handleSelect(
1962
+ zone,
1963
+ event.nativeEvent,
1964
+ modifierKeysRef.current,
1965
+ [],
1966
+ )
1967
+ return
1968
+ }
1969
+ }
1970
+
1922
1971
  if (node.type === 'zone') {
1923
1972
  return
1924
1973
  }
@@ -13,6 +13,7 @@ import {
13
13
  GRID_LAYER,
14
14
  getVisibleWallMaterials,
15
15
  heroCameraPose,
16
+ refreshIsolation,
16
17
  SNAPSHOT_MAX_EDGE,
17
18
  SNAPSHOT_MIME,
18
19
  SNAPSHOT_QUALITY,
@@ -22,6 +23,7 @@ import {
22
23
  THUMBNAIL_WIDTH,
23
24
  temporarilyHideNodeTypes,
24
25
  temporarilyShowShadowOnly,
26
+ useSceneAtmosphere,
25
27
  useViewer,
26
28
  } from '@pascal-app/viewer'
27
29
  import type { CameraControls } from '@react-three/drei'
@@ -68,6 +70,7 @@ function clampSnapshotSize(width: number, height: number): { w: number; h: numbe
68
70
  export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorProps) => {
69
71
  const gl = useThree((state) => state.gl)
70
72
  const scene = useThree((state) => state.scene)
73
+ const atmosphere = useSceneAtmosphere()
71
74
  const getThree = useThree((state) => state.get)
72
75
  const controls = useThree((state) => state.controls) as CameraControls | null
73
76
  const isGenerating = useRef(false)
@@ -82,7 +85,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
82
85
  onThumbnailCaptureRef.current = onThumbnailCapture
83
86
  }, [onThumbnailCapture])
84
87
 
85
- // Build the thumbnail camera, SSGI pipeline, and render target once — reused on every capture.
88
+ // Reuse the camera and snapshot graph until the active atmosphere source changes.
86
89
  useEffect(() => {
87
90
  captureVersion.current += 1
88
91
  const cam = new THREE.PerspectiveCamera(60, THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT, 0.1, 1000)
@@ -97,6 +100,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
97
100
  renderer: gl as unknown as WebGPURenderer,
98
101
  scene,
99
102
  camera: cam,
103
+ atmosphere,
100
104
  })
101
105
  if (!mounted) {
102
106
  pipeline?.dispose()
@@ -114,7 +118,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
114
118
  pipelineRef.current?.dispose()
115
119
  pipelineRef.current = null
116
120
  }
117
- }, [gl, scene])
121
+ }, [gl, scene, atmosphere])
118
122
 
119
123
  const generate = useCallback(
120
124
  async (event: ThumbnailGenerateEvent) => {
@@ -274,6 +278,11 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
274
278
  }
275
279
  }
276
280
 
281
+ // Geometry and presentation systems may have mounted new meshes
282
+ // since the viewport's last frame. Apply the same isolation to
283
+ // those meshes before either snapshot render path submits them,
284
+ // and restore fog afterwards so the atmosphere never leaks in.
285
+ restore(refreshIsolation(scene))
277
286
  if (pipeline) return pipeline.capture({ captureMode, cropRegion, standardSize })
278
287
  gl.render(scene, thumbnailCamera)
279
288
  return undefined