ParametricInspector: fine-grained per-field subscriptions

Subscribe to node.type at the top and to each field's value individually
inside FieldRenderer. Slider drags previously re-rendered the entire
inspector + every field every tick because the panel subscribed to the
whole node object (which gets a new reference on every updateNode).
Primitive field values stay === equal across unrelated mutations, so
now only the dragged field re-renders.

Handlers (move/delete/update) use useScene.getState() inside callbacks
instead of subscribing — they only need the current value, not a
reactive read.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-05-14 17:06:02 -04:00
co-authored by Claude Opus 4.7
parent 9d17683feb
commit f874cecf8f
@@ -3,7 +3,6 @@
import { import {
type AnyNode, type AnyNode,
type AnyNodeId, type AnyNodeId,
type AnyNodeDefinition,
nodeRegistry, nodeRegistry,
type ParamField, type ParamField,
useScene, useScene,
@@ -35,25 +34,23 @@ import { PanelWrapper } from './panel-wrapper'
* can't be auto-generated (topology editors etc.). * can't be auto-generated (topology editors etc.).
*/ */
export function ParametricInspector() { export function ParametricInspector() {
const selectedId = useViewer((s) => s.selection.selectedIds[0]) const selectedId = useViewer((s) => s.selection.selectedIds[0]) as AnyNodeId | undefined
const setSelection = useViewer((s) => s.setSelection) const setSelection = useViewer((s) => s.setSelection)
const updateNode = useScene((s) => s.updateNode) // Subscribe only to the *type* — a string primitive that doesn't change
const deleteNode = useScene((s) => s.deleteNode) // when slider values change. Without this, every updateNode tick during
const setMovingNode = useEditor((s) => s.setMovingNode) // a drag re-renders the entire panel + every field + every SliderControl.
// Per-field subscriptions live on FieldRenderer below.
const nodeType = useScene((s) => (selectedId ? (s.nodes[selectedId]?.type ?? null) : null))
const node = useScene((s) => const def = nodeType ? nodeRegistry.get(nodeType) : undefined
selectedId ? s.nodes[selectedId as AnyNodeId] : undefined,
)
const def = node ? nodeRegistry.get(node.type) : undefined
const parametrics = def?.parametrics const parametrics = def?.parametrics
const handleUpdate = useCallback( const handleUpdate = useCallback(
(patch: Partial<AnyNode>) => { (patch: Partial<AnyNode>) => {
if (!selectedId) return if (!selectedId) return
updateNode(selectedId as AnyNodeId, patch) useScene.getState().updateNode(selectedId, patch)
}, },
[selectedId, updateNode], [selectedId],
) )
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
@@ -61,23 +58,25 @@ export function ParametricInspector() {
}, [setSelection]) }, [setSelection])
const handleMove = useCallback(() => { const handleMove = useCallback(() => {
if (!selectedId) return
const node = useScene.getState().nodes[selectedId]
if (!node) return if (!node) return
sfxEmitter.emit('sfx:item-pick') sfxEmitter.emit('sfx:item-pick')
setMovingNode(node as any) useEditor.getState().setMovingNode(node as any)
setSelection({ selectedIds: [] }) setSelection({ selectedIds: [] })
}, [node, setMovingNode, setSelection]) }, [selectedId, setSelection])
const handleDelete = useCallback(() => { const handleDelete = useCallback(() => {
if (!selectedId) return if (!selectedId) return
sfxEmitter.emit('sfx:structure-delete') sfxEmitter.emit('sfx:structure-delete')
deleteNode(selectedId as AnyNodeId) useScene.getState().deleteNode(selectedId)
setSelection({ selectedIds: [] }) setSelection({ selectedIds: [] })
}, [deleteNode, selectedId, setSelection]) }, [selectedId, setSelection])
if (!node || !def || !parametrics) return null if (!selectedId || !def || !parametrics) return null
const presentation = def.presentation const presentation = def.presentation
const title = presentation?.label ?? node.type const title = presentation?.label ?? nodeType ?? ''
const canMove = !!def.capabilities.movable const canMove = !!def.capabilities.movable
const canDelete = def.capabilities.deletable !== false const canDelete = def.capabilities.deletable !== false
@@ -85,19 +84,14 @@ export function ParametricInspector() {
<PanelWrapper onClose={handleClose} title={title} width={320}> <PanelWrapper onClose={handleClose} title={title} width={320}>
{parametrics.groups.map((group, gi) => ( {parametrics.groups.map((group, gi) => (
<PanelSection key={`group-${gi}`} title={group.label}> <PanelSection key={`group-${gi}`} title={group.label}>
{group.fields.map((field, fi) => { {group.fields.map((field, fi) => (
// Skip fields whose visibleIf predicate excludes the current node.
const visibleIf = (field as { visibleIf?: (n: AnyNode) => boolean }).visibleIf
if (visibleIf && !visibleIf(node)) return null
return (
<FieldRenderer <FieldRenderer
key={`field-${gi}-${fi}-${String(field.key)}`} key={`field-${gi}-${fi}-${String(field.key)}`}
field={field as ParamField<AnyNode>} field={field as ParamField<AnyNode>}
node={node} nodeId={selectedId}
onUpdate={handleUpdate} onUpdate={handleUpdate}
/> />
) ))}
})}
</PanelSection> </PanelSection>
))} ))}
{(canMove || canDelete) && ( {(canMove || canDelete) && (
@@ -125,13 +119,30 @@ export function ParametricInspector() {
interface FieldRendererProps { interface FieldRendererProps {
field: ParamField<AnyNode> field: ParamField<AnyNode>
node: AnyNode nodeId: AnyNodeId
onUpdate: (patch: Partial<AnyNode>) => void onUpdate: (patch: Partial<AnyNode>) => void
} }
function FieldRenderer({ field, node, onUpdate }: FieldRendererProps) { function FieldRenderer({ field, nodeId, onUpdate }: FieldRendererProps) {
const key = String(field.key) const key = String(field.key)
const value = (node as Record<string, unknown>)[key] // Subscribe only to this field's value. Zustand compares with ===, so when
// another field on the same node changes (which produces a new node object
// reference), this primitive value stays equal and the field doesn't
// re-render. Vec3 arrays get a new reference only when the array itself
// changes — same outcome.
const value = useScene((s) => {
const n = s.nodes[nodeId]
return n ? (n as Record<string, unknown>)[key] : undefined
})
// visibleIf may consult other fields on the node — subscribe to its boolean
// result so we re-evaluate when relevant.
const visible = useScene((s) => {
const visibleIf = (field as { visibleIf?: (n: AnyNode) => boolean }).visibleIf
if (!visibleIf) return true
const n = s.nodes[nodeId]
return n ? visibleIf(n as AnyNode) : false
})
if (!visible) return null
switch (field.kind) { switch (field.kind) {
case 'number': { case 'number': {