@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
|
@@ -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
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
prepareFreshPlacementRootDuplicate,
|
|
28
28
|
} from '../../lib/fresh-planar-placement'
|
|
29
29
|
import { curveReshapeScope } from '../../lib/interaction/scope'
|
|
30
|
+
import { duplicateWithoutMove, registryMoveDisabled } from '../../lib/node-action-movement'
|
|
30
31
|
import { playBlockedQuickActionFeedback } from '../../lib/quick-action-feedback'
|
|
31
32
|
import { collectQuickActionNodeScope } from '../../lib/quick-action-nodes'
|
|
32
33
|
import { sfxEmitter } from '../../lib/sfx-bus'
|
|
@@ -250,7 +251,8 @@ export function FloorplanRegistryActionMenu() {
|
|
|
250
251
|
// walls land on their bespoke `MoveWallTool` (perpendicular slide
|
|
251
252
|
// with linked-wall cascade) via `affordanceTools.move`.
|
|
252
253
|
const canMove =
|
|
253
|
-
|
|
254
|
+
!registryMoveDisabled(node) &&
|
|
255
|
+
(!!def.capabilities.movable || !!def.floorplanMoveTarget || !!def.affordanceTools?.move)
|
|
254
256
|
const canDuplicate = def.capabilities.duplicable !== false
|
|
255
257
|
const canDelete = def.capabilities.deletable !== false
|
|
256
258
|
const canAddHole = node.type === 'slab' || node.type === 'ceiling'
|
|
@@ -320,6 +322,15 @@ export function FloorplanRegistryActionMenu() {
|
|
|
320
322
|
const handleDuplicate = () => {
|
|
321
323
|
if (!node.parentId) return
|
|
322
324
|
sfxEmitter.emit('sfx:item-pick')
|
|
325
|
+
if (registryMoveDisabled(node)) {
|
|
326
|
+
try {
|
|
327
|
+
const id = duplicateWithoutMove(node)
|
|
328
|
+
if (id) useViewer.getState().setSelection({ selectedIds: [id] })
|
|
329
|
+
} catch (error) {
|
|
330
|
+
console.error('Failed to duplicate node', error)
|
|
331
|
+
}
|
|
332
|
+
return
|
|
333
|
+
}
|
|
323
334
|
useScene.temporal.getState().pause()
|
|
324
335
|
let draftId: AnyNodeId | null = null
|
|
325
336
|
try {
|
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
useRef,
|
|
41
41
|
useState,
|
|
42
42
|
} from 'react'
|
|
43
|
+
import { useShallow } from 'zustand/react/shallow'
|
|
43
44
|
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
|
44
45
|
import { ROTATE_HANDLE_DRAG_LABEL } from '../../../lib/contextual-help'
|
|
45
46
|
import {
|
|
@@ -87,6 +88,7 @@ import {
|
|
|
87
88
|
import { emitCanvasNodeSelection } from '../../../lib/selection-routing'
|
|
88
89
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
89
90
|
import { clearSurfacePlanSnapFeedback } from '../../../lib/surface-plan-snap'
|
|
91
|
+
import { paintZoneMembership } from '../../../lib/units'
|
|
90
92
|
import useDirectManipulationFeedback from '../../../store/use-direct-manipulation-feedback'
|
|
91
93
|
import useDrawingView from '../../../store/use-drawing-view'
|
|
92
94
|
import useEditor, { isAngleSnapActive } from '../../../store/use-editor'
|
|
@@ -343,6 +345,13 @@ type FloorplanEntryDescriptor = {
|
|
|
343
345
|
visibilityRootId?: AnyNodeId
|
|
344
346
|
}
|
|
345
347
|
|
|
348
|
+
/** The focused unit and its members; one stable object per focus change so
|
|
349
|
+
* cached zone geometry invalidates exactly when the focus does. */
|
|
350
|
+
export type FloorplanUnitFocus = {
|
|
351
|
+
focusedUnitId: string
|
|
352
|
+
focusedUnitMemberIds: readonly string[]
|
|
353
|
+
}
|
|
354
|
+
|
|
346
355
|
type NodeDeps = {
|
|
347
356
|
automaticDimensions: boolean
|
|
348
357
|
node: AnyNode
|
|
@@ -356,6 +365,7 @@ type NodeDeps = {
|
|
|
356
365
|
moving: boolean
|
|
357
366
|
liveOverride: LiveNodeOverrides | undefined
|
|
358
367
|
palette: FloorplanPalette | undefined
|
|
368
|
+
unitFocus: FloorplanUnitFocus | undefined
|
|
359
369
|
siblingEpoch: number
|
|
360
370
|
committedNodes: Record<string, AnyNode> | null
|
|
361
371
|
dependencyNodes: AnyNode[]
|
|
@@ -476,6 +486,7 @@ function snapshotsToUpdates(snapshots: NodeSnapshot[]) {
|
|
|
476
486
|
// Stable empty sentinel used by per-entry builders while the floor plan is
|
|
477
487
|
// hidden; committed scene edits still flow through `useScene`.
|
|
478
488
|
const EMPTY_LIVE_OVERRIDES: Map<string, LiveNodeOverrides> = new Map()
|
|
489
|
+
const NO_MEMBER_IDS: readonly string[] = []
|
|
479
490
|
|
|
480
491
|
export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
481
492
|
const selectedLevelId = useViewer((s) => s.selection.levelId)
|
|
@@ -484,6 +495,17 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
|
484
495
|
const metricNotation = useViewer((s) => s.metricNotation)
|
|
485
496
|
const setSelection = useViewer((s) => s.setSelection)
|
|
486
497
|
const nodes = useScene((s) => s.nodes)
|
|
498
|
+
const focusedUnitId = useViewer((s) => s.focusedUnitId)
|
|
499
|
+
const focusedUnitMemberIds = useScene(
|
|
500
|
+
useShallow((s) => {
|
|
501
|
+
const focusedUnit = focusedUnitId ? s.nodes[focusedUnitId] : undefined
|
|
502
|
+
return focusedUnit?.type === 'unit' ? focusedUnit.members : NO_MEMBER_IDS
|
|
503
|
+
}),
|
|
504
|
+
)
|
|
505
|
+
const unitFocus = useMemo<FloorplanUnitFocus | undefined>(
|
|
506
|
+
() => (focusedUnitId ? { focusedUnitId, focusedUnitMemberIds } : undefined),
|
|
507
|
+
[focusedUnitId, focusedUnitMemberIds],
|
|
508
|
+
)
|
|
487
509
|
const installedPlugins = useScene((s) => s.installedPlugins)
|
|
488
510
|
const movingNode = useMovingNode()
|
|
489
511
|
// When a building is being moved, its explicit selection may be
|
|
@@ -650,6 +672,13 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
|
650
672
|
|
|
651
673
|
const applyEntrySelection = useCallback(
|
|
652
674
|
(id: AnyNodeId, options: { shouldToggle: boolean; isolateMember: boolean }) => {
|
|
675
|
+
const focusedUnitId = useViewer.getState().focusedUnitId
|
|
676
|
+
const clickedNode = useScene.getState().nodes[id]
|
|
677
|
+
if (focusedUnitId && clickedNode?.type === 'zone') {
|
|
678
|
+
paintZoneMembership(focusedUnitId, clickedNode.id)
|
|
679
|
+
swallowNextClick(200)
|
|
680
|
+
return
|
|
681
|
+
}
|
|
653
682
|
const currentSelectedIds = useViewer.getState().selection.selectedIds
|
|
654
683
|
let nextSelectedIds: string[]
|
|
655
684
|
if (options.shouldToggle) {
|
|
@@ -1529,6 +1558,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
|
1529
1558
|
onHandleDoubleClick={commitAffordanceAction}
|
|
1530
1559
|
onHandlePointerDown={startAffordanceDrag}
|
|
1531
1560
|
palette={palette}
|
|
1561
|
+
unitFocus={unitFocus}
|
|
1532
1562
|
pass="base"
|
|
1533
1563
|
sceneRotationDeg={sceneRotationDeg}
|
|
1534
1564
|
setMovingNode={setMovingNode}
|
|
@@ -1577,6 +1607,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
|
|
1577
1607
|
onHandleDoubleClick={commitAffordanceAction}
|
|
1578
1608
|
onHandlePointerDown={startAffordanceDrag}
|
|
1579
1609
|
palette={palette}
|
|
1610
|
+
unitFocus={unitFocus}
|
|
1580
1611
|
pass="overlay"
|
|
1581
1612
|
sceneRotationDeg={sceneRotationDeg}
|
|
1582
1613
|
setMovingNode={setMovingNode}
|
|
@@ -1908,6 +1939,7 @@ type FloorplanRegistryEntryProps = {
|
|
|
1908
1939
|
rotationPivot?: FloorplanPoint,
|
|
1909
1940
|
) => void
|
|
1910
1941
|
palette: FloorplanPalette | undefined
|
|
1942
|
+
unitFocus: FloorplanUnitFocus | undefined
|
|
1911
1943
|
pass: FloorplanRenderPass
|
|
1912
1944
|
sceneRotationDeg: number
|
|
1913
1945
|
setMovingNode: ReturnType<typeof useEditor.getState>['setMovingNode']
|
|
@@ -1943,6 +1975,7 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
|
|
|
1943
1975
|
onHandleDoubleClick,
|
|
1944
1976
|
onHandlePointerDown,
|
|
1945
1977
|
palette,
|
|
1978
|
+
unitFocus,
|
|
1946
1979
|
pass,
|
|
1947
1980
|
sceneRotationDeg,
|
|
1948
1981
|
setMovingNode,
|
|
@@ -1967,7 +2000,7 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
|
|
|
1967
2000
|
const hovered = useViewer((state) => state.hoveredId === selectionProxyId)
|
|
1968
2001
|
const setHoveredId = useViewer((state) => state.setHoveredId)
|
|
1969
2002
|
const referencedAnnotationRole = useViewer((state) =>
|
|
1970
|
-
floorplanEntryReferencedAnnotationRole(node, new Set(state.selection.selectedIds)),
|
|
2003
|
+
floorplanEntryReferencedAnnotationRole(node, nodes, new Set(state.selection.selectedIds)),
|
|
1971
2004
|
)
|
|
1972
2005
|
const activeRotateNodeId = useDirectManipulationFeedback((state) =>
|
|
1973
2006
|
state.activeRotateNodeId === nodeId ? nodeId : null,
|
|
@@ -2084,6 +2117,7 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
|
|
|
2084
2117
|
nodeId,
|
|
2085
2118
|
nodes,
|
|
2086
2119
|
palette,
|
|
2120
|
+
unitFocus,
|
|
2087
2121
|
selected,
|
|
2088
2122
|
siblingEpoch,
|
|
2089
2123
|
unit,
|
|
@@ -2153,6 +2187,7 @@ type BuildFloorplanEntryGeometryArgs = {
|
|
|
2153
2187
|
nodeId: AnyNodeId
|
|
2154
2188
|
nodes: Record<string, AnyNode>
|
|
2155
2189
|
palette: FloorplanPalette | undefined
|
|
2190
|
+
unitFocus?: FloorplanUnitFocus
|
|
2156
2191
|
selected: boolean
|
|
2157
2192
|
siblingEpoch: number
|
|
2158
2193
|
unit: 'metric' | 'imperial'
|
|
@@ -2167,7 +2202,7 @@ export function collectFloorplanDependencyNodes(
|
|
|
2167
2202
|
nodes: Record<string, AnyNode>,
|
|
2168
2203
|
liveOverrides?: Map<string, LiveNodeOverrides>,
|
|
2169
2204
|
): AnyNode[] {
|
|
2170
|
-
return (def.floorplanDependencies?.(node) ?? []).flatMap((id) => {
|
|
2205
|
+
return (def.floorplanDependencies?.(node, nodes) ?? []).flatMap((id) => {
|
|
2171
2206
|
const dependency = nodes[id]
|
|
2172
2207
|
if (!dependency) return []
|
|
2173
2208
|
const dependencyOverride = liveOverrides?.get(dependency.id)
|
|
@@ -2184,13 +2219,14 @@ export function collectFloorplanDependencyNodes(
|
|
|
2184
2219
|
|
|
2185
2220
|
function floorplanEntryReferencedAnnotationRole(
|
|
2186
2221
|
node: AnyNode,
|
|
2222
|
+
nodes: Record<string, AnyNode>,
|
|
2187
2223
|
selectedIds: ReadonlySet<string>,
|
|
2188
2224
|
): FloorplanAnnotationRole | undefined {
|
|
2189
2225
|
if (selectedIds.size === 0) return undefined
|
|
2190
2226
|
const definition = nodeRegistry.get(node.type)
|
|
2191
2227
|
const role = getFloorplanNodeExtension(definition)?.referencedSelectionAnnotationRole
|
|
2192
2228
|
if (!role) return undefined
|
|
2193
|
-
const dependencyIds = definition?.floorplanDependencies?.(node) ?? []
|
|
2229
|
+
const dependencyIds = definition?.floorplanDependencies?.(node, nodes) ?? []
|
|
2194
2230
|
return dependencyIds.some((id) => selectedIds.has(id)) ? role : undefined
|
|
2195
2231
|
}
|
|
2196
2232
|
|
|
@@ -2211,6 +2247,7 @@ export function buildFloorplanEntryGeometry({
|
|
|
2211
2247
|
nodeId,
|
|
2212
2248
|
nodes,
|
|
2213
2249
|
palette,
|
|
2250
|
+
unitFocus,
|
|
2214
2251
|
selected,
|
|
2215
2252
|
siblingEpoch,
|
|
2216
2253
|
unit,
|
|
@@ -2250,6 +2287,7 @@ export function buildFloorplanEntryGeometry({
|
|
|
2250
2287
|
moving,
|
|
2251
2288
|
liveOverride,
|
|
2252
2289
|
palette,
|
|
2290
|
+
unitFocus,
|
|
2253
2291
|
siblingEpoch: dependsOnSiblingInputs ? siblingEpoch : 0,
|
|
2254
2292
|
// Sibling-dependent kinds (wall miters, opening cuts) read other nodes'
|
|
2255
2293
|
// committed state via `ctx`, so committed sibling edits still invalidate.
|
|
@@ -2332,6 +2370,8 @@ export function buildFloorplanEntryGeometry({
|
|
|
2332
2370
|
highlighted,
|
|
2333
2371
|
hovered,
|
|
2334
2372
|
moving,
|
|
2373
|
+
focusedUnitId: unitFocus?.focusedUnitId,
|
|
2374
|
+
focusedUnitMemberIds: unitFocus?.focusedUnitMemberIds,
|
|
2335
2375
|
palette,
|
|
2336
2376
|
}
|
|
2337
2377
|
const resolveContextNode = <N = AnyNode>(rid: AnyNodeId): N | undefined => {
|
|
@@ -2360,6 +2400,8 @@ export function buildFloorplanEntryGeometry({
|
|
|
2360
2400
|
highlighted,
|
|
2361
2401
|
hovered,
|
|
2362
2402
|
moving,
|
|
2403
|
+
focusedUnitId: unitFocus?.focusedUnitId,
|
|
2404
|
+
focusedUnitMemberIds: unitFocus?.focusedUnitMemberIds,
|
|
2363
2405
|
palette,
|
|
2364
2406
|
}
|
|
2365
2407
|
: undefined,
|
|
@@ -3573,6 +3615,7 @@ function nodeDepsEqual(a: NodeDeps, b: NodeDeps): boolean {
|
|
|
3573
3615
|
'moving',
|
|
3574
3616
|
'liveOverride',
|
|
3575
3617
|
'palette',
|
|
3618
|
+
'unitFocus',
|
|
3576
3619
|
'siblingEpoch',
|
|
3577
3620
|
'ctxOverrides',
|
|
3578
3621
|
'committedNodes',
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
import { type AnyNodeId, useLiveNodeOverrides, useScene } from '@pascal-app/core'
|
|
3
|
+
import {
|
|
4
|
+
ProceduralItemNode,
|
|
5
|
+
snapParameters,
|
|
6
|
+
validateProceduralRelations,
|
|
7
|
+
} from '@pascal-app/core/procedural-items'
|
|
8
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
9
|
+
import { useEffect, useState } from 'react'
|
|
10
|
+
import { useHandleGroup } from '../../store/use-handle-group'
|
|
11
|
+
import { PanelSection } from '../ui/controls/panel-section'
|
|
12
|
+
import { SliderControl } from '../ui/controls/slider-control'
|
|
13
|
+
import { PanelWrapper } from '../ui/panels/panel-wrapper'
|
|
14
|
+
export function ProceduralInspector({
|
|
15
|
+
nodeId,
|
|
16
|
+
partId,
|
|
17
|
+
onPartChange,
|
|
18
|
+
}: {
|
|
19
|
+
nodeId: string
|
|
20
|
+
partId: string | null
|
|
21
|
+
onPartChange: (id: string | null) => void
|
|
22
|
+
}) {
|
|
23
|
+
const committed = useScene((s) => s.nodes[nodeId as AnyNodeId]) as unknown as
|
|
24
|
+
| ProceduralItemNode
|
|
25
|
+
| undefined
|
|
26
|
+
const override = useLiveNodeOverrides((s) => s.overrides.get(nodeId))
|
|
27
|
+
const node = committed ? ({ ...committed, ...override } as ProceduralItemNode) : null
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
useHandleGroup.setState({ active: partId ? { nodeId, group: partId } : null })
|
|
30
|
+
return () => {
|
|
31
|
+
if (useHandleGroup.getState().active?.nodeId === nodeId)
|
|
32
|
+
useHandleGroup.setState({ active: null })
|
|
33
|
+
}
|
|
34
|
+
}, [nodeId, partId])
|
|
35
|
+
const [error, setError] = useState('')
|
|
36
|
+
useEffect(() => () => useLiveNodeOverrides.getState().clear(nodeId as AnyNodeId), [nodeId])
|
|
37
|
+
if (!node || !committed) return null
|
|
38
|
+
const change = (id: string, value: number, commit: boolean) => {
|
|
39
|
+
const values = { ...node.parameters, [id]: value }
|
|
40
|
+
const parameters = commit ? snapParameters(node.recipe, values) : values
|
|
41
|
+
const parsed = ProceduralItemNode.safeParse({ ...node, parameters })
|
|
42
|
+
if (!parsed.success) {
|
|
43
|
+
setError(parsed.error.issues[0]?.message ?? 'Invalid edit')
|
|
44
|
+
if (commit) useLiveNodeOverrides.getState().clear(nodeId as AnyNodeId)
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
validateProceduralRelations({ ...node, parameters }, useScene.getState().nodes)
|
|
49
|
+
} catch (e) {
|
|
50
|
+
setError(e instanceof Error ? e.message : 'Invalid attachment')
|
|
51
|
+
if (commit) useLiveNodeOverrides.getState().clear(nodeId as AnyNodeId)
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
setError('')
|
|
55
|
+
if (commit) {
|
|
56
|
+
useLiveNodeOverrides.getState().clear(nodeId as AnyNodeId)
|
|
57
|
+
useScene.getState().updateNode(nodeId as AnyNodeId, { parameters } as never)
|
|
58
|
+
} else useLiveNodeOverrides.getState().set(nodeId as AnyNodeId, { parameters } as never)
|
|
59
|
+
}
|
|
60
|
+
const fields = node.recipe.parameters.filter((p) => (partId ? p.part === partId : !p.part))
|
|
61
|
+
return (
|
|
62
|
+
<div className="space-y-5">
|
|
63
|
+
<div className="flex items-center justify-between">
|
|
64
|
+
<h2 className="font-semibold text-lg">
|
|
65
|
+
{partId ? node.recipe.parts.find((p) => p.id === partId)?.label : 'Adjust design'}
|
|
66
|
+
</h2>
|
|
67
|
+
{partId && (
|
|
68
|
+
<button
|
|
69
|
+
className="rounded-full border px-3 py-1 text-xs"
|
|
70
|
+
onClick={() => onPartChange(null)}
|
|
71
|
+
type="button"
|
|
72
|
+
>
|
|
73
|
+
Back
|
|
74
|
+
</button>
|
|
75
|
+
)}
|
|
76
|
+
</div>
|
|
77
|
+
<div className="space-y-3">
|
|
78
|
+
{fields.map((p) => (
|
|
79
|
+
<SliderControl
|
|
80
|
+
key={p.id}
|
|
81
|
+
label={p.label}
|
|
82
|
+
value={node.parameters[p.id] ?? p.default}
|
|
83
|
+
min={p.min}
|
|
84
|
+
max={p.max}
|
|
85
|
+
step={p.step}
|
|
86
|
+
precision={3}
|
|
87
|
+
unit={p.unit === 'm' ? 'm' : ''}
|
|
88
|
+
onChange={(v) => change(p.id, v, false)}
|
|
89
|
+
onCommit={(v) => change(p.id, v, true)}
|
|
90
|
+
restoreOnCommit={false}
|
|
91
|
+
/>
|
|
92
|
+
))}
|
|
93
|
+
</div>
|
|
94
|
+
{partId && (
|
|
95
|
+
<button
|
|
96
|
+
type="button"
|
|
97
|
+
className="rounded-full border px-3 py-1 text-xs"
|
|
98
|
+
onClick={() => {
|
|
99
|
+
const parameters = { ...committed.parameters }
|
|
100
|
+
for (const p of fields) delete parameters[p.id]
|
|
101
|
+
try {
|
|
102
|
+
ProceduralItemNode.parse({ ...committed, parameters })
|
|
103
|
+
useScene.getState().updateNode(nodeId as AnyNodeId, { parameters } as never)
|
|
104
|
+
setError('')
|
|
105
|
+
} catch (e) {
|
|
106
|
+
setError(e instanceof Error ? e.message : 'Invalid reset')
|
|
107
|
+
}
|
|
108
|
+
}}
|
|
109
|
+
>
|
|
110
|
+
Reset this part
|
|
111
|
+
</button>
|
|
112
|
+
)}
|
|
113
|
+
{error && (
|
|
114
|
+
<p role="alert" className="text-red-600 text-sm">
|
|
115
|
+
{error}
|
|
116
|
+
</p>
|
|
117
|
+
)}
|
|
118
|
+
{!partId && node.recipe.parameters.some((p) => p.part) && (
|
|
119
|
+
<div>
|
|
120
|
+
<h3 className="mb-2 font-medium text-sm">Edit parts</h3>
|
|
121
|
+
<div className="flex flex-wrap gap-2">
|
|
122
|
+
{node.recipe.parts
|
|
123
|
+
.filter((part) => node.recipe.parameters.some((p) => p.part === part.id))
|
|
124
|
+
.map((p) => (
|
|
125
|
+
<button
|
|
126
|
+
key={p.id}
|
|
127
|
+
type="button"
|
|
128
|
+
className="rounded-full border px-3 py-1.5 text-sm text-foreground hover:bg-muted hover:text-foreground"
|
|
129
|
+
onClick={() => onPartChange(p.id)}
|
|
130
|
+
>
|
|
131
|
+
{p.label}
|
|
132
|
+
</button>
|
|
133
|
+
))}
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
)}
|
|
137
|
+
</div>
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export default function ProceduralItemPanel({ node }: { node: ProceduralItemNode }) {
|
|
142
|
+
return <ProceduralPanel key={node.id} node={node} />
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function ProceduralPanel({ node }: { node: ProceduralItemNode }) {
|
|
146
|
+
const [partId, setPartId] = useState<string | null>(null)
|
|
147
|
+
return (
|
|
148
|
+
<PanelWrapper
|
|
149
|
+
title={node.name ?? node.recipe.name}
|
|
150
|
+
onClose={() => useViewer.getState().setSelection({ selectedIds: [] })}
|
|
151
|
+
>
|
|
152
|
+
<div className="space-y-5 p-3">
|
|
153
|
+
<ProceduralInspector
|
|
154
|
+
key={node.id}
|
|
155
|
+
nodeId={node.id}
|
|
156
|
+
partId={partId}
|
|
157
|
+
onPartChange={setPartId}
|
|
158
|
+
/>
|
|
159
|
+
<ProceduralPlacementControls node={node} />
|
|
160
|
+
</div>
|
|
161
|
+
</PanelWrapper>
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function ProceduralPlacementControls({ node }: { node: ProceduralItemNode }) {
|
|
166
|
+
const [error, setError] = useState('')
|
|
167
|
+
const update = (patch: Partial<ProceduralItemNode>) => {
|
|
168
|
+
try {
|
|
169
|
+
useScene.getState().updateNode(node.id as AnyNodeId, patch as never)
|
|
170
|
+
setError('')
|
|
171
|
+
} catch (e) {
|
|
172
|
+
setError(e instanceof Error ? e.message : 'Invalid placement')
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const changePosition = (index: number, value: number, commit: boolean) => {
|
|
176
|
+
const live = useLiveNodeOverrides.getState().overrides.get(node.id)
|
|
177
|
+
const position = [
|
|
178
|
+
...((live?.position as typeof node.position | undefined) ?? node.position),
|
|
179
|
+
] as typeof node.position
|
|
180
|
+
position[index] = Number(value.toFixed(3))
|
|
181
|
+
const patch = { position }
|
|
182
|
+
try {
|
|
183
|
+
validateProceduralRelations({ ...node, ...patch }, useScene.getState().nodes)
|
|
184
|
+
if (commit) {
|
|
185
|
+
useLiveNodeOverrides.getState().clear(node.id as AnyNodeId)
|
|
186
|
+
update(patch)
|
|
187
|
+
} else useLiveNodeOverrides.getState().set(node.id as AnyNodeId, patch)
|
|
188
|
+
setError('')
|
|
189
|
+
} catch (e) {
|
|
190
|
+
if (commit) useLiveNodeOverrides.getState().clear(node.id as AnyNodeId)
|
|
191
|
+
setError(e instanceof Error ? e.message : 'Invalid position')
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return (
|
|
195
|
+
<PanelSection title="Position">
|
|
196
|
+
{(['X', 'Y', 'Z'] as const).map((axis, index) => (
|
|
197
|
+
<SliderControl
|
|
198
|
+
key={axis}
|
|
199
|
+
label={
|
|
200
|
+
<>
|
|
201
|
+
{axis}
|
|
202
|
+
<sub className="ml-[1px] text-[11px] opacity-70">pos</sub>
|
|
203
|
+
</>
|
|
204
|
+
}
|
|
205
|
+
value={node.position[index]!}
|
|
206
|
+
min={node.position[index]! - 2}
|
|
207
|
+
max={node.position[index]! + 2}
|
|
208
|
+
precision={2}
|
|
209
|
+
step={0.01}
|
|
210
|
+
unit="m"
|
|
211
|
+
onChange={(value) => changePosition(index, value, false)}
|
|
212
|
+
onCommit={(value) => changePosition(index, value, true)}
|
|
213
|
+
restoreOnCommit={false}
|
|
214
|
+
/>
|
|
215
|
+
))}
|
|
216
|
+
{error && (
|
|
217
|
+
<p role="alert" className="text-destructive text-xs">
|
|
218
|
+
{error}
|
|
219
|
+
</p>
|
|
220
|
+
)}
|
|
221
|
+
</PanelSection>
|
|
222
|
+
)
|
|
223
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type AnyNodeId, sceneRegistry, useScene } from '@pascal-app/core'
|
|
2
|
+
import { isProceduralItem } from '@pascal-app/core/procedural-items'
|
|
2
3
|
import { useViewer } from '@pascal-app/viewer'
|
|
3
4
|
import { useEffect } from 'react'
|
|
4
5
|
import { Color, type Material, type Mesh } from 'three'
|
|
@@ -98,7 +99,8 @@ export const CeilingSystem = () => {
|
|
|
98
99
|
const isCeilingToolActive =
|
|
99
100
|
tool === 'ceiling' ||
|
|
100
101
|
selectedItem?.attachTo === 'ceiling' ||
|
|
101
|
-
(movingNode?.type === 'item' && movingNode?.asset?.attachTo === 'ceiling')
|
|
102
|
+
(movingNode?.type === 'item' && movingNode?.asset?.attachTo === 'ceiling') ||
|
|
103
|
+
(isProceduralItem(movingNode) && movingNode.recipe.mounting?.attachTo === 'ceiling')
|
|
102
104
|
|
|
103
105
|
if (isCeilingToolActive && activeLevelId) {
|
|
104
106
|
levelsToShowCeilings.add(activeLevelId)
|