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:
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': {
|
||||||
|
|||||||
Reference in New Issue
Block a user