From f874cecf8f72785ee8417d97c0eb9eecfdc54f1e Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Thu, 14 May 2026 17:06:02 -0400 Subject: [PATCH] ParametricInspector: fine-grained per-field subscriptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../ui/panels/parametric-inspector.tsx | 79 +++++++++++-------- 1 file changed, 45 insertions(+), 34 deletions(-) diff --git a/packages/editor/src/components/ui/panels/parametric-inspector.tsx b/packages/editor/src/components/ui/panels/parametric-inspector.tsx index f3b4ebd7..0f153cc6 100644 --- a/packages/editor/src/components/ui/panels/parametric-inspector.tsx +++ b/packages/editor/src/components/ui/panels/parametric-inspector.tsx @@ -3,7 +3,6 @@ import { type AnyNode, type AnyNodeId, - type AnyNodeDefinition, nodeRegistry, type ParamField, useScene, @@ -35,25 +34,23 @@ import { PanelWrapper } from './panel-wrapper' * can't be auto-generated (topology editors etc.). */ 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 updateNode = useScene((s) => s.updateNode) - const deleteNode = useScene((s) => s.deleteNode) - const setMovingNode = useEditor((s) => s.setMovingNode) + // Subscribe only to the *type* — a string primitive that doesn't change + // when slider values change. Without this, every updateNode tick during + // 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) => - selectedId ? s.nodes[selectedId as AnyNodeId] : undefined, - ) - - const def = node ? nodeRegistry.get(node.type) : undefined + const def = nodeType ? nodeRegistry.get(nodeType) : undefined const parametrics = def?.parametrics const handleUpdate = useCallback( (patch: Partial) => { if (!selectedId) return - updateNode(selectedId as AnyNodeId, patch) + useScene.getState().updateNode(selectedId, patch) }, - [selectedId, updateNode], + [selectedId], ) const handleClose = useCallback(() => { @@ -61,23 +58,25 @@ export function ParametricInspector() { }, [setSelection]) const handleMove = useCallback(() => { + if (!selectedId) return + const node = useScene.getState().nodes[selectedId] if (!node) return sfxEmitter.emit('sfx:item-pick') - setMovingNode(node as any) + useEditor.getState().setMovingNode(node as any) setSelection({ selectedIds: [] }) - }, [node, setMovingNode, setSelection]) + }, [selectedId, setSelection]) const handleDelete = useCallback(() => { if (!selectedId) return sfxEmitter.emit('sfx:structure-delete') - deleteNode(selectedId as AnyNodeId) + useScene.getState().deleteNode(selectedId) setSelection({ selectedIds: [] }) - }, [deleteNode, selectedId, setSelection]) + }, [selectedId, setSelection]) - if (!node || !def || !parametrics) return null + if (!selectedId || !def || !parametrics) return null const presentation = def.presentation - const title = presentation?.label ?? node.type + const title = presentation?.label ?? nodeType ?? '' const canMove = !!def.capabilities.movable const canDelete = def.capabilities.deletable !== false @@ -85,19 +84,14 @@ export function ParametricInspector() { {parametrics.groups.map((group, gi) => ( - {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 ( - } - node={node} - onUpdate={handleUpdate} - /> - ) - })} + {group.fields.map((field, fi) => ( + } + nodeId={selectedId} + onUpdate={handleUpdate} + /> + ))} ))} {(canMove || canDelete) && ( @@ -125,13 +119,30 @@ export function ParametricInspector() { interface FieldRendererProps { field: ParamField - node: AnyNode + nodeId: AnyNodeId onUpdate: (patch: Partial) => void } -function FieldRenderer({ field, node, onUpdate }: FieldRendererProps) { +function FieldRenderer({ field, nodeId, onUpdate }: FieldRendererProps) { const key = String(field.key) - const value = (node as Record)[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)[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) { case 'number': {