Selection: registry-driven, drop spawn flag, restore green color
Two concerns from the spike:
1) Selection / floating-action-menu had hardcoded kind lists scattered
across 4 files. Adding 'shelf' to each one per migration was the
wrong abstraction — the user's question "did you make it generic
from the noderegistry?" was the right one. Done now.
Added to @pascal-app/core/registry:
- getSelectableKinds(): string[] — returns all registered kinds
whose definition declares `capabilities.selectable`.
- isRegistrySelectable(kind): boolean — predicate for OR-chains.
Refactored hardcoded sites to merge registry kinds at runtime,
keeping legacy hardcoded lists intact so existing kinds keep
working unchanged:
- editor SelectionManager: 4 subscription loops (enter/leave/click)
+ structure.isValid + getSelectionTarget — all augment with
registry kinds. Phase 6 deletes the hardcoded lists.
- viewer SelectionManager: subscription loop + SelectableNodeType
broadened with `(string & {})` to accept registry kinds.
- floating-action-menu: ALLOWED_TYPES OR'd with isRegistrySelectable.
- Removed the manually-added 'shelf' entries from previous commit
857ddd4; they were redundant once the registry-driven path landed.
Future built-in nodes that declare `capabilities.selectable` get
click-selection + hover + the floating action menu (move/delete
icons) for free, no editing of these 4 files.
2) Spawn parity is signed off. Drop the
NEXT_PUBLIC_USE_REGISTRY_FOR_SPAWN flag entirely; spawn registers
unconditionally in builtinPlugin.nodes. Restored SPAWN_COLOR to
the original #22c55e green (was #ef4444 red as a Phase 2
verification marker).
Pre-existing typecheck errors in editor (ceiling/fence/slab tree-node,
scene.ts buildingId) are unchanged.
630 tests still pass.
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
857ddd4d95
commit
6d97a87547
@@ -10,6 +10,7 @@ import {
|
||||
FenceNode,
|
||||
generateId,
|
||||
ItemNode,
|
||||
isRegistrySelectable,
|
||||
RoofSegmentNode,
|
||||
type SlabNode,
|
||||
SpawnNode,
|
||||
@@ -78,7 +79,12 @@ export function FloatingActionMenu() {
|
||||
// Subscribe just to the selected node so unrelated scene updates do not
|
||||
// re-render this menu.
|
||||
const node = useScene((s) => (selectedId ? (s.nodes[selectedId as AnyNodeId] ?? null) : null))
|
||||
const isValidType = node ? ALLOWED_TYPES.includes(node.type) : false
|
||||
// ALLOWED_TYPES is the hardcoded set; registry-driven kinds (any
|
||||
// NodeDefinition with `capabilities.selectable`) get the floating menu
|
||||
// by default too. Phase 4 collapses these into a single registry check.
|
||||
const isValidType = node
|
||||
? ALLOWED_TYPES.includes(node.type) || isRegistrySelectable(node.type)
|
||||
: false
|
||||
|
||||
// Boolean selector, only re-renders when curving availability actually flips.
|
||||
const canCurveSelectedWall = useScene((s) => {
|
||||
|
||||
@@ -7,7 +7,9 @@ import {
|
||||
emitter,
|
||||
type FenceNode,
|
||||
getMaterialPresetByRef,
|
||||
getSelectableKinds,
|
||||
type ItemNode,
|
||||
isRegistrySelectable,
|
||||
type NodeEvent,
|
||||
type RoofEvent,
|
||||
type RoofNode,
|
||||
@@ -576,7 +578,6 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
|
||||
'roof-segment',
|
||||
'stair',
|
||||
'stair-segment',
|
||||
'shelf',
|
||||
'spawn',
|
||||
'window',
|
||||
'door',
|
||||
@@ -649,6 +650,11 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
|
||||
}
|
||||
if (node.type === 'window' || node.type === 'door') return true
|
||||
|
||||
// Registry-driven: any kind whose NodeDefinition declares the
|
||||
// `selectable` capability is also selectable in structure phase. Phase 4
|
||||
// makes this the only path and deletes the hardcoded chain above.
|
||||
if (isRegistrySelectable(node.type)) return true
|
||||
|
||||
return false
|
||||
},
|
||||
},
|
||||
@@ -705,7 +711,10 @@ const getSelectionTarget = (node: AnyNode): SelectionTarget | null => {
|
||||
node.type === 'stair-segment' ||
|
||||
node.type === 'spawn' ||
|
||||
node.type === 'window' ||
|
||||
node.type === 'door'
|
||||
node.type === 'door' ||
|
||||
// Registry-driven kinds default to structure/elements (Phase 4 reads
|
||||
// `definition.presentation.paletteSection` to route correctly).
|
||||
isRegistrySelectable(node.type)
|
||||
) {
|
||||
return {
|
||||
phase: 'structure',
|
||||
@@ -1013,20 +1022,26 @@ export const SelectionManager = () => {
|
||||
'roof-segment',
|
||||
'stair',
|
||||
'stair-segment',
|
||||
'shelf',
|
||||
'window',
|
||||
'door',
|
||||
'zone',
|
||||
] as const
|
||||
|
||||
for (const type of allTypes) {
|
||||
// Registry-driven kinds get the same subscriptions as the hardcoded list,
|
||||
// so future built-in nodes don't need to edit allTypes per migration.
|
||||
const registryKinds = getSelectableKinds().filter(
|
||||
(k) => !(allTypes as readonly string[]).includes(k),
|
||||
)
|
||||
const subscribedKinds = [...(allTypes as readonly string[]), ...registryKinds]
|
||||
|
||||
for (const type of subscribedKinds) {
|
||||
emitter.on(`${type}:enter` as any, onEnter as any)
|
||||
emitter.on(`${type}:leave` as any, onLeave as any)
|
||||
emitter.on(`${type}:click` as any, onClick as any)
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const type of allTypes) {
|
||||
for (const type of subscribedKinds) {
|
||||
emitter.off(`${type}:enter` as any, onEnter as any)
|
||||
emitter.off(`${type}:leave` as any, onLeave as any)
|
||||
emitter.off(`${type}:click` as any, onClick as any)
|
||||
@@ -1185,12 +1200,18 @@ export const SelectionManager = () => {
|
||||
'roof-segment',
|
||||
'stair',
|
||||
'stair-segment',
|
||||
'shelf',
|
||||
'spawn',
|
||||
'window',
|
||||
'door',
|
||||
]
|
||||
allTypes.forEach((type) => {
|
||||
// Registry-driven kinds get the same subscriptions as the hardcoded list,
|
||||
// so future built-in nodes don't need to edit allTypes per migration.
|
||||
const registryKinds = getSelectableKinds().filter(
|
||||
(k) => !(allTypes as readonly string[]).includes(k),
|
||||
)
|
||||
const subscribedKinds = [...(allTypes as readonly string[]), ...registryKinds]
|
||||
|
||||
subscribedKinds.forEach((type) => {
|
||||
emitter.on(`${type}:click` as any, onClick as any)
|
||||
})
|
||||
|
||||
@@ -1211,7 +1232,7 @@ export const SelectionManager = () => {
|
||||
emitter.on('grid:click', onGridClick)
|
||||
|
||||
return () => {
|
||||
allTypes.forEach((type) => {
|
||||
subscribedKinds.forEach((type) => {
|
||||
emitter.off(`${type}:click` as any, onClick as any)
|
||||
})
|
||||
emitter.off('grid:click', onGridClick)
|
||||
@@ -1337,21 +1358,25 @@ export const SelectionManager = () => {
|
||||
'roof-segment',
|
||||
'stair',
|
||||
'stair-segment',
|
||||
'shelf',
|
||||
'spawn',
|
||||
'window',
|
||||
'door',
|
||||
'zone',
|
||||
'site',
|
||||
]
|
||||
allTypes.forEach((type) => {
|
||||
const registryKinds = getSelectableKinds().filter(
|
||||
(k) => !(allTypes as readonly string[]).includes(k),
|
||||
)
|
||||
const subscribedKinds = [...(allTypes as readonly string[]), ...registryKinds]
|
||||
|
||||
subscribedKinds.forEach((type) => {
|
||||
emitter.on(`${type}:enter` as any, onEnter as any)
|
||||
emitter.on(`${type}:leave` as any, onLeave as any)
|
||||
emitter.on(`${type}:double-click` as any, onDoubleClick as any)
|
||||
})
|
||||
|
||||
return () => {
|
||||
allTypes.forEach((type) => {
|
||||
subscribedKinds.forEach((type) => {
|
||||
emitter.off(`${type}:enter` as any, onEnter as any)
|
||||
emitter.off(`${type}:leave` as any, onLeave as any)
|
||||
emitter.off(`${type}:double-click` as any, onDoubleClick as any)
|
||||
@@ -1412,21 +1437,25 @@ export const SelectionManager = () => {
|
||||
'roof-segment',
|
||||
'stair',
|
||||
'stair-segment',
|
||||
'shelf',
|
||||
'spawn',
|
||||
'window',
|
||||
'door',
|
||||
'zone',
|
||||
] as const
|
||||
|
||||
for (const type of allTypes) {
|
||||
const registryKinds = getSelectableKinds().filter(
|
||||
(k) => !(allTypes as readonly string[]).includes(k),
|
||||
)
|
||||
const subscribedKinds = [...(allTypes as readonly string[]), ...registryKinds]
|
||||
|
||||
for (const type of subscribedKinds) {
|
||||
emitter.on(`${type}:click` as any, onClick as any)
|
||||
emitter.on(`${type}:enter` as any, onEnter as any)
|
||||
emitter.on(`${type}:leave` as any, onLeave as any)
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const type of allTypes) {
|
||||
for (const type of subscribedKinds) {
|
||||
emitter.off(`${type}:click` as any, onClick as any)
|
||||
emitter.off(`${type}:enter` as any, onEnter as any)
|
||||
emitter.off(`${type}:leave` as any, onLeave as any)
|
||||
|
||||
Reference in New Issue
Block a user