@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.
- package/package.json +12 -6
- package/src/components/editor/bake-exporter.tsx +4 -0
- package/src/components/editor/bake-thumbnail.tsx +4 -1
- package/src/components/editor/custom-camera-controls.tsx +22 -74
- package/src/components/editor/editor-layout-mobile.tsx +1 -0
- package/src/components/editor/floating-action-menu.tsx +14 -1
- package/src/components/editor/floorplan-panel.tsx +233 -166
- package/src/components/editor/grid.tsx +31 -7
- package/src/components/editor/group-actions.ts +5 -9
- package/src/components/editor/group-floating-action-menu.tsx +19 -9
- package/src/components/editor/group-move-3d.ts +4 -6
- package/src/components/editor/group-rotate-handle.tsx +81 -35
- package/src/components/editor/group-selection-box-3d.tsx +25 -17
- package/src/components/editor/group-transform-shared.test.ts +126 -1
- package/src/components/editor/group-transform-shared.ts +79 -73
- package/src/components/editor/handles/handle-arrow.tsx +72 -2
- package/src/components/editor/handles/handle-placement.test.ts +111 -0
- package/src/components/editor/handles/handle-placement.ts +69 -0
- package/src/components/editor/handles/linear-resize-drag.ts +13 -2
- package/src/components/editor/handles/use-handle-drag.ts +80 -8
- package/src/components/editor/index.tsx +36 -13
- package/src/components/editor/node-action-menu.tsx +2 -0
- package/src/components/editor/node-arrow-handles.tsx +162 -69
- package/src/components/editor/registry-action-contributions.tsx +53 -0
- package/src/components/editor/selection-manager.tsx +49 -0
- package/src/components/editor/thumbnail-generator.tsx +11 -2
- package/src/components/editor/wall-move-side-handles.tsx +71 -21
- package/src/components/editor/wall-opening-highlights.tsx +2 -3
- package/src/components/editor-2d/floorplan-group-move.tsx +17 -24
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +33 -12
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +154 -49
- package/src/components/editor-2d/renderers/floorplan-draft-wall-measurement.tsx +166 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +49 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +113 -24
- package/src/components/procedural-items/inspector.tsx +223 -0
- package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
- package/src/components/systems/zone/zone-system.tsx +8 -4
- package/src/components/tools/__fixtures__/hosting.ts +10 -0
- package/src/components/tools/item/item-surface-placement.test.ts +169 -0
- package/src/components/tools/item/item-surface-strategy-resolver.test.ts +204 -0
- package/src/components/tools/item/placement-strategies.ts +212 -200
- package/src/components/tools/item/placement-types.ts +4 -0
- package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
- package/src/components/tools/item/use-draft-node.ts +136 -47
- package/src/components/tools/item/use-placement-coordinator.tsx +391 -157
- package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
- package/src/components/tools/registry/item-surface-move.test.ts +509 -0
- package/src/components/tools/registry/item-surface-move.ts +463 -0
- package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
- package/src/components/tools/registry/move-registry-node-tool.tsx +237 -78
- package/src/components/tools/shared/affordance-dispatch.test.ts +24 -0
- package/src/components/tools/shared/affordance-dispatch.ts +13 -0
- package/src/components/tools/shared/draft-measurement-label.tsx +38 -0
- package/src/components/tools/shared/pointer-support-cap.test.ts +27 -0
- package/src/components/tools/shared/pointer-support-cap.ts +27 -13
- package/src/components/tools/shared/shelf-stickiness.ts +54 -0
- package/src/components/tools/shared/surface-hit.test.ts +56 -0
- package/src/components/tools/shared/surface-hit.ts +23 -0
- package/src/components/tools/shared/surface-rejection.test.tsx +107 -0
- package/src/components/tools/shared/surface-rejection.tsx +43 -0
- package/src/components/tools/tool-manager.tsx +36 -3
- package/src/components/tools/zone/zone-tool.tsx +13 -4
- package/src/components/ui/command-palette/editor-commands.tsx +54 -1
- package/src/components/ui/controls/material-paint-panel.tsx +6 -67
- package/src/components/ui/controls/material-picker.tsx +5 -67
- package/src/components/ui/controls/metric-control.tsx +29 -13
- package/src/components/ui/controls/slider-control.tsx +87 -35
- package/src/components/ui/floating-level-selector.tsx +81 -5
- package/src/components/ui/helpers/helper-manager.tsx +24 -6
- package/src/components/ui/helpers/registered-tool-helper.tsx +2 -32
- package/src/components/ui/item-catalog/item-catalog.tsx +5 -30
- package/src/components/ui/panels/multi-height-mode.tsx +27 -21
- package/src/components/ui/panels/multi-parametric-inspector.tsx +6 -3
- package/src/components/ui/panels/parametric-field-control.tsx +1 -1
- package/src/components/ui/panels/parametric-inspector.tsx +9 -17
- package/src/components/ui/panels/reference-panel.tsx +3 -3
- package/src/components/ui/primitives/checkbox.tsx +34 -0
- package/src/components/ui/primitives/shortcut-token.tsx +4 -0
- package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +669 -336
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +31 -4
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +30 -11
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +106 -4
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +6 -1
- package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
- package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
- package/src/components/ui/snap-target-badge.tsx +2 -0
- package/src/components/viewer/floorplan-preview-visibility.test.ts +35 -0
- package/src/components/viewer/floorplan-preview-visibility.ts +19 -0
- package/src/components/viewer/floorplan-preview.tsx +1 -12
- package/src/components/viewer/viewer-scene-header.tsx +3 -0
- package/src/components/viewer/viewer-units-panel.tsx +116 -0
- package/src/components/viewer-zone-system.tsx +7 -2
- package/src/hooks/use-auto-save.integration.test.tsx +197 -0
- package/src/hooks/use-auto-save.ts +21 -6
- package/src/hooks/use-ceiling-events.test.ts +25 -2
- package/src/hooks/use-ceiling-events.ts +7 -1
- package/src/hooks/use-grid-events.test.ts +2 -0
- package/src/hooks/use-grid-events.ts +16 -11
- package/src/hooks/use-keyboard.test.ts +7 -0
- package/src/hooks/use-keyboard.ts +43 -28
- package/src/index.tsx +68 -3
- package/src/lib/catalog-panel-model.ts +39 -0
- package/src/lib/catalog-selection.test.ts +27 -0
- package/src/lib/catalog-selection.ts +8 -0
- package/src/lib/contextual-help.test.ts +27 -1
- package/src/lib/contextual-help.ts +16 -9
- package/src/lib/export-texture-utils.test.ts +167 -0
- package/src/lib/export-texture-utils.ts +121 -0
- package/src/lib/export-user-data.test.ts +48 -0
- package/src/lib/export-user-data.ts +69 -0
- package/src/lib/floorplan/floorplan-export.test.ts +142 -2
- package/src/lib/floorplan/floorplan-export.tsx +9 -3
- package/src/lib/floorplan/floorplan-extension.ts +16 -0
- package/src/lib/floorplan/floorplan-readonly.ts +4 -0
- package/src/lib/fresh-planar-placement.ts +173 -33
- package/src/lib/glb-export-serialize.test.ts +280 -0
- package/src/lib/glb-export.test.ts +265 -16
- package/src/lib/glb-export.ts +197 -37
- package/src/lib/grid-frame.test.ts +19 -0
- package/src/lib/grid-frame.ts +18 -0
- package/src/lib/interaction/scope.ts +9 -1
- package/src/lib/keyboard-pan.test.ts +62 -0
- package/src/lib/keyboard-pan.ts +89 -0
- package/src/lib/level-duplication.test.ts +73 -0
- package/src/lib/level-duplication.ts +32 -2
- package/src/lib/linear-display.test.ts +59 -0
- package/src/lib/linear-display.ts +39 -0
- package/src/lib/local-guide-image.test.ts +24 -0
- package/src/lib/local-guide-image.ts +34 -1
- package/src/lib/material-catalog-model.ts +72 -0
- package/src/lib/material-paint-panel-model.ts +76 -0
- package/src/lib/node-action-movement.ts +21 -0
- package/src/lib/overlay-transform.test.ts +28 -0
- package/src/lib/overlay-transform.ts +12 -0
- package/src/lib/panel-rows.ts +75 -0
- package/src/lib/panel-tool-options.test.ts +34 -0
- package/src/lib/panel-tool-options.ts +46 -0
- package/src/lib/parametric-node-update.ts +37 -0
- package/src/lib/portable-export.test.ts +89 -0
- package/src/lib/portable-export.ts +84 -11
- package/src/lib/print-shell-compiler-manifold-core.ts +59 -10
- package/src/lib/print-shell-compiler-manifold-worker.ts +14 -1
- package/src/lib/print-shell-compiler-manifold.worker.ts +1 -1
- package/src/lib/print-shell-compiler-protocol.ts +8 -0
- package/src/lib/scene-clipboard.ts +1 -1
- package/src/lib/scene.test.ts +99 -0
- package/src/lib/scene.ts +21 -3
- package/src/lib/send-to-app.test.ts +108 -0
- package/src/lib/send-to-app.ts +121 -0
- package/src/lib/sfx/movement-tick.ts +37 -0
- package/src/lib/snapping-mode.test.ts +13 -2
- package/src/lib/snapping-mode.ts +11 -6
- package/src/lib/spatial-drag-plane.test.ts +53 -0
- package/src/lib/spatial-drag-plane.ts +19 -0
- package/src/lib/spatial-pointer-input.test.ts +72 -0
- package/src/lib/spatial-pointer-input.ts +62 -0
- package/src/lib/spatial-snap.test.ts +47 -0
- package/src/lib/surface-attachment.ts +77 -0
- package/src/lib/terrain-panel-model.ts +117 -0
- package/src/lib/units.ts +254 -0
- package/src/lib/usdz-export.ts +43 -16
- package/src/lib/use-linear-display.ts +8 -32
- package/src/lib/world-grid-snap.test.ts +48 -0
- package/src/lib/world-grid-snap.ts +9 -5
- package/src/store/rotation-snapping.test.ts +53 -0
- package/src/store/use-editor.tsx +10 -2
- package/src/store/use-floorplan-draft-preview.ts +10 -1
- package/src/store/use-handle-group.ts +4 -0
- package/src/store/use-interaction-scope.ts +155 -6
- 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, {
|
|
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 {
|
|
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))
|
|
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
|
|
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: {
|
|
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
|
-
|
|
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
|
-
//
|
|
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 (
|
|
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
|
-
//
|
|
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
|