@pascal-app/editor 1.0.1 → 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 +10 -5
- package/src/components/editor/custom-camera-controls.tsx +22 -74
- package/src/components/editor/editor-layout-mobile.tsx +1 -0
- package/src/components/editor/floorplan-panel.tsx +210 -163
- 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/use-handle-drag.ts +75 -6
- package/src/components/editor/index.tsx +24 -9
- package/src/components/editor/node-action-menu.tsx +2 -0
- package/src/components/editor/node-arrow-handles.tsx +141 -7
- package/src/components/editor/registry-action-contributions.tsx +53 -0
- 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-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 +67 -21
- 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 +3 -9
- package/src/components/tools/item/item-surface-strategy-resolver.test.ts +3 -9
- package/src/components/tools/item/placement-strategies.ts +55 -43
- package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +3 -3
- package/src/components/tools/item/use-draft-node.ts +41 -2
- package/src/components/tools/item/use-placement-coordinator.tsx +166 -73
- package/src/components/tools/registry/item-surface-move.test.ts +10 -2
- package/src/components/tools/registry/item-surface-move.ts +46 -24
- package/src/components/tools/registry/item-surface-resolver.test.ts +2 -2
- package/src/components/tools/registry/move-registry-node-tool.tsx +23 -9
- 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 +17 -5
- package/src/components/tools/shared/surface-rejection.test.tsx +5 -4
- package/src/components/tools/shared/surface-rejection.tsx +14 -0
- package/src/components/tools/tool-manager.tsx +36 -3
- 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/slider-control.tsx +76 -31
- 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/parametric-inspector.tsx +3 -15
- 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 +585 -369
- 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.tsx +1 -1
- 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-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 +1 -0
- package/src/hooks/use-ceiling-events.ts +2 -0
- 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 +42 -29
- package/src/index.tsx +63 -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/floorplan/floorplan-export.test.ts +3 -1
- package/src/lib/floorplan/floorplan-extension.ts +16 -0
- package/src/lib/fresh-planar-placement.ts +168 -36
- package/src/lib/glb-export.test.ts +59 -16
- package/src/lib/glb-export.ts +26 -13
- 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/material-catalog-model.ts +72 -0
- package/src/lib/material-paint-panel-model.ts +76 -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/scene.test.ts +99 -0
- package/src/lib/scene.ts +21 -3
- 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/terrain-panel-model.ts +117 -0
- 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-interaction-scope.ts +155 -6
|
@@ -11,6 +11,8 @@ interface SliderControlProps {
|
|
|
11
11
|
value: number
|
|
12
12
|
onChange: (value: number) => void
|
|
13
13
|
onCommit?: (value: number) => void
|
|
14
|
+
onCancel?: () => void
|
|
15
|
+
previewWhileTyping?: boolean
|
|
14
16
|
min?: number
|
|
15
17
|
max?: number
|
|
16
18
|
precision?: number
|
|
@@ -54,6 +56,8 @@ export function SliderControl({
|
|
|
54
56
|
value,
|
|
55
57
|
onChange,
|
|
56
58
|
onCommit,
|
|
59
|
+
onCancel,
|
|
60
|
+
previewWhileTyping = false,
|
|
57
61
|
min = Number.NEGATIVE_INFINITY,
|
|
58
62
|
max = Number.POSITIVE_INFINITY,
|
|
59
63
|
precision: storedPrecision = 0,
|
|
@@ -87,6 +91,9 @@ export function SliderControl({
|
|
|
87
91
|
anchorValue: number
|
|
88
92
|
stepMultiplier: number
|
|
89
93
|
} | null>(null)
|
|
94
|
+
const cancelRef = useRef(onCancel)
|
|
95
|
+
cancelRef.current = onCancel
|
|
96
|
+
const editingRef = useRef(false)
|
|
90
97
|
const labelRef = useRef<HTMLDivElement>(null)
|
|
91
98
|
const shown = dragDisplay ?? value
|
|
92
99
|
const valueRef = useRef(shown)
|
|
@@ -217,45 +224,74 @@ export function SliderControl({
|
|
|
217
224
|
[onChange, onCommit, restoreOnCommit],
|
|
218
225
|
)
|
|
219
226
|
|
|
227
|
+
const cancelEdit = useCallback(() => {
|
|
228
|
+
const drag = dragRef.current
|
|
229
|
+
dragRef.current = null
|
|
230
|
+
editingRef.current = false
|
|
231
|
+
if (drag) {
|
|
232
|
+
if (!onCancel) onChange(drag.originValue)
|
|
233
|
+
useScene.temporal.getState().resume()
|
|
234
|
+
}
|
|
235
|
+
onCancel?.()
|
|
236
|
+
setIsDragging(false)
|
|
237
|
+
setIsEditing(false)
|
|
238
|
+
setDragDisplay(null)
|
|
239
|
+
}, [onCancel, onChange])
|
|
240
|
+
|
|
241
|
+
useEffect(
|
|
242
|
+
() => () => {
|
|
243
|
+
if (dragRef.current) useScene.temporal.getState().resume()
|
|
244
|
+
if (dragRef.current || editingRef.current) cancelRef.current?.()
|
|
245
|
+
},
|
|
246
|
+
[],
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
useEffect(() => {
|
|
250
|
+
if (!isDragging) return
|
|
251
|
+
const escape = (event: KeyboardEvent) => {
|
|
252
|
+
if (event.key === 'Escape') cancelEdit()
|
|
253
|
+
}
|
|
254
|
+
window.addEventListener('keydown', escape)
|
|
255
|
+
return () => window.removeEventListener('keydown', escape)
|
|
256
|
+
}, [isDragging, cancelEdit])
|
|
257
|
+
|
|
220
258
|
const handleValueClick = useCallback(() => {
|
|
259
|
+
editingRef.current = true
|
|
221
260
|
setIsEditing(true)
|
|
222
261
|
setInputValue(toDisplay(value).toFixed(precision))
|
|
223
262
|
}, [value, precision, toDisplay])
|
|
224
263
|
|
|
264
|
+
const parseInputValue = useCallback(
|
|
265
|
+
(text: string) => {
|
|
266
|
+
const spec = lingoUnitSpec(unit)
|
|
267
|
+
const parsed = spec
|
|
268
|
+
? parseMeasurement(text, spec, {
|
|
269
|
+
bareUnit: parseUnit ?? spec.unitId,
|
|
270
|
+
system: isImperial ? 'us' : 'metric',
|
|
271
|
+
})
|
|
272
|
+
: null
|
|
273
|
+
const bare = Number(text)
|
|
274
|
+
const stored = parsed ?? (text.trim() && Number.isFinite(bare) ? toStored(bare) : null)
|
|
275
|
+
return stored === null
|
|
276
|
+
? null
|
|
277
|
+
: clamp(toStored(Number.parseFloat(toDisplay(stored).toFixed(precision))))
|
|
278
|
+
},
|
|
279
|
+
[unit, parseUnit, isImperial, toStored, clamp, toDisplay, precision],
|
|
280
|
+
)
|
|
281
|
+
|
|
225
282
|
const submitValue = useCallback(() => {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
})
|
|
232
|
-
: null
|
|
233
|
-
if (stored === null) {
|
|
234
|
-
// Fallback: a bare number typed in the DISPLAY unit → convert to stored.
|
|
235
|
-
const numValue = Number.parseFloat(inputValue)
|
|
236
|
-
stored = Number.isFinite(numValue) ? toStored(numValue) : null
|
|
237
|
-
}
|
|
238
|
-
if (stored === null) {
|
|
283
|
+
if (!editingRef.current) return
|
|
284
|
+
editingRef.current = false
|
|
285
|
+
const nextValue = parseInputValue(inputValue)
|
|
286
|
+
if (nextValue === null) {
|
|
287
|
+
onCancel?.()
|
|
239
288
|
setInputValue(toDisplay(value).toFixed(precision))
|
|
240
289
|
} else {
|
|
241
|
-
const nextValue = clamp(toStored(Number.parseFloat(toDisplay(stored).toFixed(precision))))
|
|
242
290
|
onChange(nextValue)
|
|
243
291
|
onCommit?.(nextValue)
|
|
244
292
|
}
|
|
245
293
|
setIsEditing(false)
|
|
246
|
-
}, [
|
|
247
|
-
inputValue,
|
|
248
|
-
unit,
|
|
249
|
-
isImperial,
|
|
250
|
-
parseUnit,
|
|
251
|
-
onChange,
|
|
252
|
-
onCommit,
|
|
253
|
-
clamp,
|
|
254
|
-
precision,
|
|
255
|
-
value,
|
|
256
|
-
toDisplay,
|
|
257
|
-
toStored,
|
|
258
|
-
])
|
|
294
|
+
}, [inputValue, parseInputValue, onCancel, onChange, onCommit, toDisplay, value, precision])
|
|
259
295
|
|
|
260
296
|
const spec = lingoUnitSpec(unit)
|
|
261
297
|
const hint =
|
|
@@ -274,8 +310,7 @@ export function SliderControl({
|
|
|
274
310
|
if (e.key === 'Enter') {
|
|
275
311
|
submitValue()
|
|
276
312
|
} else if (e.key === 'Escape') {
|
|
277
|
-
|
|
278
|
-
setIsEditing(false)
|
|
313
|
+
cancelEdit()
|
|
279
314
|
} else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
|
280
315
|
e.preventDefault()
|
|
281
316
|
const direction = e.key === 'ArrowUp' ? 1 : -1
|
|
@@ -285,7 +320,7 @@ export function SliderControl({
|
|
|
285
320
|
setInputValue(toDisplay(newV).toFixed(precision))
|
|
286
321
|
}
|
|
287
322
|
},
|
|
288
|
-
[submitValue, value, precision, step, applyDisplayDelta, onChange, toDisplay],
|
|
323
|
+
[submitValue, cancelEdit, value, precision, step, applyDisplayDelta, onChange, toDisplay],
|
|
289
324
|
)
|
|
290
325
|
|
|
291
326
|
const displayValue = toDisplay(shown)
|
|
@@ -309,6 +344,10 @@ export function SliderControl({
|
|
|
309
344
|
onPointerDown={handleLabelPointerDown}
|
|
310
345
|
onPointerMove={handleLabelPointerMove}
|
|
311
346
|
onPointerUp={handleLabelPointerUp}
|
|
347
|
+
onPointerCancel={cancelEdit}
|
|
348
|
+
onLostPointerCapture={() => {
|
|
349
|
+
if (dragRef.current) cancelEdit()
|
|
350
|
+
}}
|
|
312
351
|
ref={labelRef}
|
|
313
352
|
>
|
|
314
353
|
{/* Grip dots — 2×3 grid */}
|
|
@@ -340,7 +379,13 @@ export function SliderControl({
|
|
|
340
379
|
autoFocus
|
|
341
380
|
className="w-14 bg-transparent p-0 text-right font-mono text-foreground outline-none selection:bg-primary/30"
|
|
342
381
|
onBlur={submitValue}
|
|
343
|
-
onChange={(e) =>
|
|
382
|
+
onChange={(e) => {
|
|
383
|
+
setInputValue(e.target.value)
|
|
384
|
+
if (previewWhileTyping) {
|
|
385
|
+
const next = parseInputValue(e.target.value)
|
|
386
|
+
if (next !== null) onChange(next)
|
|
387
|
+
}
|
|
388
|
+
}}
|
|
344
389
|
onKeyDown={handleInputKeyDown}
|
|
345
390
|
type="text"
|
|
346
391
|
value={inputValue}
|
|
@@ -29,6 +29,7 @@ import useEditor, { getActiveContinuationContext } from '../../../store/use-edit
|
|
|
29
29
|
import useInteractionScope, {
|
|
30
30
|
useActiveHandleDrag,
|
|
31
31
|
useMovingNode,
|
|
32
|
+
useReshapingNode,
|
|
32
33
|
} from '../../../store/use-interaction-scope'
|
|
33
34
|
import { BuildingHelper } from './building-helper'
|
|
34
35
|
import { ContextualHelperPanel } from './contextual-helper-panel'
|
|
@@ -84,6 +85,7 @@ function terrainSculptHints(verb: TerrainVerb, sampling: boolean): ContextualSho
|
|
|
84
85
|
}
|
|
85
86
|
|
|
86
87
|
type ActiveModifierKeys = {
|
|
88
|
+
alt: boolean
|
|
87
89
|
command: boolean
|
|
88
90
|
shift: boolean
|
|
89
91
|
}
|
|
@@ -93,6 +95,7 @@ const NO_CONTEXTUAL_HELP_SUBSCRIPTION = () => () => {}
|
|
|
93
95
|
|
|
94
96
|
function useActiveModifierKeys(): ActiveModifierKeys {
|
|
95
97
|
const [modifiers, setModifiers] = useState<ActiveModifierKeys>({
|
|
98
|
+
alt: false,
|
|
96
99
|
command: false,
|
|
97
100
|
shift: false,
|
|
98
101
|
})
|
|
@@ -101,6 +104,7 @@ function useActiveModifierKeys(): ActiveModifierKeys {
|
|
|
101
104
|
const updateModifiers = (event: KeyboardEvent) => {
|
|
102
105
|
const isKeyDown = event.type === 'keydown'
|
|
103
106
|
setModifiers({
|
|
107
|
+
alt: event.altKey || (isKeyDown && event.key === 'Alt'),
|
|
104
108
|
command:
|
|
105
109
|
event.metaKey ||
|
|
106
110
|
event.ctrlKey ||
|
|
@@ -109,7 +113,7 @@ function useActiveModifierKeys(): ActiveModifierKeys {
|
|
|
109
113
|
})
|
|
110
114
|
}
|
|
111
115
|
const clearModifiers = () => {
|
|
112
|
-
setModifiers({ command: false, shift: false })
|
|
116
|
+
setModifiers({ alt: false, command: false, shift: false })
|
|
113
117
|
}
|
|
114
118
|
|
|
115
119
|
window.addEventListener('keydown', updateModifiers)
|
|
@@ -135,6 +139,7 @@ export function HelperManager() {
|
|
|
135
139
|
const workspaceMode = useEditor((s) => s.workspaceMode)
|
|
136
140
|
const scope = useInteractionScope((s) => s.scope)
|
|
137
141
|
const movingNode = useMovingNode()
|
|
142
|
+
const reshapingNode = useReshapingNode()
|
|
138
143
|
const activeHandleDrag = useActiveHandleDrag()
|
|
139
144
|
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
|
140
145
|
const isMobile = useIsMobile()
|
|
@@ -190,10 +195,12 @@ export function HelperManager() {
|
|
|
190
195
|
: single?.type === 'duct-fitting' || single?.type === 'pipe-fitting'
|
|
191
196
|
? 'fitting'
|
|
192
197
|
: null
|
|
198
|
+
const hasOpeningRadiusSelection = single?.type === 'door' || single?.type === 'window'
|
|
193
199
|
return resolveSelectModeHelpHints({
|
|
194
200
|
selectedCount: selectedNodes.length,
|
|
195
201
|
hasMovableSelection: selectedNodes.some((node) => canDirectMoveNode(node)),
|
|
196
202
|
hasRotatableSelection: selectedNodes.some((node) => canDirectRotateNode(node)),
|
|
203
|
+
hasOpeningRadiusSelection,
|
|
197
204
|
commandPressed: modifiers.command,
|
|
198
205
|
shiftPressed: modifiers.shift,
|
|
199
206
|
mepSelection,
|
|
@@ -211,15 +218,16 @@ export function HelperManager() {
|
|
|
211
218
|
// tools — editor shortcut hints would only mislead there.
|
|
212
219
|
if (workspaceMode === 'studio') return null
|
|
213
220
|
|
|
214
|
-
// Rotating a node (or a multi-selection group) via its in-world gizmo:
|
|
215
|
-
// advertise Shift = free rotation, the same angle-step bypass wall drafting
|
|
216
|
-
// exposes. Takes priority over the idle select-mode hints since a handle
|
|
217
|
-
// drag is the active interaction.
|
|
218
221
|
if (
|
|
219
222
|
activeHandleDrag?.label === ROTATE_HANDLE_DRAG_LABEL ||
|
|
220
223
|
activeHandleDrag?.label === GROUP_ROTATE_DRAG_LABEL
|
|
221
224
|
) {
|
|
222
|
-
return
|
|
225
|
+
return (
|
|
226
|
+
<ContextualHelperPanel
|
|
227
|
+
hints={resolveRotateHandleHelpHints(modifiers.alt)}
|
|
228
|
+
snapContext={snapContext}
|
|
229
|
+
/>
|
|
230
|
+
)
|
|
223
231
|
}
|
|
224
232
|
|
|
225
233
|
// Group-move drag / pick-up: the drag resolves to the 'item' snap context
|
|
@@ -254,6 +262,15 @@ export function HelperManager() {
|
|
|
254
262
|
// before the select branch so the idle "drag selected / add objects" hints
|
|
255
263
|
// never leak over an in-progress reshape — and it gets its own snapping chip.
|
|
256
264
|
if (scope.kind === 'reshaping') {
|
|
265
|
+
// A kind's own reshape brings its hints (`def.affordanceHints[reshape]`).
|
|
266
|
+
const hints = reshapingNode
|
|
267
|
+
? nodeRegistry.get(reshapingNode.type)?.affordanceHints?.[scope.reshape]
|
|
268
|
+
: undefined
|
|
269
|
+
if (hints) {
|
|
270
|
+
return (
|
|
271
|
+
<RegisteredToolHelper hints={hints} shiftPressed={modifiers.shift} snapContext={snapContext} />
|
|
272
|
+
)
|
|
273
|
+
}
|
|
257
274
|
return <ContextualHelperPanel hints={reshapingHints(scope.reshape)} snapContext={snapContext} />
|
|
258
275
|
}
|
|
259
276
|
|
|
@@ -297,6 +314,7 @@ export function HelperManager() {
|
|
|
297
314
|
return <ContextualHelperPanel hints={contextualEditHints} snapContext={snapContext} />
|
|
298
315
|
}
|
|
299
316
|
|
|
317
|
+
|
|
300
318
|
// Idle select only — an active scope (handle-drag, box-select, …) must not show
|
|
301
319
|
// the idle selection hints.
|
|
302
320
|
if (mode === 'select' && scope.kind === 'idle') {
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import type { ToolHint } from '@pascal-app/core'
|
|
2
|
-
import { useMemo, useSyncExternalStore } from 'react'
|
|
3
2
|
import type { ContinuationContext } from '../../../lib/continuation'
|
|
4
3
|
import type { SnapContext } from '../../../lib/snapping-mode'
|
|
5
|
-
import
|
|
4
|
+
import { useVisibleToolHints } from '../../../lib/panel-tool-options'
|
|
6
5
|
import { ContextualHelperPanel } from './contextual-helper-panel'
|
|
7
6
|
|
|
8
7
|
/**
|
|
@@ -25,36 +24,7 @@ export function RegisteredToolHelper({
|
|
|
25
24
|
snapContext?: SnapContext | null
|
|
26
25
|
continuationContext?: ContinuationContext | null
|
|
27
26
|
}) {
|
|
28
|
-
|
|
29
|
-
// minimum (e.g. "Finish" at ≥ 3) only appear once they're actually possible.
|
|
30
|
-
const draftVertexCount = useEditor((s) => s.draftVertexCount)
|
|
31
|
-
const visibilityStore = useMemo(
|
|
32
|
-
() => ({
|
|
33
|
-
subscribe: (onChange: () => void) => {
|
|
34
|
-
const unsubscribers = hints.flatMap((hint) =>
|
|
35
|
-
hint.visible ? [hint.visible.subscribe(onChange)] : [],
|
|
36
|
-
)
|
|
37
|
-
return () => {
|
|
38
|
-
for (const unsubscribe of unsubscribers) unsubscribe()
|
|
39
|
-
}
|
|
40
|
-
},
|
|
41
|
-
getSnapshot: () => hints.map((hint) => (hint.visible?.value() === false ? '0' : '1')).join(''),
|
|
42
|
-
}),
|
|
43
|
-
[hints],
|
|
44
|
-
)
|
|
45
|
-
useSyncExternalStore(
|
|
46
|
-
visibilityStore.subscribe,
|
|
47
|
-
visibilityStore.getSnapshot,
|
|
48
|
-
visibilityStore.getSnapshot,
|
|
49
|
-
)
|
|
50
|
-
// Some hints are replaced by live contextual chips, so keep the generic
|
|
51
|
-
// registry renderer from duplicating stale/static versions.
|
|
52
|
-
const visible = hints.filter(
|
|
53
|
-
(hint) =>
|
|
54
|
-
!(hint.key === 'Shift' && hint.label === 'Cycle snapping mode') &&
|
|
55
|
-
hint.visible?.value() !== false &&
|
|
56
|
-
(hint.minDraftVertices == null || draftVertexCount >= hint.minDraftVertices),
|
|
57
|
-
)
|
|
27
|
+
const visible = useVisibleToolHints(hints)
|
|
58
28
|
if (visible.length === 0 && !snapContext && !continuationContext) return null
|
|
59
29
|
// Hints carrying a live-state `chip` render as mode chips next to the
|
|
60
30
|
// snapping / continuation rows; the rest stay static key rows.
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import type { AssetInput } from '@pascal-app/core'
|
|
4
|
-
import { resolveCdnUrl
|
|
4
|
+
import { resolveCdnUrl } from '@pascal-app/viewer'
|
|
5
5
|
import { useEffect } from 'react'
|
|
6
6
|
import { triggerSFX } from './../../../lib/sfx-bus'
|
|
7
7
|
import { cn } from './../../../lib/utils'
|
|
8
8
|
import useEditor, { type CatalogCategory } from './../../../store/use-editor'
|
|
9
9
|
import { resolveAssetSnapTarget, SnapTargetBadge } from '../snap-target-badge'
|
|
10
|
-
import {
|
|
10
|
+
import { activateCatalogItem, filterCatalogItems, isCatalogItemSelected } from '../../../lib/catalog-panel-model'
|
|
11
11
|
|
|
12
12
|
export function ItemCatalog({
|
|
13
13
|
category,
|
|
@@ -32,26 +32,7 @@ export function ItemCatalog({
|
|
|
32
32
|
emptyState?: React.ReactNode
|
|
33
33
|
}) {
|
|
34
34
|
const selectedItem = useEditor((state) => state.selectedItem)
|
|
35
|
-
const
|
|
36
|
-
const setMode = useEditor((state) => state.setMode)
|
|
37
|
-
const setTool = useEditor((state) => state.setTool)
|
|
38
|
-
|
|
39
|
-
const sourceItems: CatalogItem[] = itemsOverride ?? CATALOG_ITEMS
|
|
40
|
-
// Server-provided results bypass all local filtering; otherwise filter by category/search/tags
|
|
41
|
-
const filteredItems: CatalogItem[] =
|
|
42
|
-
overrideItems ??
|
|
43
|
-
(() => {
|
|
44
|
-
const categoryItems = search
|
|
45
|
-
? sourceItems
|
|
46
|
-
: sourceItems.filter((item) => item.category === category)
|
|
47
|
-
return categoryItems.filter((item) => {
|
|
48
|
-
const tags = item.tags ?? []
|
|
49
|
-
if (activePlacementTag && !tags.includes(activePlacementTag)) return false
|
|
50
|
-
if (activeFunctionalTag && !tags.includes(activeFunctionalTag)) return false
|
|
51
|
-
if (search && !item.name.toLowerCase().includes(search.toLowerCase())) return false
|
|
52
|
-
return true
|
|
53
|
-
})
|
|
54
|
-
})()
|
|
35
|
+
const filteredItems = filterCatalogItems({ category, items: itemsOverride, overrideItems, search, activePlacementTag, activeFunctionalTag })
|
|
55
36
|
|
|
56
37
|
if (filteredItems.length === 0 && emptyState) {
|
|
57
38
|
return <>{emptyState}</>
|
|
@@ -64,7 +45,7 @@ export function ItemCatalog({
|
|
|
64
45
|
>
|
|
65
46
|
{leadingTile}
|
|
66
47
|
{filteredItems.map((item, index) => {
|
|
67
|
-
const isSelected = selectedItem
|
|
48
|
+
const isSelected = isCatalogItemSelected(item, selectedItem)
|
|
68
49
|
const snapTarget = resolveAssetSnapTarget(item?.attachTo)
|
|
69
50
|
return (
|
|
70
51
|
<button
|
|
@@ -75,13 +56,7 @@ export function ItemCatalog({
|
|
|
75
56
|
key={index}
|
|
76
57
|
onClick={() => {
|
|
77
58
|
triggerSFX('sfx:menu-click')
|
|
78
|
-
|
|
79
|
-
// it would route shortcuts (rotate & co) to both the ghost and
|
|
80
|
-
// the selected node.
|
|
81
|
-
useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
|
|
82
|
-
setSelectedItem(item)
|
|
83
|
-
setTool(item.tool ?? 'item')
|
|
84
|
-
setMode('build')
|
|
59
|
+
activateCatalogItem(item)
|
|
85
60
|
}}
|
|
86
61
|
onMouseEnter={() => triggerSFX('sfx:menu-hover')}
|
|
87
62
|
type="button"
|
|
@@ -58,6 +58,32 @@ function ceilingCustomHeight(node: CeilingNode, nodes: Record<string, AnyNode>):
|
|
|
58
58
|
return Math.min(resolved, max)
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
export function applyMultiHeightMode(
|
|
62
|
+
nodeIds: AnyNodeId[],
|
|
63
|
+
next: 'storey' | 'custom',
|
|
64
|
+
parametrics: ParametricDescriptor<AnyNode>,
|
|
65
|
+
) {
|
|
66
|
+
const nodes = useScene.getState().nodes as Record<string, AnyNode>
|
|
67
|
+
commitMultiNodeFields(
|
|
68
|
+
nodeIds,
|
|
69
|
+
(node) => {
|
|
70
|
+
const isCustom = (node as { height?: number }).height != null
|
|
71
|
+
if (next === 'custom') {
|
|
72
|
+
if (isCustom) return {}
|
|
73
|
+
if (node.type === 'ceiling') return { height: ceilingCustomHeight(node, nodes) }
|
|
74
|
+
if (node.type === 'wall') {
|
|
75
|
+
return { height: Math.max(0.1, getWallEffectiveHeightForNodes(node, nodes)) }
|
|
76
|
+
}
|
|
77
|
+
return {}
|
|
78
|
+
}
|
|
79
|
+
if (!isCustom) return {}
|
|
80
|
+
if (node.type === 'wall') return wallFollowsLevelPatch(node, nodes)
|
|
81
|
+
return { height: undefined }
|
|
82
|
+
},
|
|
83
|
+
parametrics,
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
61
87
|
export function MultiHeightModeField({
|
|
62
88
|
nodeIds,
|
|
63
89
|
nodeType,
|
|
@@ -97,27 +123,7 @@ export function MultiHeightModeField({
|
|
|
97
123
|
|
|
98
124
|
const applyMode = useCallback(
|
|
99
125
|
(next: 'storey' | 'custom') => {
|
|
100
|
-
|
|
101
|
-
commitMultiNodeFields(
|
|
102
|
-
nodeIds,
|
|
103
|
-
(node) => {
|
|
104
|
-
const isCustom = (node as { height?: number }).height != null
|
|
105
|
-
if (next === 'custom') {
|
|
106
|
-
if (isCustom) return {}
|
|
107
|
-
if (node.type === 'ceiling') {
|
|
108
|
-
return { height: ceilingCustomHeight(node, nodes) }
|
|
109
|
-
}
|
|
110
|
-
if (node.type === 'wall') {
|
|
111
|
-
return { height: Math.max(0.1, getWallEffectiveHeightForNodes(node, nodes)) }
|
|
112
|
-
}
|
|
113
|
-
return {}
|
|
114
|
-
}
|
|
115
|
-
if (!isCustom) return {}
|
|
116
|
-
if (node.type === 'wall') return wallFollowsLevelPatch(node, nodes)
|
|
117
|
-
return { height: undefined }
|
|
118
|
-
},
|
|
119
|
-
parametrics,
|
|
120
|
-
)
|
|
126
|
+
applyMultiHeightMode(nodeIds, next, parametrics)
|
|
121
127
|
},
|
|
122
128
|
[nodeIds, parametrics],
|
|
123
129
|
)
|
|
@@ -15,6 +15,7 @@ import { Icon } from '@iconify/react'
|
|
|
15
15
|
import { Move, Trash2 } from 'lucide-react'
|
|
16
16
|
import { type ComponentType, lazy, Suspense, useCallback } from 'react'
|
|
17
17
|
import { resolveMoveActionNode } from '../../../lib/direct-manipulation'
|
|
18
|
+
import { commitParametricNodeFields } from '../../../lib/parametric-node-update'
|
|
18
19
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
|
19
20
|
import { collectZoneContentIds } from '../../../lib/zone-content'
|
|
20
21
|
import useEditor from '../../../store/use-editor'
|
|
@@ -61,22 +62,9 @@ export function ParametricInspector({
|
|
|
61
62
|
const handleUpdate = useCallback(
|
|
62
63
|
(patch: Partial<AnyNode>) => {
|
|
63
64
|
if (!selectedId) return
|
|
64
|
-
|
|
65
|
-
const node = scene.nodes[selectedId]
|
|
66
|
-
if (parametrics?.derive && node) {
|
|
67
|
-
const next = { ...node, ...patch } as AnyNode
|
|
68
|
-
patch = { ...patch, ...parametrics.derive(next, patch, node as AnyNode) }
|
|
69
|
-
}
|
|
70
|
-
// Bundle the edited node + any reconcile follow-ups into ONE
|
|
71
|
-
// updateNodes call so a single inspector edit is a single undo step.
|
|
72
|
-
const updates: { id: AnyNodeId; data: Partial<AnyNode> }[] = [{ id: selectedId, data: patch }]
|
|
73
|
-
if (parametrics?.reconcile && node) {
|
|
74
|
-
const next = { ...node, ...patch } as AnyNode
|
|
75
|
-
updates.push(...parametrics.reconcile(node as AnyNode, next))
|
|
76
|
-
}
|
|
77
|
-
scene.updateNodes(updates)
|
|
65
|
+
commitParametricNodeFields(selectedId, patch)
|
|
78
66
|
},
|
|
79
|
-
[selectedId
|
|
67
|
+
[selectedId],
|
|
80
68
|
)
|
|
81
69
|
|
|
82
70
|
const clearSelection = useCallback(() => {
|
|
@@ -20,6 +20,10 @@ const MOUSE_SHORTCUTS = {
|
|
|
20
20
|
icon: 'ph:mouse-right-click-fill',
|
|
21
21
|
label: 'Right click',
|
|
22
22
|
},
|
|
23
|
+
Scroll: {
|
|
24
|
+
icon: 'ph:mouse-scroll-fill',
|
|
25
|
+
label: 'Scroll',
|
|
26
|
+
},
|
|
23
27
|
} as const
|
|
24
28
|
|
|
25
29
|
// The platform-agnostic command modifier. Both Cmd and Ctrl bind the action; we
|
|
@@ -28,12 +28,12 @@ export function AudioSettingsDialog() {
|
|
|
28
28
|
<DialogTrigger asChild>
|
|
29
29
|
<Button className="w-full justify-start gap-2" variant="outline">
|
|
30
30
|
{muted ? <VolumeX className="size-4" /> : <Volume2 className="size-4" />}
|
|
31
|
-
Audio
|
|
31
|
+
Audio settings
|
|
32
32
|
</Button>
|
|
33
33
|
</DialogTrigger>
|
|
34
34
|
<DialogContent className="sm:max-w-[425px]">
|
|
35
35
|
<DialogHeader>
|
|
36
|
-
<DialogTitle>Audio
|
|
36
|
+
<DialogTitle>Audio settings</DialogTitle>
|
|
37
37
|
<DialogDescription>Adjust volume levels and mute settings</DialogDescription>
|
|
38
38
|
</DialogHeader>
|
|
39
39
|
<div className="space-y-6 py-4">
|