@pascal-app/editor 1.0.0 → 1.0.1
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 +7 -6
- package/src/components/editor/bake-exporter.tsx +4 -0
- package/src/components/editor/bake-thumbnail.tsx +4 -1
- package/src/components/editor/floating-action-menu.tsx +14 -1
- package/src/components/editor/floorplan-panel.tsx +23 -3
- 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 +5 -2
- package/src/components/editor/index.tsx +12 -4
- package/src/components/editor/node-arrow-handles.tsx +21 -62
- package/src/components/editor/selection-manager.tsx +49 -0
- package/src/components/editor/thumbnail-generator.tsx +11 -2
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +46 -3
- package/src/components/procedural-items/inspector.tsx +223 -0
- package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
- package/src/components/tools/item/item-surface-placement.test.ts +175 -0
- package/src/components/tools/item/item-surface-strategy-resolver.test.ts +210 -0
- package/src/components/tools/item/placement-strategies.ts +169 -169
- 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 +95 -45
- package/src/components/tools/item/use-placement-coordinator.tsx +247 -106
- package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
- package/src/components/tools/registry/item-surface-move.test.ts +501 -0
- package/src/components/tools/registry/item-surface-move.ts +441 -0
- package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
- package/src/components/tools/registry/move-registry-node-tool.tsx +215 -70
- package/src/components/tools/shared/pointer-support-cap.ts +11 -9
- 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 +106 -0
- package/src/components/tools/shared/surface-rejection.tsx +29 -0
- 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/metric-control.tsx +29 -13
- package/src/components/ui/controls/slider-control.tsx +25 -18
- package/src/components/ui/floating-level-selector.tsx +81 -5
- 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 +6 -2
- package/src/components/ui/panels/reference-panel.tsx +3 -3
- package/src/components/ui/primitives/checkbox.tsx +34 -0
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +120 -3
- 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 +29 -10
- 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/viewer-scene-header.tsx +3 -0
- package/src/components/viewer/viewer-units-panel.tsx +116 -0
- package/src/hooks/use-ceiling-events.test.ts +24 -2
- package/src/hooks/use-ceiling-events.ts +5 -1
- package/src/hooks/use-keyboard.ts +3 -1
- package/src/index.tsx +5 -0
- 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 +139 -1
- package/src/lib/floorplan/floorplan-export.tsx +9 -3
- package/src/lib/floorplan/floorplan-readonly.ts +4 -0
- package/src/lib/fresh-planar-placement.ts +13 -5
- package/src/lib/glb-export-serialize.test.ts +280 -0
- package/src/lib/glb-export.test.ts +206 -0
- package/src/lib/glb-export.ts +171 -24
- 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/node-action-movement.ts +21 -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/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/surface-attachment.ts +77 -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/store/use-handle-group.ts +4 -0
- package/src/store/use-send-to-app.ts +21 -0
package/package.json
CHANGED
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pascal-app/editor",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"description": "Pascal building editor component",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"exports": {
|
|
8
8
|
".": "./src/index.tsx",
|
|
9
|
-
"./catalog": "./src/components/ui/item-catalog/catalog-items.tsx"
|
|
9
|
+
"./catalog": "./src/components/ui/item-catalog/catalog-items.tsx",
|
|
10
|
+
"./procedural-items": "./src/components/procedural-items/inspector.tsx"
|
|
10
11
|
},
|
|
11
12
|
"scripts": {
|
|
12
13
|
"check-types": "tsgo --noEmit",
|
|
13
14
|
"test": "bun test src"
|
|
14
15
|
},
|
|
15
16
|
"peerDependencies": {
|
|
16
|
-
"@pascal-app/core": "^1.0.
|
|
17
|
-
"@pascal-app/viewer": "^1.0.
|
|
17
|
+
"@pascal-app/core": "^1.0.1",
|
|
18
|
+
"@pascal-app/viewer": "^1.0.1",
|
|
18
19
|
"@react-three/drei": "^10",
|
|
19
20
|
"@react-three/fiber": "^9",
|
|
20
21
|
"next": ">=15",
|
|
@@ -61,8 +62,8 @@
|
|
|
61
62
|
"zustand": "^5.0.11"
|
|
62
63
|
},
|
|
63
64
|
"devDependencies": {
|
|
64
|
-
"@pascal-app/core": "^1.0.
|
|
65
|
-
"@pascal-app/viewer": "^1.0.
|
|
65
|
+
"@pascal-app/core": "^1.0.1",
|
|
66
|
+
"@pascal-app/viewer": "^1.0.1",
|
|
66
67
|
"@pascal/typescript-config": "*",
|
|
67
68
|
"@react-three/test-renderer": "^9.1.0",
|
|
68
69
|
"@types/blob-stream": "^0.1.33",
|
|
@@ -32,6 +32,10 @@ export function BakeExporter({
|
|
|
32
32
|
const buffer = await exportSceneToGlb(sceneGroup, useScene.getState().nodes, {
|
|
33
33
|
textures: 'reference',
|
|
34
34
|
purpose: 'viewer',
|
|
35
|
+
// The bake worker owns the deadline (BAKE_CAPTURE_TIMEOUT_MS); under
|
|
36
|
+
// SwiftShader a moderate scene can legitimately outlast the
|
|
37
|
+
// interactive default.
|
|
38
|
+
timeoutMs: Number.POSITIVE_INFINITY,
|
|
35
39
|
})
|
|
36
40
|
onComplete(buffer)
|
|
37
41
|
} catch (err) {
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
GRID_LAYER,
|
|
7
7
|
heroCameraPose,
|
|
8
8
|
temporarilyHideNodeTypes,
|
|
9
|
+
useSceneAtmosphere,
|
|
9
10
|
useViewer,
|
|
10
11
|
} from '@pascal-app/viewer'
|
|
11
12
|
import { useThree } from '@react-three/fiber'
|
|
@@ -25,6 +26,7 @@ export function BakeThumbnail({
|
|
|
25
26
|
}) {
|
|
26
27
|
const renderer = useThree((state) => state.gl)
|
|
27
28
|
const scene = useThree((state) => state.scene)
|
|
29
|
+
const atmosphere = useSceneAtmosphere()
|
|
28
30
|
const doneRef = useRef(false)
|
|
29
31
|
|
|
30
32
|
useEffect(() => {
|
|
@@ -61,6 +63,7 @@ export function BakeThumbnail({
|
|
|
61
63
|
renderer: renderer as unknown as WebGPURenderer,
|
|
62
64
|
scene,
|
|
63
65
|
camera,
|
|
66
|
+
atmosphere,
|
|
64
67
|
})
|
|
65
68
|
if (!pipeline) {
|
|
66
69
|
onError('thumbnail pipeline failed to build')
|
|
@@ -89,7 +92,7 @@ export function BakeThumbnail({
|
|
|
89
92
|
}
|
|
90
93
|
|
|
91
94
|
void run()
|
|
92
|
-
}, [active, onComplete, onError, renderer, scene])
|
|
95
|
+
}, [active, atmosphere, onComplete, onError, renderer, scene])
|
|
93
96
|
|
|
94
97
|
return null
|
|
95
98
|
}
|
|
@@ -51,6 +51,7 @@ import {
|
|
|
51
51
|
} from '../../lib/fresh-planar-placement'
|
|
52
52
|
import { resolveFloatingActionMenuVisibility } from '../../lib/interaction/overlay-policy'
|
|
53
53
|
import { curveReshapeScope, holeEditScope } from '../../lib/interaction/scope'
|
|
54
|
+
import { duplicateWithoutMove, registryMoveDisabled } from '../../lib/node-action-movement'
|
|
54
55
|
import { playBlockedQuickActionFeedback } from '../../lib/quick-action-feedback'
|
|
55
56
|
import { collectQuickActionNodeScope } from '../../lib/quick-action-nodes'
|
|
56
57
|
import { duplicateRoofSubtree } from '../../lib/roof-duplication'
|
|
@@ -501,6 +502,16 @@ export function FloatingActionMenu() {
|
|
|
501
502
|
if (!node?.parentId) return
|
|
502
503
|
sfxEmitter.emit('sfx:item-pick')
|
|
503
504
|
|
|
505
|
+
if (registryMoveDisabled(node)) {
|
|
506
|
+
try {
|
|
507
|
+
const id = duplicateWithoutMove(node)
|
|
508
|
+
if (id) setSelection({ selectedIds: [id] })
|
|
509
|
+
} catch (error) {
|
|
510
|
+
console.error('Failed to duplicate node', error)
|
|
511
|
+
}
|
|
512
|
+
return
|
|
513
|
+
}
|
|
514
|
+
|
|
504
515
|
if (node.type === 'roof') {
|
|
505
516
|
try {
|
|
506
517
|
duplicateRoofSubtree(node.id as AnyNodeId, { mode: 'move' })
|
|
@@ -805,7 +816,9 @@ export function FloatingActionMenu() {
|
|
|
805
816
|
// `capabilities.movable`, a `floorplanMoveTarget`, or a
|
|
806
817
|
// 3D `affordanceTools.move` mover gets the Move button.
|
|
807
818
|
// Adding a new movable kind never touches this file.
|
|
808
|
-
node && isRegistryMovable(node.type)
|
|
819
|
+
node && isRegistryMovable(node.type) && !registryMoveDisabled(node)
|
|
820
|
+
? handleMove
|
|
821
|
+
: undefined
|
|
809
822
|
}
|
|
810
823
|
onDelete={handleDelete}
|
|
811
824
|
onDuplicate={
|
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
type RoofSegmentNode,
|
|
35
35
|
resolveSlabPlacementElevation,
|
|
36
36
|
resolveTerrainWallConstructionOptions,
|
|
37
|
+
runAsSingleSceneHistoryStep,
|
|
37
38
|
type SiteNode,
|
|
38
39
|
type SlabNode,
|
|
39
40
|
SlabNode as SlabNodeSchema,
|
|
@@ -98,6 +99,7 @@ import { formatLinearMeasurement, linearUnitToMeters } from '../../lib/measureme
|
|
|
98
99
|
import { sfxEmitter } from '../../lib/sfx-bus'
|
|
99
100
|
import { SITE_BOUNDARY_DRAG_LABEL, siteBoundaryHandlesEnabled } from '../../lib/site-boundary'
|
|
100
101
|
import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap'
|
|
102
|
+
import { cancelPendingZonePaint, focusedUnitNode, paintZoneMembership } from '../../lib/units'
|
|
101
103
|
import { cn } from '../../lib/utils'
|
|
102
104
|
import { snapBuildingLocalToWorldGrid } from '../../lib/world-grid-snap'
|
|
103
105
|
import { subscribeNavigationSyncPose } from '../../store/navigation-sync-pose-store'
|
|
@@ -7952,7 +7954,7 @@ export function FloorplanPanel({
|
|
|
7952
7954
|
return null
|
|
7953
7955
|
}
|
|
7954
7956
|
|
|
7955
|
-
const { createNode, nodes } = useScene.getState()
|
|
7957
|
+
const { createNode, updateNode, nodes } = useScene.getState()
|
|
7956
7958
|
const zoneCount = Object.values(nodes).filter((node) => node.type === 'zone').length
|
|
7957
7959
|
const zone = ZoneNodeSchema.parse({
|
|
7958
7960
|
color: PALETTE_COLORS[zoneCount % PALETTE_COLORS.length],
|
|
@@ -7960,9 +7962,15 @@ export function FloorplanPanel({
|
|
|
7960
7962
|
polygon: points.map(([x, z]) => [x, z] as [number, number]),
|
|
7961
7963
|
})
|
|
7962
7964
|
|
|
7963
|
-
|
|
7965
|
+
// Joining the focused unit rides in the zone's own undo step; selecting
|
|
7966
|
+
// the zone would end focus, so a painted unit keeps it instead.
|
|
7967
|
+
const focusedUnit = focusedUnitNode()
|
|
7968
|
+
runAsSingleSceneHistoryStep(useScene, () => {
|
|
7969
|
+
createNode(zone, levelId)
|
|
7970
|
+
if (focusedUnit) updateNode(focusedUnit.id, { members: [...focusedUnit.members, zone.id] })
|
|
7971
|
+
})
|
|
7964
7972
|
sfxEmitter.emit('sfx:structure-build')
|
|
7965
|
-
setSelection({ zoneId: zone.id })
|
|
7973
|
+
if (!focusedUnit) setSelection({ zoneId: zone.id })
|
|
7966
7974
|
return zone.id
|
|
7967
7975
|
},
|
|
7968
7976
|
[levelId, setSelection],
|
|
@@ -10008,6 +10016,18 @@ export function FloorplanPanel({
|
|
|
10008
10016
|
setSelectedReferenceId(null)
|
|
10009
10017
|
|
|
10010
10018
|
if (backgroundSelection.kind === 'select-zone') {
|
|
10019
|
+
// Unit focus: a click paints membership, a double-click selects the
|
|
10020
|
+
// zone and keeps focus (the SVG gets the second click as detail 2).
|
|
10021
|
+
const focusedUnitId = useViewer.getState().focusedUnitId
|
|
10022
|
+
if (focusedUnitId) {
|
|
10023
|
+
if (event.detail >= 2) {
|
|
10024
|
+
cancelPendingZonePaint(backgroundSelection.zoneId)
|
|
10025
|
+
setSelection({ zoneId: backgroundSelection.zoneId })
|
|
10026
|
+
} else {
|
|
10027
|
+
paintZoneMembership(focusedUnitId, backgroundSelection.zoneId)
|
|
10028
|
+
}
|
|
10029
|
+
return
|
|
10030
|
+
}
|
|
10011
10031
|
setSelection({ zoneId: backgroundSelection.zoneId })
|
|
10012
10032
|
return
|
|
10013
10033
|
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test'
|
|
2
|
+
import { type AnyNode, createSceneApi, type LinearResizeHandle, useScene } from '@pascal-app/core'
|
|
3
|
+
import { Euler, Vector3 } from 'three'
|
|
4
|
+
import { createArrowHandleGeometry } from './handle-arrow'
|
|
5
|
+
import {
|
|
6
|
+
computeFreezeOffset,
|
|
7
|
+
resolveLinearHandlePosition,
|
|
8
|
+
resolveLinearHandleRotation,
|
|
9
|
+
} from './handle-placement'
|
|
10
|
+
|
|
11
|
+
const scene = createSceneApi(useScene)
|
|
12
|
+
for (const axis of ['x', 'y', 'z'] as const) {
|
|
13
|
+
test(`${axis} clearance preserves sufficient offsets and keeps even hovered arrow tails clear at different scales`, () => {
|
|
14
|
+
const index = axis === 'x' ? 0 : axis === 'y' ? 1 : 2
|
|
15
|
+
const geometry = createArrowHandleGeometry(true)
|
|
16
|
+
geometry.computeBoundingBox()
|
|
17
|
+
const tail = geometry.boundingBox!.min.x * 1.12
|
|
18
|
+
for (const edge of [0.06, 1.2, -0.4]) {
|
|
19
|
+
const position: [number, number, number] = [0, 0, 0]
|
|
20
|
+
position[index] = edge + 0.15
|
|
21
|
+
const descriptor: LinearResizeHandle<null> = {
|
|
22
|
+
kind: 'linear-resize',
|
|
23
|
+
axis,
|
|
24
|
+
anchor: 'center',
|
|
25
|
+
currentValue: () => 1,
|
|
26
|
+
apply: () => null,
|
|
27
|
+
placement: { position: () => position, clearance: { edge: () => edge, distance: 0.4 } },
|
|
28
|
+
}
|
|
29
|
+
for (const scale of [0.1, 0.325, 0.65, 1.3, 3]) {
|
|
30
|
+
const resolved = resolveLinearHandlePosition(descriptor, null, scene, scale)
|
|
31
|
+
expect(resolved[index] + tail * scale - edge).toBeGreaterThan(0.1 * scale)
|
|
32
|
+
if (0.4 * scale <= 0.15) expect(resolved).toEqual(position)
|
|
33
|
+
resolved.forEach((value, i) => {
|
|
34
|
+
if (i !== index) expect(value).toBe(position[i]!)
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
delete descriptor.placement.clearance
|
|
38
|
+
expect(resolveLinearHandlePosition(descriptor, null, scene, 3)).toBe(position)
|
|
39
|
+
}
|
|
40
|
+
geometry.dispose()
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
test('array rotations keep sibling handles finite during rotation and resize', () => {
|
|
45
|
+
const initial = { position: [1, 2, 3], rotation: [0, Math.PI / 2, 0] } as unknown as AnyNode
|
|
46
|
+
const rotated = { ...initial, rotation: [0, Math.PI, 0] } as unknown as AnyNode
|
|
47
|
+
expect(computeFreezeOffset(rotated, initial)).toEqual([0, 0, 0])
|
|
48
|
+
const moved = { ...initial, position: [2, 4, 3] } as unknown as AnyNode
|
|
49
|
+
const offset = computeFreezeOffset(moved, initial)
|
|
50
|
+
expect(offset[0]).toBeCloseTo(0)
|
|
51
|
+
expect(offset[1]).toBe(2)
|
|
52
|
+
expect(offset[2]).toBeCloseTo(1)
|
|
53
|
+
expect(
|
|
54
|
+
computeFreezeOffset(moved, { ...initial, rotation: Math.PI / 2 } as unknown as AnyNode),
|
|
55
|
+
).toEqual(offset)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
for (const axis of ['x', 'y', 'z'] as const) {
|
|
59
|
+
test(`${axis} negative direction keeps hovered arrow above its lower-edge clearance`, () => {
|
|
60
|
+
const index = axis === 'x' ? 0 : axis === 'y' ? 1 : 2
|
|
61
|
+
const geometry = createArrowHandleGeometry(true)
|
|
62
|
+
geometry.computeBoundingBox()
|
|
63
|
+
const tail = -geometry.boundingBox!.min.x * 1.12
|
|
64
|
+
for (const edge of [-2, 0, 2]) {
|
|
65
|
+
const position: [number, number, number] = [0, 0, 0]
|
|
66
|
+
position[index] = edge - 0.15
|
|
67
|
+
const descriptor: LinearResizeHandle<null> = {
|
|
68
|
+
kind: 'linear-resize',
|
|
69
|
+
axis,
|
|
70
|
+
anchor: 'max',
|
|
71
|
+
direction: -1,
|
|
72
|
+
currentValue: () => 1,
|
|
73
|
+
apply: () => null,
|
|
74
|
+
placement: { position: () => position, clearance: { edge: () => edge, distance: 0.4 } },
|
|
75
|
+
}
|
|
76
|
+
for (const scale of [0.1, 0.325, 0.65, 1.3, 3]) {
|
|
77
|
+
const resolved = resolveLinearHandlePosition(descriptor, null, scene, scale)
|
|
78
|
+
expect(edge - (resolved[index] + tail * scale)).toBeGreaterThan(0.1 * scale)
|
|
79
|
+
if (0.4 * scale <= 0.15) expect(resolved).toEqual(position)
|
|
80
|
+
const pointing = new Vector3(1, 0, 0)
|
|
81
|
+
.applyEuler(new Euler(...resolveLinearHandleRotation(descriptor, resolved)))
|
|
82
|
+
.applyAxisAngle(new Vector3(0, 1, 0), axis === 'z' ? -Math.PI / 2 : 0)
|
|
83
|
+
expect(pointing.getComponent(index)).toBeCloseTo(-1)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
geometry.dispose()
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
test('unspecified direction preserves vertical position heuristic and wall-facing blade', () => {
|
|
91
|
+
const descriptor: LinearResizeHandle<null> = {
|
|
92
|
+
kind: 'linear-resize',
|
|
93
|
+
axis: 'y',
|
|
94
|
+
anchor: 'min',
|
|
95
|
+
currentValue: () => 1,
|
|
96
|
+
apply: () => null,
|
|
97
|
+
placement: { position: () => [0, 0, 0] },
|
|
98
|
+
}
|
|
99
|
+
expect(resolveLinearHandleRotation(descriptor, [0, -1, 0])).toEqual([
|
|
100
|
+
0,
|
|
101
|
+
Math.PI / 2,
|
|
102
|
+
-Math.PI / 2,
|
|
103
|
+
])
|
|
104
|
+
expect(resolveLinearHandleRotation(descriptor, [0, 1, 0])).toEqual([0, Math.PI / 2, Math.PI / 2])
|
|
105
|
+
expect(
|
|
106
|
+
resolveLinearHandleRotation({ ...descriptor, axis: 'x', faceNormal: true }, [0, 0, 0]),
|
|
107
|
+
).toEqual([Math.PI / 2, 0, 0])
|
|
108
|
+
expect(
|
|
109
|
+
resolveLinearHandleRotation({ ...descriptor, axis: 'z', faceNormal: true }, [0, 0, 0]),
|
|
110
|
+
).toEqual([0, 0, 0])
|
|
111
|
+
})
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { AnyNode, LinearResizeHandle, RadialResizeHandle, SceneApi } from '@pascal-app/core'
|
|
2
|
+
|
|
3
|
+
export function resolveLinearHandlePosition<N>(
|
|
4
|
+
descriptor: LinearResizeHandle<N> | RadialResizeHandle<N>,
|
|
5
|
+
node: N,
|
|
6
|
+
scene: SceneApi,
|
|
7
|
+
baseScale: number,
|
|
8
|
+
): readonly [number, number, number] {
|
|
9
|
+
const position = descriptor.placement.position(node, scene)
|
|
10
|
+
const clearance = descriptor.kind === 'linear-resize' ? descriptor.placement.clearance : undefined
|
|
11
|
+
if (!clearance) return position
|
|
12
|
+
const index = descriptor.axis === 'x' ? 0 : descriptor.axis === 'y' ? 1 : 2
|
|
13
|
+
const result: [number, number, number] = [...position]
|
|
14
|
+
const direction = descriptor.kind === 'linear-resize' ? (descriptor.direction ?? 1) : 1
|
|
15
|
+
const edge = clearance.edge(node, scene) + direction * clearance.distance * baseScale
|
|
16
|
+
result[index] = direction === -1 ? Math.min(result[index], edge) : Math.max(result[index], edge)
|
|
17
|
+
return result
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function resolveLinearHandleRotation<N>(
|
|
21
|
+
descriptor: LinearResizeHandle<N> | RadialResizeHandle<N>,
|
|
22
|
+
position: readonly [number, number, number],
|
|
23
|
+
): [number, number, number] {
|
|
24
|
+
const direction = descriptor.kind === 'linear-resize' ? descriptor.direction : undefined
|
|
25
|
+
if (descriptor.axis === 'y') {
|
|
26
|
+
const sign = direction ?? (position[1] < 0 ? -1 : 1)
|
|
27
|
+
return [0, Math.PI / 2, (sign * Math.PI) / 2]
|
|
28
|
+
}
|
|
29
|
+
const faceNormal =
|
|
30
|
+
descriptor.kind === 'linear-resize' && descriptor.axis === 'x' && descriptor.faceNormal
|
|
31
|
+
return [faceNormal ? Math.PI / 2 : 0, 0, direction === -1 ? Math.PI : 0]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Offset, in node-local frame, that compensates for `position` drift on
|
|
35
|
+
// the mesh during an asymmetric resize. Width/length L+R recompute
|
|
36
|
+
// `position` so the anchored edge stays world-fixed — the renderer
|
|
37
|
+
// follows that override, the ride object moves, and every arrow under
|
|
38
|
+
// it would drift along with the mesh center. Subtracting this offset
|
|
39
|
+
// from a non-active arrow's local placement undoes that drift so it
|
|
40
|
+
// stays at its pre-drag world position.
|
|
41
|
+
//
|
|
42
|
+
// Rotation drags don't change `position`, so the offset collapses to
|
|
43
|
+
// zero and non-active arrows naturally rotate with the mesh — which is
|
|
44
|
+
// the desired behaviour (the whole rig rotates as a unit).
|
|
45
|
+
export function computeFreezeOffset(
|
|
46
|
+
liveNode: AnyNode,
|
|
47
|
+
preDragNode: AnyNode,
|
|
48
|
+
): [number, number, number] {
|
|
49
|
+
// Not every node in the union carries a `position` field (sites are the
|
|
50
|
+
// notable holdout — they don't have handles anyway, but TypeScript still
|
|
51
|
+
// requires us to discriminate). Guarded access keeps the freeze logic
|
|
52
|
+
// safe for the few node kinds that lack the field.
|
|
53
|
+
const liveP = (liveNode as { position?: readonly [number, number, number] }).position ?? [0, 0, 0]
|
|
54
|
+
const preP = (preDragNode as { position?: readonly [number, number, number] }).position ?? [
|
|
55
|
+
0, 0, 0,
|
|
56
|
+
]
|
|
57
|
+
const deltaWorldX = liveP[0] - preP[0]
|
|
58
|
+
const deltaWorldY = liveP[1] - preP[1]
|
|
59
|
+
const deltaWorldZ = liveP[2] - preP[2]
|
|
60
|
+
const rotation = (preDragNode as { rotation?: number | readonly number[] }).rotation
|
|
61
|
+
const rotY = typeof rotation === 'number' ? rotation : (rotation?.[1] ?? 0)
|
|
62
|
+
// World → node-local for Y-axis rotation by rotY (THREE.Object3D
|
|
63
|
+
// rotation-y convention): inverse is rotation by -rotY around +Y.
|
|
64
|
+
const cosR = Math.cos(rotY)
|
|
65
|
+
const sinR = Math.sin(rotY)
|
|
66
|
+
const deltaLocalX = cosR * deltaWorldX - sinR * deltaWorldZ
|
|
67
|
+
const deltaLocalZ = sinR * deltaWorldX + cosR * deltaWorldZ
|
|
68
|
+
return [deltaLocalX, deltaWorldY, deltaLocalZ]
|
|
69
|
+
}
|
|
@@ -1,14 +1,23 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type AnyNode,
|
|
3
3
|
type AnyNodeId,
|
|
4
|
+
cascadeDirty,
|
|
4
5
|
type HandleDragModifiers,
|
|
5
6
|
type LinearResizeHandle,
|
|
7
|
+
type RadialResizeHandle,
|
|
6
8
|
type SceneApi,
|
|
7
9
|
useLiveNodeOverrides,
|
|
8
10
|
useScene,
|
|
9
11
|
} from '@pascal-app/core'
|
|
10
12
|
import { replacePreviewOverrideIds } from './preview-overrides'
|
|
11
13
|
|
|
14
|
+
export function linearResizeFactor<N>(
|
|
15
|
+
descriptor: LinearResizeHandle<N> | RadialResizeHandle<N>,
|
|
16
|
+
): number {
|
|
17
|
+
if (descriptor.kind === 'radial-resize') return 1
|
|
18
|
+
return descriptor.anchor === 'center' ? 2 : descriptor.anchor === 'min' ? 1 : -1
|
|
19
|
+
}
|
|
20
|
+
|
|
12
21
|
export function createLinearResizeDragBinding({
|
|
13
22
|
descriptor,
|
|
14
23
|
initialNode,
|
|
@@ -52,14 +61,16 @@ export function createLinearResizeDragBinding({
|
|
|
52
61
|
previewEntries.map(([id, previewPatch]) => [id, previewPatch as Record<string, unknown>]),
|
|
53
62
|
)
|
|
54
63
|
for (const [previewId] of previewEntries) {
|
|
55
|
-
|
|
64
|
+
for (const id of cascadeDirty(previewId, { scene: sceneApi }))
|
|
65
|
+
useScene.getState().markDirty(id)
|
|
56
66
|
}
|
|
57
67
|
return patch
|
|
58
68
|
},
|
|
59
69
|
clearPreview(): void {
|
|
60
70
|
for (const previewId of previewOverrideIds) {
|
|
61
71
|
useLiveNodeOverrides.getState().clear(previewId)
|
|
62
|
-
|
|
72
|
+
for (const id of cascadeDirty(previewId, { scene: sceneApi }))
|
|
73
|
+
useScene.getState().markDirty(id)
|
|
63
74
|
}
|
|
64
75
|
previewOverrideIds = new Set()
|
|
65
76
|
},
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
type AnyNode,
|
|
5
5
|
type AnyNodeId,
|
|
6
6
|
type Cursor,
|
|
7
|
+
cascadeDirty,
|
|
7
8
|
createSceneApi,
|
|
8
9
|
type HandleDragModifiers,
|
|
9
10
|
runAsSingleSceneHistoryStep,
|
|
@@ -198,7 +199,8 @@ export function useHandleDrag(args: UseHandleDragArgs) {
|
|
|
198
199
|
lastPatch = patch
|
|
199
200
|
useLiveNodeOverrides.getState().set(overrideId, patch as Record<string, unknown>)
|
|
200
201
|
if (markDirty) {
|
|
201
|
-
|
|
202
|
+
for (const id of cascadeDirty(overrideId, { scene: createSceneApi(useScene) }))
|
|
203
|
+
useScene.getState().markDirty(id)
|
|
202
204
|
}
|
|
203
205
|
}
|
|
204
206
|
|
|
@@ -222,7 +224,8 @@ export function useHandleDrag(args: UseHandleDragArgs) {
|
|
|
222
224
|
const clearOverride = () => {
|
|
223
225
|
useLiveNodeOverrides.getState().clear(overrideId)
|
|
224
226
|
if (markDirty) {
|
|
225
|
-
|
|
227
|
+
for (const id of cascadeDirty(overrideId, { scene: createSceneApi(useScene) }))
|
|
228
|
+
useScene.getState().markDirty(id)
|
|
226
229
|
}
|
|
227
230
|
}
|
|
228
231
|
|
|
@@ -49,6 +49,7 @@ import {
|
|
|
49
49
|
writePersistedSelection,
|
|
50
50
|
} from '../../lib/scene'
|
|
51
51
|
import { disposeSFXBus, initSFXBus } from '../../lib/sfx-bus'
|
|
52
|
+
import { useUnitFocusRules } from '../../lib/units'
|
|
52
53
|
import { type CameraHintAction, useCameraHintFocus } from '../../store/use-camera-hint-focus'
|
|
53
54
|
import useEditor from '../../store/use-editor'
|
|
54
55
|
import useFloorplanMode from '../../store/use-floorplan-mode'
|
|
@@ -1041,6 +1042,10 @@ const ViewerCanvas = memo(function ViewerCanvas({
|
|
|
1041
1042
|
const setFloorplanPaneRatio = useEditor((s) => s.setFloorplanPaneRatio)
|
|
1042
1043
|
const isPreviewMode = useEditor((s) => s.isPreviewMode)
|
|
1043
1044
|
const isCaptureMode = useEditor((s) => s.isCaptureMode)
|
|
1045
|
+
useUnitFocusRules()
|
|
1046
|
+
const presetIsolation = useEditor((s) =>
|
|
1047
|
+
s.captureMode.mode === 'preset' ? s.captureMode.isolated : null,
|
|
1048
|
+
)
|
|
1044
1049
|
|
|
1045
1050
|
const [isCameraControlsHintVisible, setIsCameraControlsHintVisible] = useState<boolean | null>(
|
|
1046
1051
|
null,
|
|
@@ -1151,6 +1156,10 @@ const ViewerCanvas = memo(function ViewerCanvas({
|
|
|
1151
1156
|
defaultRender={EDITOR_DEFAULT_RENDER}
|
|
1152
1157
|
disablePostFx={disablePostFx}
|
|
1153
1158
|
hoverStyles={EDITOR_HOVER_STYLES}
|
|
1159
|
+
isolate={presetIsolation}
|
|
1160
|
+
// Preset captures isolate one subtree and keep the exterior transparent.
|
|
1161
|
+
// Other modes retain the viewer's configured background policy.
|
|
1162
|
+
transparent={presetIsolation === null ? undefined : true}
|
|
1154
1163
|
onSceneReadyChange={onSceneReadyChange}
|
|
1155
1164
|
renderContext="editor"
|
|
1156
1165
|
renderPaused={!show3d && !showLoader}
|
|
@@ -1347,10 +1356,9 @@ function EditorContent({
|
|
|
1347
1356
|
|
|
1348
1357
|
// Load on mount, project switches, and explicit retry attempts.
|
|
1349
1358
|
useEffect(() => {
|
|
1350
|
-
void sceneLoadAttempt
|
|
1351
1359
|
let cancelled = false
|
|
1352
1360
|
|
|
1353
|
-
async function load() {
|
|
1361
|
+
async function load(attempt: number) {
|
|
1354
1362
|
isLoadingSceneRef.current = true
|
|
1355
1363
|
setSceneLoadError(null)
|
|
1356
1364
|
setHasLoadedInitialScene(false)
|
|
@@ -1364,7 +1372,7 @@ function EditorContent({
|
|
|
1364
1372
|
let failed = false
|
|
1365
1373
|
try {
|
|
1366
1374
|
const sceneGraph = onLoad ? await onLoad() : loadSceneFromLocalStorage()
|
|
1367
|
-
if (!cancelled) {
|
|
1375
|
+
if (!cancelled && attempt === sceneLoadAttempt) {
|
|
1368
1376
|
applySceneGraphToEditor(sceneGraph)
|
|
1369
1377
|
setIsViewerSceneReady(false)
|
|
1370
1378
|
setSceneReadyKey((key) => key + 1)
|
|
@@ -1390,7 +1398,7 @@ function EditorContent({
|
|
|
1390
1398
|
}
|
|
1391
1399
|
}
|
|
1392
1400
|
|
|
1393
|
-
load()
|
|
1401
|
+
load(sceneLoadAttempt)
|
|
1394
1402
|
|
|
1395
1403
|
return () => {
|
|
1396
1404
|
cancelled = true
|
|
@@ -47,6 +47,7 @@ import { createEditorApi } from '../../lib/editor-api'
|
|
|
47
47
|
import { sfxEmitter } from '../../lib/sfx-bus'
|
|
48
48
|
import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback'
|
|
49
49
|
import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../store/use-editor'
|
|
50
|
+
import { useHandleGroup } from '../../store/use-handle-group'
|
|
50
51
|
import useInteractionScope, {
|
|
51
52
|
useEndpointReshape,
|
|
52
53
|
useIsCurveReshape,
|
|
@@ -62,7 +63,12 @@ import {
|
|
|
62
63
|
HandleArrow,
|
|
63
64
|
NO_RAYCAST,
|
|
64
65
|
} from './handles/handle-arrow'
|
|
65
|
-
import {
|
|
66
|
+
import {
|
|
67
|
+
computeFreezeOffset,
|
|
68
|
+
resolveLinearHandlePosition,
|
|
69
|
+
resolveLinearHandleRotation,
|
|
70
|
+
} from './handles/handle-placement'
|
|
71
|
+
import { createLinearResizeDragBinding, linearResizeFactor } from './handles/linear-resize-drag'
|
|
66
72
|
import { resolveResizeSnapValue } from './handles/resize-snap'
|
|
67
73
|
import { type HandleDragControls, useHandleDrag } from './handles/use-handle-drag'
|
|
68
74
|
|
|
@@ -227,6 +233,9 @@ export function NodeArrowHandles() {
|
|
|
227
233
|
() => (rawNode && liveOverride ? ({ ...rawNode, ...liveOverride } as AnyNode) : rawNode),
|
|
228
234
|
[rawNode, liveOverride],
|
|
229
235
|
)
|
|
236
|
+
const activeGroup = useHandleGroup((s) =>
|
|
237
|
+
s.active && s.active.nodeId === node?.id ? s.active.group : null,
|
|
238
|
+
)
|
|
230
239
|
const def = node ? nodeRegistry.get(node.type) : null
|
|
231
240
|
const descriptorSceneApi = useMemo(() => createSceneApi(useScene), [])
|
|
232
241
|
const descriptors = useMemo(() => {
|
|
@@ -236,6 +245,8 @@ export function NodeArrowHandles() {
|
|
|
236
245
|
? def.handles(node as never, descriptorSceneApi)
|
|
237
246
|
: (def.handles as HandleDescriptor[])
|
|
238
247
|
return all.filter((descriptor) => {
|
|
248
|
+
if (activeGroup)
|
|
249
|
+
return descriptor.kind === 'linear-resize' && descriptor.latchGroup === activeGroup
|
|
239
250
|
if (descriptor.kind === 'translate') return false
|
|
240
251
|
const visible =
|
|
241
252
|
'visible' in descriptor
|
|
@@ -244,7 +255,7 @@ export function NodeArrowHandles() {
|
|
|
244
255
|
if ('shape' in descriptor && descriptor.shape === 'move-cross') return visible === true
|
|
245
256
|
return visible !== false
|
|
246
257
|
})
|
|
247
|
-
}, [node, def, descriptorSceneApi])
|
|
258
|
+
}, [node, def, descriptorSceneApi, activeGroup])
|
|
248
259
|
|
|
249
260
|
const shouldRender =
|
|
250
261
|
Boolean(node && descriptors?.length) &&
|
|
@@ -291,6 +302,9 @@ function NodeArrowHandlesForNode({
|
|
|
291
302
|
node: AnyNode
|
|
292
303
|
descriptors: HandleDescriptor[]
|
|
293
304
|
}) {
|
|
305
|
+
const controlledGroup = useHandleGroup((s) =>
|
|
306
|
+
s.active?.nodeId === node.id ? s.active.group : null,
|
|
307
|
+
)
|
|
294
308
|
const parentId = node.parentId ?? null
|
|
295
309
|
|
|
296
310
|
const portalMode: HandlePortal = descriptors.some((d) => d.portal === 'grandparent')
|
|
@@ -476,7 +490,8 @@ function NodeArrowHandlesForNode({
|
|
|
476
490
|
}
|
|
477
491
|
// Arrows tagged with a latch group stay hidden until that group is open.
|
|
478
492
|
const latchGroup = descriptor.kind === 'linear-resize' ? descriptor.latchGroup : undefined
|
|
479
|
-
if (latchGroup && !openLatchGroups.has(latchGroup))
|
|
493
|
+
if (latchGroup && latchGroup !== controlledGroup && !openLatchGroups.has(latchGroup))
|
|
494
|
+
return null
|
|
480
495
|
return (
|
|
481
496
|
<ArrowHandle
|
|
482
497
|
activeIndex={activeIndex}
|
|
@@ -502,39 +517,6 @@ function NodeArrowHandlesForNode({
|
|
|
502
517
|
)
|
|
503
518
|
}
|
|
504
519
|
|
|
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
520
|
function ArrowHandle({
|
|
539
521
|
descriptor,
|
|
540
522
|
liveNode,
|
|
@@ -666,7 +648,7 @@ function LinearArrow({
|
|
|
666
648
|
// for the edge being resized); cleared when the drag ends.
|
|
667
649
|
const onDrag = descriptor.kind === 'linear-resize' ? descriptor.onDrag : undefined
|
|
668
650
|
const placementSceneApi = useMemo(() => createSceneApi(useScene), [])
|
|
669
|
-
const basePosition = descriptor
|
|
651
|
+
const basePosition = resolveLinearHandlePosition(descriptor, node, placementSceneApi, baseScale)
|
|
670
652
|
// `freezeOffset` (in node-local frame) cancels the mesh's `position`
|
|
671
653
|
// drift while another arrow is being dragged — `basePosition` is
|
|
672
654
|
// computed against the pre-drag snapshot, then we subtract the offset
|
|
@@ -730,14 +712,7 @@ function LinearArrow({
|
|
|
730
712
|
const initialValue = descriptor.currentValue(initialNode)
|
|
731
713
|
const minBound = resolveBound(descriptor.min, Number.NEGATIVE_INFINITY, initialNode, sceneApi)
|
|
732
714
|
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
|
|
715
|
+
const factor = linearResizeFactor(descriptor)
|
|
741
716
|
|
|
742
717
|
// Last value an emitted resize tick fired at — a new tick fires only
|
|
743
718
|
// when the (snapped + clamped) value actually changes, so the cue
|
|
@@ -806,23 +781,7 @@ function LinearArrow({
|
|
|
806
781
|
},
|
|
807
782
|
})
|
|
808
783
|
|
|
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]
|
|
784
|
+
const innerRotation = resolveLinearHandleRotation(descriptor, position)
|
|
826
785
|
|
|
827
786
|
// Optional guide decoration — linear handles use it for curved-stair
|
|
828
787
|
// width / inner-radius rings; radial handles use it for the column's
|