@pascal-app/editor 1.0.0 → 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +12 -6
- package/src/components/editor/bake-exporter.tsx +4 -0
- package/src/components/editor/bake-thumbnail.tsx +4 -1
- package/src/components/editor/custom-camera-controls.tsx +22 -74
- package/src/components/editor/editor-layout-mobile.tsx +1 -0
- package/src/components/editor/floating-action-menu.tsx +14 -1
- package/src/components/editor/floorplan-panel.tsx +233 -166
- package/src/components/editor/grid.tsx +31 -7
- package/src/components/editor/group-actions.ts +5 -9
- package/src/components/editor/group-floating-action-menu.tsx +19 -9
- package/src/components/editor/group-move-3d.ts +4 -6
- package/src/components/editor/group-rotate-handle.tsx +81 -35
- package/src/components/editor/group-selection-box-3d.tsx +25 -17
- package/src/components/editor/group-transform-shared.test.ts +126 -1
- package/src/components/editor/group-transform-shared.ts +79 -73
- package/src/components/editor/handles/handle-arrow.tsx +72 -2
- package/src/components/editor/handles/handle-placement.test.ts +111 -0
- package/src/components/editor/handles/handle-placement.ts +69 -0
- package/src/components/editor/handles/linear-resize-drag.ts +13 -2
- package/src/components/editor/handles/use-handle-drag.ts +80 -8
- package/src/components/editor/index.tsx +36 -13
- package/src/components/editor/node-action-menu.tsx +2 -0
- package/src/components/editor/node-arrow-handles.tsx +162 -69
- package/src/components/editor/registry-action-contributions.tsx +53 -0
- package/src/components/editor/selection-manager.tsx +49 -0
- package/src/components/editor/thumbnail-generator.tsx +11 -2
- package/src/components/editor/wall-move-side-handles.tsx +71 -21
- package/src/components/editor/wall-opening-highlights.tsx +2 -3
- package/src/components/editor-2d/floorplan-group-move.tsx +17 -24
- package/src/components/editor-2d/floorplan-registered-tool-layer.tsx +33 -12
- package/src/components/editor-2d/floorplan-registry-action-menu.tsx +12 -1
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +154 -49
- package/src/components/editor-2d/renderers/floorplan-draft-wall-measurement.tsx +166 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +49 -0
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +113 -24
- package/src/components/procedural-items/inspector.tsx +223 -0
- package/src/components/systems/ceiling/ceiling-system.tsx +3 -1
- package/src/components/systems/zone/zone-system.tsx +8 -4
- package/src/components/tools/__fixtures__/hosting.ts +10 -0
- package/src/components/tools/item/item-surface-placement.test.ts +169 -0
- package/src/components/tools/item/item-surface-strategy-resolver.test.ts +204 -0
- package/src/components/tools/item/placement-strategies.ts +212 -200
- package/src/components/tools/item/placement-types.ts +4 -0
- package/src/components/tools/item/shelf-surface-strategy-resolver.test.ts +257 -0
- package/src/components/tools/item/use-draft-node.ts +136 -47
- package/src/components/tools/item/use-placement-coordinator.tsx +391 -157
- package/src/components/tools/registry/item-surface-child-frame.test.ts +90 -0
- package/src/components/tools/registry/item-surface-move.test.ts +509 -0
- package/src/components/tools/registry/item-surface-move.ts +463 -0
- package/src/components/tools/registry/item-surface-resolver.test.ts +257 -0
- package/src/components/tools/registry/move-registry-node-tool.tsx +237 -78
- package/src/components/tools/shared/affordance-dispatch.test.ts +24 -0
- package/src/components/tools/shared/affordance-dispatch.ts +13 -0
- package/src/components/tools/shared/draft-measurement-label.tsx +38 -0
- package/src/components/tools/shared/pointer-support-cap.test.ts +27 -0
- package/src/components/tools/shared/pointer-support-cap.ts +27 -13
- package/src/components/tools/shared/shelf-stickiness.ts +54 -0
- package/src/components/tools/shared/surface-hit.test.ts +56 -0
- package/src/components/tools/shared/surface-hit.ts +23 -0
- package/src/components/tools/shared/surface-rejection.test.tsx +107 -0
- package/src/components/tools/shared/surface-rejection.tsx +43 -0
- package/src/components/tools/tool-manager.tsx +36 -3
- package/src/components/tools/zone/zone-tool.tsx +13 -4
- package/src/components/ui/command-palette/editor-commands.tsx +54 -1
- package/src/components/ui/controls/material-paint-panel.tsx +6 -67
- package/src/components/ui/controls/material-picker.tsx +5 -67
- package/src/components/ui/controls/metric-control.tsx +29 -13
- package/src/components/ui/controls/slider-control.tsx +87 -35
- package/src/components/ui/floating-level-selector.tsx +81 -5
- package/src/components/ui/helpers/helper-manager.tsx +24 -6
- package/src/components/ui/helpers/registered-tool-helper.tsx +2 -32
- package/src/components/ui/item-catalog/item-catalog.tsx +5 -30
- package/src/components/ui/panels/multi-height-mode.tsx +27 -21
- package/src/components/ui/panels/multi-parametric-inspector.tsx +6 -3
- package/src/components/ui/panels/parametric-field-control.tsx +1 -1
- package/src/components/ui/panels/parametric-inspector.tsx +9 -17
- package/src/components/ui/panels/reference-panel.tsx +3 -3
- package/src/components/ui/primitives/checkbox.tsx +34 -0
- package/src/components/ui/primitives/shortcut-token.tsx +4 -0
- package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +669 -336
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +2 -2
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.test.ts +31 -4
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +30 -11
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +8 -1
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +106 -4
- package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +2 -7
- package/src/components/ui/sidebar/panels/site-panel/shelf-tree-node.tsx +3 -8
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +6 -1
- package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +224 -0
- package/src/components/ui/sidebar/panels/site-panel/zone-membership-checkbox.tsx +22 -0
- package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +24 -7
- package/src/components/ui/snap-target-badge.tsx +2 -0
- package/src/components/viewer/floorplan-preview-visibility.test.ts +35 -0
- package/src/components/viewer/floorplan-preview-visibility.ts +19 -0
- package/src/components/viewer/floorplan-preview.tsx +1 -12
- package/src/components/viewer/viewer-scene-header.tsx +3 -0
- package/src/components/viewer/viewer-units-panel.tsx +116 -0
- package/src/components/viewer-zone-system.tsx +7 -2
- package/src/hooks/use-auto-save.integration.test.tsx +197 -0
- package/src/hooks/use-auto-save.ts +21 -6
- package/src/hooks/use-ceiling-events.test.ts +25 -2
- package/src/hooks/use-ceiling-events.ts +7 -1
- package/src/hooks/use-grid-events.test.ts +2 -0
- package/src/hooks/use-grid-events.ts +16 -11
- package/src/hooks/use-keyboard.test.ts +7 -0
- package/src/hooks/use-keyboard.ts +43 -28
- package/src/index.tsx +68 -3
- package/src/lib/catalog-panel-model.ts +39 -0
- package/src/lib/catalog-selection.test.ts +27 -0
- package/src/lib/catalog-selection.ts +8 -0
- package/src/lib/contextual-help.test.ts +27 -1
- package/src/lib/contextual-help.ts +16 -9
- package/src/lib/export-texture-utils.test.ts +167 -0
- package/src/lib/export-texture-utils.ts +121 -0
- package/src/lib/export-user-data.test.ts +48 -0
- package/src/lib/export-user-data.ts +69 -0
- package/src/lib/floorplan/floorplan-export.test.ts +142 -2
- package/src/lib/floorplan/floorplan-export.tsx +9 -3
- package/src/lib/floorplan/floorplan-extension.ts +16 -0
- package/src/lib/floorplan/floorplan-readonly.ts +4 -0
- package/src/lib/fresh-planar-placement.ts +173 -33
- package/src/lib/glb-export-serialize.test.ts +280 -0
- package/src/lib/glb-export.test.ts +265 -16
- package/src/lib/glb-export.ts +197 -37
- package/src/lib/grid-frame.test.ts +19 -0
- package/src/lib/grid-frame.ts +18 -0
- package/src/lib/interaction/scope.ts +9 -1
- package/src/lib/keyboard-pan.test.ts +62 -0
- package/src/lib/keyboard-pan.ts +89 -0
- package/src/lib/level-duplication.test.ts +73 -0
- package/src/lib/level-duplication.ts +32 -2
- package/src/lib/linear-display.test.ts +59 -0
- package/src/lib/linear-display.ts +39 -0
- package/src/lib/local-guide-image.test.ts +24 -0
- package/src/lib/local-guide-image.ts +34 -1
- package/src/lib/material-catalog-model.ts +72 -0
- package/src/lib/material-paint-panel-model.ts +76 -0
- package/src/lib/node-action-movement.ts +21 -0
- package/src/lib/overlay-transform.test.ts +28 -0
- package/src/lib/overlay-transform.ts +12 -0
- package/src/lib/panel-rows.ts +75 -0
- package/src/lib/panel-tool-options.test.ts +34 -0
- package/src/lib/panel-tool-options.ts +46 -0
- package/src/lib/parametric-node-update.ts +37 -0
- package/src/lib/portable-export.test.ts +89 -0
- package/src/lib/portable-export.ts +84 -11
- package/src/lib/print-shell-compiler-manifold-core.ts +59 -10
- package/src/lib/print-shell-compiler-manifold-worker.ts +14 -1
- package/src/lib/print-shell-compiler-manifold.worker.ts +1 -1
- package/src/lib/print-shell-compiler-protocol.ts +8 -0
- package/src/lib/scene-clipboard.ts +1 -1
- package/src/lib/scene.test.ts +99 -0
- package/src/lib/scene.ts +21 -3
- package/src/lib/send-to-app.test.ts +108 -0
- package/src/lib/send-to-app.ts +121 -0
- package/src/lib/sfx/movement-tick.ts +37 -0
- package/src/lib/snapping-mode.test.ts +13 -2
- package/src/lib/snapping-mode.ts +11 -6
- package/src/lib/spatial-drag-plane.test.ts +53 -0
- package/src/lib/spatial-drag-plane.ts +19 -0
- package/src/lib/spatial-pointer-input.test.ts +72 -0
- package/src/lib/spatial-pointer-input.ts +62 -0
- package/src/lib/spatial-snap.test.ts +47 -0
- package/src/lib/surface-attachment.ts +77 -0
- package/src/lib/terrain-panel-model.ts +117 -0
- package/src/lib/units.ts +254 -0
- package/src/lib/usdz-export.ts +43 -16
- package/src/lib/use-linear-display.ts +8 -32
- package/src/lib/world-grid-snap.test.ts +48 -0
- package/src/lib/world-grid-snap.ts +9 -5
- package/src/store/rotation-snapping.test.ts +53 -0
- package/src/store/use-editor.tsx +10 -2
- package/src/store/use-floorplan-draft-preview.ts +10 -1
- package/src/store/use-handle-group.ts +4 -0
- package/src/store/use-interaction-scope.ts +155 -6
- package/src/store/use-send-to-app.ts +21 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import type { AnyNodeId } from '@pascal-app/core'
|
|
4
|
-
import { DEFAULT_LEVEL_HEIGHT, LevelNode, useScene } from '@pascal-app/core'
|
|
4
|
+
import { DEFAULT_LEVEL_HEIGHT, LevelNode, type UnitNode, useScene } from '@pascal-app/core'
|
|
5
5
|
import { useViewer } from '@pascal-app/viewer'
|
|
6
6
|
import {
|
|
7
7
|
AppWindow,
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
EyeOff,
|
|
16
16
|
FileJson,
|
|
17
17
|
Grid3X3,
|
|
18
|
+
Group,
|
|
18
19
|
Hexagon,
|
|
19
20
|
Layers,
|
|
20
21
|
Map,
|
|
@@ -39,6 +40,7 @@ import {
|
|
|
39
40
|
import { useEffect } from 'react'
|
|
40
41
|
import { getHistoryCommandState, runRedo, runUndo } from '../../../lib/history'
|
|
41
42
|
import { deleteLevelWithFallbackSelection } from '../../../lib/level-selection'
|
|
43
|
+
import { createUnitInBuilding, enterUnitFocus, leaveUnitFocus } from '../../../lib/units'
|
|
42
44
|
import { useCommandRegistry } from '../../../store/use-command-registry'
|
|
43
45
|
import type { StructureTool } from '../../../store/use-editor'
|
|
44
46
|
import useEditor from '../../../store/use-editor'
|
|
@@ -56,13 +58,25 @@ export function EditorCommands() {
|
|
|
56
58
|
const setPreviewMode = useEditor((s) => s.setPreviewMode)
|
|
57
59
|
|
|
58
60
|
const exportScene = useViewer((s) => s.exportScene)
|
|
61
|
+
// Focusable units are listed one command each; the key changes only when a
|
|
62
|
+
// unit is added, removed or renamed.
|
|
63
|
+
const unitListKey = useScene((s) =>
|
|
64
|
+
Object.values(s.nodes)
|
|
65
|
+
.filter((n): n is UnitNode => n.type === 'unit')
|
|
66
|
+
.map((n) => `${n.id}:${n.name ?? ''}`)
|
|
67
|
+
.join('|'),
|
|
68
|
+
)
|
|
59
69
|
|
|
60
70
|
// Re-register when exportScene availability changes (it's a conditional action)
|
|
61
71
|
useEffect(() => {
|
|
72
|
+
void unitListKey
|
|
62
73
|
const run = (fn: () => void) => {
|
|
63
74
|
fn()
|
|
64
75
|
setOpen(false)
|
|
65
76
|
}
|
|
77
|
+
const focusableUnits = Object.values(useScene.getState().nodes).filter(
|
|
78
|
+
(n): n is UnitNode => n.type === 'unit',
|
|
79
|
+
)
|
|
66
80
|
|
|
67
81
|
const activateTool = (tool: StructureTool) => {
|
|
68
82
|
run(() => {
|
|
@@ -249,6 +263,44 @@ export function EditorCommands() {
|
|
|
249
263
|
}),
|
|
250
264
|
},
|
|
251
265
|
|
|
266
|
+
// ── Units ────────────────────────────────────────────────────────────
|
|
267
|
+
{
|
|
268
|
+
id: 'editor.unit.new',
|
|
269
|
+
label: 'New unit',
|
|
270
|
+
group: 'Units',
|
|
271
|
+
icon: <Group className="h-4 w-4" />,
|
|
272
|
+
keywords: ['unit', 'apartment', 'hotel', 'room', 'add', 'create', 'new'],
|
|
273
|
+
when: () => Object.values(useScene.getState().nodes).some((n) => n.type === 'building'),
|
|
274
|
+
execute: () =>
|
|
275
|
+
run(() => {
|
|
276
|
+
const { nodes } = useScene.getState()
|
|
277
|
+
const selectedBuildingId = useViewer.getState().selection.buildingId
|
|
278
|
+
const building =
|
|
279
|
+
(selectedBuildingId ? nodes[selectedBuildingId] : undefined) ??
|
|
280
|
+
Object.values(nodes).find((n) => n.type === 'building')
|
|
281
|
+
if (building?.type !== 'building') return
|
|
282
|
+
createUnitInBuilding(building.id)
|
|
283
|
+
}),
|
|
284
|
+
},
|
|
285
|
+
...focusableUnits.map((unit) => ({
|
|
286
|
+
id: `editor.unit.focus.${unit.id}`,
|
|
287
|
+
label: `Focus unit: ${unit.name || 'Unit'}`,
|
|
288
|
+
group: 'Units',
|
|
289
|
+
icon: <Group className="h-4 w-4" />,
|
|
290
|
+
keywords: ['unit', 'focus', 'apartment', unit.name || 'Unit'],
|
|
291
|
+
when: () => useViewer.getState().focusedUnitId !== unit.id,
|
|
292
|
+
execute: () => run(() => enterUnitFocus(unit.id)),
|
|
293
|
+
})),
|
|
294
|
+
{
|
|
295
|
+
id: 'editor.unit.exit-focus',
|
|
296
|
+
label: 'Exit unit focus',
|
|
297
|
+
group: 'Units',
|
|
298
|
+
icon: <Group className="h-4 w-4" />,
|
|
299
|
+
keywords: ['unit', 'focus', 'exit', 'leave', 'apartment'],
|
|
300
|
+
when: () => !!useViewer.getState().focusedUnitId,
|
|
301
|
+
execute: () => run(() => leaveUnitFocus()),
|
|
302
|
+
},
|
|
303
|
+
|
|
252
304
|
// ── Viewer Controls ──────────────────────────────────────────────────
|
|
253
305
|
{
|
|
254
306
|
id: 'editor.viewer.wall-mode',
|
|
@@ -431,6 +483,7 @@ export function EditorCommands() {
|
|
|
431
483
|
isPreviewMode,
|
|
432
484
|
setPreviewMode,
|
|
433
485
|
exportScene,
|
|
486
|
+
unitListKey,
|
|
434
487
|
])
|
|
435
488
|
|
|
436
489
|
return null
|
|
@@ -1,20 +1,9 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
type AnyNodeId,
|
|
5
|
-
generateSceneMaterialId,
|
|
6
|
-
type SceneMaterialId,
|
|
7
|
-
toSceneMaterialRef,
|
|
8
|
-
useScene,
|
|
9
|
-
} from '@pascal-app/core'
|
|
10
|
-
import { useViewer } from '@pascal-app/viewer'
|
|
3
|
+
import { type SceneMaterialId } from '@pascal-app/core'
|
|
11
4
|
import { Eraser, Plus, RotateCcw } from 'lucide-react'
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
buildResetSurfaceMaterialUpdates,
|
|
15
|
-
resolvePaintTargetFromSelection,
|
|
16
|
-
} from './../../../lib/material-paint'
|
|
17
|
-
import useEditor from './../../../store/use-editor'
|
|
5
|
+
import { useState } from 'react'
|
|
6
|
+
import { useMaterialPaintPanelModel } from '../../../lib/material-paint-panel-model'
|
|
18
7
|
import { Button } from '../primitives/button'
|
|
19
8
|
import { Tooltip, TooltipContent, TooltipTrigger } from '../primitives/tooltip'
|
|
20
9
|
import { MaterialPicker } from './material-picker'
|
|
@@ -33,57 +22,9 @@ export type MaterialPaintPanelProps = {
|
|
|
33
22
|
}
|
|
34
23
|
|
|
35
24
|
export function MaterialPaintPanel({ onCreateMaterialRequest }: MaterialPaintPanelProps) {
|
|
36
|
-
const activePaintMaterial =
|
|
37
|
-
const activePaintTarget = useEditor((state) => state.activePaintTarget)
|
|
38
|
-
const armMaterialPaint = useEditor((state) => state.armMaterialPaint)
|
|
39
|
-
const setActivePaintTarget = useEditor((state) => state.setActivePaintTarget)
|
|
40
|
-
const paintEraser = useEditor((state) => state.paintEraser)
|
|
41
|
-
const setPaintEraser = useEditor((state) => state.setPaintEraser)
|
|
42
|
-
// Id of a just-created scene material whose inline editor should open on mount.
|
|
25
|
+
const { activePaintMaterial, paintEraser, setPaintEraser, canResetSelection, resetSelection, materialCount, selectMaterial, createCustomMaterial: createMaterial } = useMaterialPaintPanelModel()
|
|
43
26
|
const [autoEditMaterialId, setAutoEditMaterialId] = useState<SceneMaterialId | null>(null)
|
|
44
|
-
const
|
|
45
|
-
const nodes = useScene((state) => state.nodes)
|
|
46
|
-
const materialCount = useScene((state) => Object.keys(state.materials).length)
|
|
47
|
-
const selectedId = selectedIds.length === 1 ? (selectedIds[0] ?? null) : null
|
|
48
|
-
const selectedNode = selectedId ? nodes[selectedId as AnyNodeId] : null
|
|
49
|
-
const canResetSelection =
|
|
50
|
-
selectedNode != null && resolvePaintTargetFromSelection({ nodes, selectedId }) != null
|
|
51
|
-
|
|
52
|
-
useEffect(() => {
|
|
53
|
-
const selectedPaintTarget = resolvePaintTargetFromSelection({ nodes, selectedId })
|
|
54
|
-
if (selectedPaintTarget) {
|
|
55
|
-
setActivePaintTarget(selectedPaintTarget)
|
|
56
|
-
}
|
|
57
|
-
}, [nodes, selectedId, setActivePaintTarget])
|
|
58
|
-
|
|
59
|
-
const resetSelection = () => {
|
|
60
|
-
if (!selectedNode) return
|
|
61
|
-
useScene.getState().updateNodes(buildResetSurfaceMaterialUpdates(nodes, selectedNode))
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// Create a blank custom scene material, select it as the brush (`scene:` ref so
|
|
65
|
-
// edits propagate), and open its inline editor. Available from any category.
|
|
66
|
-
const createCustomMaterial = () => {
|
|
67
|
-
const id = generateSceneMaterialId()
|
|
68
|
-
const count = Object.keys(useScene.getState().materials).length
|
|
69
|
-
useScene.getState().addSceneMaterial({
|
|
70
|
-
id,
|
|
71
|
-
name: `Material ${count + 1}`,
|
|
72
|
-
material: {
|
|
73
|
-
preset: 'custom',
|
|
74
|
-
properties: {
|
|
75
|
-
color: '#ffffff',
|
|
76
|
-
roughness: 0.5,
|
|
77
|
-
metalness: 0,
|
|
78
|
-
opacity: 1,
|
|
79
|
-
transparent: false,
|
|
80
|
-
side: 'front',
|
|
81
|
-
},
|
|
82
|
-
},
|
|
83
|
-
})
|
|
84
|
-
armMaterialPaint({ materialPreset: toSceneMaterialRef(id), sourceTarget: activePaintTarget })
|
|
85
|
-
setAutoEditMaterialId(id)
|
|
86
|
-
}
|
|
27
|
+
const createCustomMaterial = () => setAutoEditMaterialId(createMaterial())
|
|
87
28
|
|
|
88
29
|
return (
|
|
89
30
|
<div className="flex h-full min-h-0 w-full flex-col">
|
|
@@ -117,9 +58,7 @@ export function MaterialPaintPanel({ onCreateMaterialRequest }: MaterialPaintPan
|
|
|
117
58
|
<div className="min-h-0 flex-1" data-guide-target="paint-material">
|
|
118
59
|
<MaterialPicker
|
|
119
60
|
onCreateMaterialRequest={onCreateMaterialRequest}
|
|
120
|
-
onSelectMaterialPreset={
|
|
121
|
-
armMaterialPaint({ materialPreset, sourceTarget: activePaintTarget })
|
|
122
|
-
}}
|
|
61
|
+
onSelectMaterialPreset={selectMaterial}
|
|
123
62
|
selectedMaterialPreset={activePaintMaterial?.materialPreset}
|
|
124
63
|
/>
|
|
125
64
|
</div>
|
|
@@ -1,23 +1,11 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
getCatalogMaterialById,
|
|
5
|
-
getDynamicLibraryMaterials,
|
|
6
|
-
getLibraryMaterialIdFromRef,
|
|
7
|
-
getLibraryMaterialsVersion,
|
|
8
|
-
getMaterialsForCategory,
|
|
9
|
-
MATERIAL_CATEGORIES,
|
|
10
|
-
type MaterialCatalogItem,
|
|
11
|
-
type MaterialSource,
|
|
12
|
-
type MaterialTarget,
|
|
13
|
-
subscribeLibraryMaterials,
|
|
14
|
-
toLibraryMaterialRef,
|
|
15
|
-
} from '@pascal-app/core'
|
|
3
|
+
import { type MaterialTarget, toLibraryMaterialRef } from '@pascal-app/core'
|
|
16
4
|
import { Plus } from 'lucide-react'
|
|
17
|
-
import {
|
|
5
|
+
import { useMaterialCatalogModel, type MaterialSourceFilter } from '../../../lib/material-catalog-model'
|
|
18
6
|
import { triggerSFX } from '../../../lib/sfx-bus'
|
|
19
7
|
|
|
20
|
-
export type MaterialSourceFilter
|
|
8
|
+
export type { MaterialSourceFilter } from '../../../lib/material-catalog-model'
|
|
21
9
|
|
|
22
10
|
export type MaterialPickerProps = {
|
|
23
11
|
selectedMaterialPreset?: string
|
|
@@ -28,23 +16,10 @@ export type MaterialPickerProps = {
|
|
|
28
16
|
onCreateMaterialRequest?: () => void
|
|
29
17
|
}
|
|
30
18
|
|
|
31
|
-
|
|
32
|
-
// to the Pascal library — the combined list buried the curated set.
|
|
33
|
-
const SOURCE_FILTERS: { id: MaterialSourceFilter; label: string }[] = [
|
|
34
|
-
{ id: 'pascal', label: 'Pascal' },
|
|
35
|
-
{ id: 'mine', label: 'Mine' },
|
|
36
|
-
{ id: 'workspace', label: 'Workspace' },
|
|
37
|
-
{ id: 'community', label: 'Community' },
|
|
38
|
-
]
|
|
39
|
-
|
|
40
|
-
function getCategoryLabel(category: (typeof MATERIAL_CATEGORIES)[number]) {
|
|
19
|
+
function getCategoryLabel(category: string) {
|
|
41
20
|
return category.charAt(0).toUpperCase() + category.slice(1)
|
|
42
21
|
}
|
|
43
22
|
|
|
44
|
-
function filterBySource(items: MaterialCatalogItem[], filter: MaterialSourceFilter) {
|
|
45
|
-
return items.filter((item) => (item.source ?? 'pascal') === filter)
|
|
46
|
-
}
|
|
47
|
-
|
|
48
23
|
/**
|
|
49
24
|
* Catalog material picker: a fixed row of category tabs and a source filter row
|
|
50
25
|
* over a scrollable grid of swatches. Scene-material creation lives in the
|
|
@@ -57,40 +32,7 @@ export function MaterialPicker({
|
|
|
57
32
|
disabled = false,
|
|
58
33
|
onCreateMaterialRequest,
|
|
59
34
|
}: MaterialPickerProps) {
|
|
60
|
-
const
|
|
61
|
-
MATERIAL_CATEGORIES[0],
|
|
62
|
-
)
|
|
63
|
-
const [sourceFilter, setSourceFilter] = useState<MaterialSourceFilter>('pascal')
|
|
64
|
-
// Version counter so host registrations/unregistrations re-render the picker.
|
|
65
|
-
const libraryVersion = useSyncExternalStore(
|
|
66
|
-
subscribeLibraryMaterials,
|
|
67
|
-
getLibraryMaterialsVersion,
|
|
68
|
-
getLibraryMaterialsVersion,
|
|
69
|
-
)
|
|
70
|
-
const hasWorkspaceMaterials = useMemo(
|
|
71
|
-
() => getDynamicLibraryMaterials().some((item) => item.source === 'workspace'),
|
|
72
|
-
[libraryVersion],
|
|
73
|
-
)
|
|
74
|
-
const visibleSourceFilters = SOURCE_FILTERS.filter(
|
|
75
|
-
(filter) => filter.id !== 'workspace' || hasWorkspaceMaterials,
|
|
76
|
-
)
|
|
77
|
-
const availableCategories = MATERIAL_CATEGORIES.filter(
|
|
78
|
-
(category) => getMaterialsForCategory(category).length > 0,
|
|
79
|
-
)
|
|
80
|
-
const catalogItems = filterBySource(getMaterialsForCategory(selectedCategory), sourceFilter)
|
|
81
|
-
|
|
82
|
-
// Keep the visible category in sync with the externally-selected catalog
|
|
83
|
-
// material (a `scene:` ref matches no catalog entry, so the tab stays put).
|
|
84
|
-
useEffect(() => {
|
|
85
|
-
const catalogId = getLibraryMaterialIdFromRef(selectedMaterialPreset) ?? undefined
|
|
86
|
-
const entry = getCatalogMaterialById(catalogId)
|
|
87
|
-
if (entry?.category) setSelectedCategory(entry.category)
|
|
88
|
-
}, [selectedMaterialPreset])
|
|
89
|
-
|
|
90
|
-
const handleCatalogSelect = (materialId: string) => {
|
|
91
|
-
if (disabled) return
|
|
92
|
-
onSelectMaterialPreset?.(toLibraryMaterialRef(materialId))
|
|
93
|
-
}
|
|
35
|
+
const { selectedCategory, setSelectedCategory, sourceFilter, setSourceFilter, visibleSourceFilters, availableCategories, catalogItems, select: handleCatalogSelect } = useMaterialCatalogModel(selectedMaterialPreset, onSelectMaterialPreset, disabled)
|
|
94
36
|
|
|
95
37
|
return (
|
|
96
38
|
<div
|
|
@@ -108,10 +50,6 @@ export function MaterialPicker({
|
|
|
108
50
|
key={category}
|
|
109
51
|
onClick={() => {
|
|
110
52
|
setSelectedCategory(category)
|
|
111
|
-
// Auto-select the first material in the category so the brush is
|
|
112
|
-
// immediately ready (and the swatch shows as selected).
|
|
113
|
-
const first = filterBySource(getMaterialsForCategory(category), sourceFilter)[0]
|
|
114
|
-
if (first) handleCatalogSelect(first.id)
|
|
115
53
|
}}
|
|
116
54
|
type="button"
|
|
117
55
|
>
|
|
@@ -2,11 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useScene } from '@pascal-app/core'
|
|
4
4
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
5
|
-
import {
|
|
6
|
-
lingoUnitSpec,
|
|
7
|
-
measurementHint,
|
|
8
|
-
parseMeasurement,
|
|
9
|
-
} from '../../../lib/measurement-parser'
|
|
5
|
+
import { lingoUnitSpec, measurementHint, parseMeasurement } from '../../../lib/measurement-parser'
|
|
10
6
|
import { useLinearDisplay } from '../../../lib/use-linear-display'
|
|
11
7
|
import { cn } from '../../../lib/utils'
|
|
12
8
|
|
|
@@ -31,8 +27,8 @@ export function MetricControl({
|
|
|
31
27
|
onCommit,
|
|
32
28
|
min = Number.NEGATIVE_INFINITY,
|
|
33
29
|
max = Number.POSITIVE_INFINITY,
|
|
34
|
-
precision = 2,
|
|
35
|
-
step = 1,
|
|
30
|
+
precision: storedPrecision = 2,
|
|
31
|
+
step: storedStep = 1,
|
|
36
32
|
className,
|
|
37
33
|
unit = '',
|
|
38
34
|
restoreOnCommit = true,
|
|
@@ -40,9 +36,12 @@ export function MetricControl({
|
|
|
40
36
|
const {
|
|
41
37
|
isImperial,
|
|
42
38
|
displayUnit,
|
|
39
|
+
parseUnit,
|
|
40
|
+
precision,
|
|
41
|
+
step,
|
|
43
42
|
toDisplay: toDisplayValue,
|
|
44
43
|
toStored: toStoredValue,
|
|
45
|
-
} = useLinearDisplay(unit,
|
|
44
|
+
} = useLinearDisplay(unit, storedPrecision, storedStep)
|
|
46
45
|
|
|
47
46
|
const clamp = useCallback(
|
|
48
47
|
(val: number) => {
|
|
@@ -110,7 +109,14 @@ export function MetricControl({
|
|
|
110
109
|
|
|
111
110
|
container.addEventListener('wheel', handleWheel, { passive: false })
|
|
112
111
|
return () => container.removeEventListener('wheel', handleWheel)
|
|
113
|
-
}, [
|
|
112
|
+
}, [
|
|
113
|
+
isEditing,
|
|
114
|
+
step,
|
|
115
|
+
clamp,
|
|
116
|
+
applyCommittedValue,
|
|
117
|
+
toStoredValue,
|
|
118
|
+
roundStoredValueForDisplayPrecision,
|
|
119
|
+
])
|
|
114
120
|
|
|
115
121
|
useEffect(() => {
|
|
116
122
|
if (!isHovered || isEditing) return
|
|
@@ -226,7 +232,7 @@ export function MetricControl({
|
|
|
226
232
|
const spec = lingoUnitSpec(unit)
|
|
227
233
|
let stored = spec
|
|
228
234
|
? parseMeasurement(inputValue, spec, {
|
|
229
|
-
bareUnit:
|
|
235
|
+
bareUnit: parseUnit ?? spec.unitId,
|
|
230
236
|
system: isImperial ? 'us' : 'metric',
|
|
231
237
|
})
|
|
232
238
|
: null
|
|
@@ -244,6 +250,7 @@ export function MetricControl({
|
|
|
244
250
|
inputValue,
|
|
245
251
|
unit,
|
|
246
252
|
isImperial,
|
|
253
|
+
parseUnit,
|
|
247
254
|
applyCommittedValue,
|
|
248
255
|
clamp,
|
|
249
256
|
toStoredValue,
|
|
@@ -256,9 +263,9 @@ export function MetricControl({
|
|
|
256
263
|
const hint =
|
|
257
264
|
isEditing && spec
|
|
258
265
|
? measurementHint(inputValue, spec, {
|
|
259
|
-
bareUnit:
|
|
266
|
+
bareUnit: parseUnit ?? spec.unitId,
|
|
260
267
|
system: isImperial ? 'us' : 'metric',
|
|
261
|
-
displayUnit:
|
|
268
|
+
displayUnit: parseUnit ?? spec.unitId,
|
|
262
269
|
precision,
|
|
263
270
|
clamp,
|
|
264
271
|
})
|
|
@@ -287,7 +294,16 @@ export function MetricControl({
|
|
|
287
294
|
setInputValue(toDisplayValue(newV).toFixed(precision))
|
|
288
295
|
}
|
|
289
296
|
},
|
|
290
|
-
[
|
|
297
|
+
[
|
|
298
|
+
submitValue,
|
|
299
|
+
value,
|
|
300
|
+
toDisplayValue,
|
|
301
|
+
precision,
|
|
302
|
+
step,
|
|
303
|
+
clamp,
|
|
304
|
+
applyCommittedValue,
|
|
305
|
+
toStoredValue,
|
|
306
|
+
],
|
|
291
307
|
)
|
|
292
308
|
|
|
293
309
|
return (
|
|
@@ -2,11 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useScene } from '@pascal-app/core'
|
|
4
4
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
5
|
-
import {
|
|
6
|
-
lingoUnitSpec,
|
|
7
|
-
measurementHint,
|
|
8
|
-
parseMeasurement,
|
|
9
|
-
} from '../../../lib/measurement-parser'
|
|
5
|
+
import { lingoUnitSpec, measurementHint, parseMeasurement } from '../../../lib/measurement-parser'
|
|
10
6
|
import { useLinearDisplay } from '../../../lib/use-linear-display'
|
|
11
7
|
import { cn } from '../../../lib/utils'
|
|
12
8
|
|
|
@@ -15,6 +11,8 @@ interface SliderControlProps {
|
|
|
15
11
|
value: number
|
|
16
12
|
onChange: (value: number) => void
|
|
17
13
|
onCommit?: (value: number) => void
|
|
14
|
+
onCancel?: () => void
|
|
15
|
+
previewWhileTyping?: boolean
|
|
18
16
|
min?: number
|
|
19
17
|
max?: number
|
|
20
18
|
precision?: number
|
|
@@ -58,21 +56,20 @@ export function SliderControl({
|
|
|
58
56
|
value,
|
|
59
57
|
onChange,
|
|
60
58
|
onCommit,
|
|
59
|
+
onCancel,
|
|
60
|
+
previewWhileTyping = false,
|
|
61
61
|
min = Number.NEGATIVE_INFINITY,
|
|
62
62
|
max = Number.POSITIVE_INFINITY,
|
|
63
|
-
precision = 0,
|
|
64
|
-
step = 1,
|
|
63
|
+
precision: storedPrecision = 0,
|
|
64
|
+
step: storedStep = 1,
|
|
65
65
|
className,
|
|
66
66
|
unit = '',
|
|
67
67
|
restoreOnCommit = true,
|
|
68
68
|
mixed = false,
|
|
69
69
|
}: SliderControlProps) {
|
|
70
|
-
//
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
// and rendered number are in the DISPLAY unit (feet when imperial). For
|
|
74
|
-
// metric and non-length units these conversions are the identity.
|
|
75
|
-
const { isImperial, displayUnit, toDisplay, toStored } = useLinearDisplay(unit, precision)
|
|
70
|
+
// Values and bounds stay in meters; gestures and input use the displayed unit.
|
|
71
|
+
const { isImperial, displayUnit, parseUnit, precision, step, toDisplay, toStored } =
|
|
72
|
+
useLinearDisplay(unit, storedPrecision, storedStep)
|
|
76
73
|
|
|
77
74
|
const [isEditing, setIsEditing] = useState(false)
|
|
78
75
|
const [isDragging, setIsDragging] = useState(false)
|
|
@@ -94,6 +91,9 @@ export function SliderControl({
|
|
|
94
91
|
anchorValue: number
|
|
95
92
|
stepMultiplier: number
|
|
96
93
|
} | null>(null)
|
|
94
|
+
const cancelRef = useRef(onCancel)
|
|
95
|
+
cancelRef.current = onCancel
|
|
96
|
+
const editingRef = useRef(false)
|
|
97
97
|
const labelRef = useRef<HTMLDivElement>(null)
|
|
98
98
|
const shown = dragDisplay ?? value
|
|
99
99
|
const valueRef = useRef(shown)
|
|
@@ -106,7 +106,9 @@ export function SliderControl({
|
|
|
106
106
|
(storedValue: number, displayDelta: number, displayStep: number) =>
|
|
107
107
|
clamp(
|
|
108
108
|
toStored(
|
|
109
|
-
Number.parseFloat(
|
|
109
|
+
Number.parseFloat(
|
|
110
|
+
(toDisplay(storedValue) + displayDelta).toFixed(stepPrecision(displayStep)),
|
|
111
|
+
),
|
|
110
112
|
),
|
|
111
113
|
),
|
|
112
114
|
[clamp, toDisplay, toStored],
|
|
@@ -222,41 +224,82 @@ export function SliderControl({
|
|
|
222
224
|
[onChange, onCommit, restoreOnCommit],
|
|
223
225
|
)
|
|
224
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
|
+
|
|
225
258
|
const handleValueClick = useCallback(() => {
|
|
259
|
+
editingRef.current = true
|
|
226
260
|
setIsEditing(true)
|
|
227
261
|
setInputValue(toDisplay(value).toFixed(precision))
|
|
228
262
|
}, [value, precision, toDisplay])
|
|
229
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
|
+
|
|
230
282
|
const submitValue = useCallback(() => {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
})
|
|
237
|
-
: null
|
|
238
|
-
if (stored === null) {
|
|
239
|
-
// Fallback: a bare number typed in the DISPLAY unit → convert to stored.
|
|
240
|
-
const numValue = Number.parseFloat(inputValue)
|
|
241
|
-
stored = Number.isFinite(numValue) ? toStored(numValue) : null
|
|
242
|
-
}
|
|
243
|
-
if (stored === null) {
|
|
283
|
+
if (!editingRef.current) return
|
|
284
|
+
editingRef.current = false
|
|
285
|
+
const nextValue = parseInputValue(inputValue)
|
|
286
|
+
if (nextValue === null) {
|
|
287
|
+
onCancel?.()
|
|
244
288
|
setInputValue(toDisplay(value).toFixed(precision))
|
|
245
289
|
} else {
|
|
246
|
-
const nextValue = clamp(toStored(Number.parseFloat(toDisplay(stored).toFixed(precision))))
|
|
247
290
|
onChange(nextValue)
|
|
248
291
|
onCommit?.(nextValue)
|
|
249
292
|
}
|
|
250
293
|
setIsEditing(false)
|
|
251
|
-
}, [inputValue,
|
|
294
|
+
}, [inputValue, parseInputValue, onCancel, onChange, onCommit, toDisplay, value, precision])
|
|
252
295
|
|
|
253
296
|
const spec = lingoUnitSpec(unit)
|
|
254
297
|
const hint =
|
|
255
298
|
isEditing && spec
|
|
256
299
|
? measurementHint(inputValue, spec, {
|
|
257
|
-
bareUnit:
|
|
300
|
+
bareUnit: parseUnit ?? spec.unitId,
|
|
258
301
|
system: isImperial ? 'us' : 'metric',
|
|
259
|
-
displayUnit:
|
|
302
|
+
displayUnit: parseUnit ?? spec.unitId,
|
|
260
303
|
precision,
|
|
261
304
|
clamp,
|
|
262
305
|
})
|
|
@@ -267,8 +310,7 @@ export function SliderControl({
|
|
|
267
310
|
if (e.key === 'Enter') {
|
|
268
311
|
submitValue()
|
|
269
312
|
} else if (e.key === 'Escape') {
|
|
270
|
-
|
|
271
|
-
setIsEditing(false)
|
|
313
|
+
cancelEdit()
|
|
272
314
|
} else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
|
273
315
|
e.preventDefault()
|
|
274
316
|
const direction = e.key === 'ArrowUp' ? 1 : -1
|
|
@@ -278,7 +320,7 @@ export function SliderControl({
|
|
|
278
320
|
setInputValue(toDisplay(newV).toFixed(precision))
|
|
279
321
|
}
|
|
280
322
|
},
|
|
281
|
-
[submitValue, value, precision, step, applyDisplayDelta, onChange, toDisplay],
|
|
323
|
+
[submitValue, cancelEdit, value, precision, step, applyDisplayDelta, onChange, toDisplay],
|
|
282
324
|
)
|
|
283
325
|
|
|
284
326
|
const displayValue = toDisplay(shown)
|
|
@@ -302,6 +344,10 @@ export function SliderControl({
|
|
|
302
344
|
onPointerDown={handleLabelPointerDown}
|
|
303
345
|
onPointerMove={handleLabelPointerMove}
|
|
304
346
|
onPointerUp={handleLabelPointerUp}
|
|
347
|
+
onPointerCancel={cancelEdit}
|
|
348
|
+
onLostPointerCapture={() => {
|
|
349
|
+
if (dragRef.current) cancelEdit()
|
|
350
|
+
}}
|
|
305
351
|
ref={labelRef}
|
|
306
352
|
>
|
|
307
353
|
{/* Grip dots — 2×3 grid */}
|
|
@@ -333,7 +379,13 @@ export function SliderControl({
|
|
|
333
379
|
autoFocus
|
|
334
380
|
className="w-14 bg-transparent p-0 text-right font-mono text-foreground outline-none selection:bg-primary/30"
|
|
335
381
|
onBlur={submitValue}
|
|
336
|
-
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
|
+
}}
|
|
337
389
|
onKeyDown={handleInputKeyDown}
|
|
338
390
|
type="text"
|
|
339
391
|
value={inputValue}
|