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:
Wassim SAMAD
2026-05-14 15:22:51 -04:00
co-authored by Claude Opus 4.7
parent 857ddd4d95
commit 6d97a87547
7 changed files with 119 additions and 67 deletions
+7 -1
View File
@@ -1,4 +1,10 @@
export { loadPlugin, nodeRegistry, registerNode } from './registry' export {
getSelectableKinds,
isRegistrySelectable,
loadPlugin,
nodeRegistry,
registerNode,
} from './registry'
export { export {
type CascadeContext, type CascadeContext,
type ChildQuery, type ChildQuery,
+28
View File
@@ -57,6 +57,34 @@ export function registerNode(def: AnyNodeDefinition): void {
nodeRegistry._register(def) nodeRegistry._register(def)
} }
/**
* Returns the set of registered kinds whose definition declares the
* `selectable` capability. Callers that maintain hardcoded "selectable kinds"
* lists (SelectionManager, FloatingActionMenu) should concat this with their
* legacy entries instead of editing the hardcoded list per migration.
*
* Phase 6 deletes the hardcoded lists entirely and uses this function as the
* single source of truth. For now it's additive over the legacy lists so the
* existing kinds keep working unchanged.
*/
export function getSelectableKinds(): string[] {
const result: string[] = []
for (const [kind, def] of nodeRegistry.entries()) {
if (def.capabilities.selectable !== undefined) {
result.push(kind)
}
}
return result
}
/**
* Returns true when the kind is declared selectable in the registry. Use
* in expression chains like `if (node.type === 'wall' || isRegistrySelectable(node.type))`.
*/
export function isRegistrySelectable(kind: string): boolean {
return nodeRegistry.get(kind)?.capabilities.selectable !== undefined
}
export async function loadPlugin(plugin: Plugin): Promise<void> { export async function loadPlugin(plugin: Plugin): Promise<void> {
if (plugin.apiVersion !== HOST_API_VERSION) { if (plugin.apiVersion !== HOST_API_VERSION) {
throw new Error( throw new Error(
@@ -10,6 +10,7 @@ import {
FenceNode, FenceNode,
generateId, generateId,
ItemNode, ItemNode,
isRegistrySelectable,
RoofSegmentNode, RoofSegmentNode,
type SlabNode, type SlabNode,
SpawnNode, SpawnNode,
@@ -78,7 +79,12 @@ export function FloatingActionMenu() {
// Subscribe just to the selected node so unrelated scene updates do not // Subscribe just to the selected node so unrelated scene updates do not
// re-render this menu. // re-render this menu.
const node = useScene((s) => (selectedId ? (s.nodes[selectedId as AnyNodeId] ?? null) : null)) 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. // Boolean selector, only re-renders when curving availability actually flips.
const canCurveSelectedWall = useScene((s) => { const canCurveSelectedWall = useScene((s) => {
@@ -7,7 +7,9 @@ import {
emitter, emitter,
type FenceNode, type FenceNode,
getMaterialPresetByRef, getMaterialPresetByRef,
getSelectableKinds,
type ItemNode, type ItemNode,
isRegistrySelectable,
type NodeEvent, type NodeEvent,
type RoofEvent, type RoofEvent,
type RoofNode, type RoofNode,
@@ -576,7 +578,6 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
'roof-segment', 'roof-segment',
'stair', 'stair',
'stair-segment', 'stair-segment',
'shelf',
'spawn', 'spawn',
'window', 'window',
'door', 'door',
@@ -649,6 +650,11 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
} }
if (node.type === 'window' || node.type === 'door') return true 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 return false
}, },
}, },
@@ -705,7 +711,10 @@ const getSelectionTarget = (node: AnyNode): SelectionTarget | null => {
node.type === 'stair-segment' || node.type === 'stair-segment' ||
node.type === 'spawn' || node.type === 'spawn' ||
node.type === 'window' || 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 { return {
phase: 'structure', phase: 'structure',
@@ -1013,20 +1022,26 @@ export const SelectionManager = () => {
'roof-segment', 'roof-segment',
'stair', 'stair',
'stair-segment', 'stair-segment',
'shelf',
'window', 'window',
'door', 'door',
'zone', 'zone',
] as const ] 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}:enter` as any, onEnter as any)
emitter.on(`${type}:leave` as any, onLeave as any) emitter.on(`${type}:leave` as any, onLeave as any)
emitter.on(`${type}:click` as any, onClick as any) emitter.on(`${type}:click` as any, onClick as any)
} }
return () => { return () => {
for (const type of allTypes) { for (const type of subscribedKinds) {
emitter.off(`${type}:enter` as any, onEnter as any) emitter.off(`${type}:enter` as any, onEnter as any)
emitter.off(`${type}:leave` as any, onLeave as any) emitter.off(`${type}:leave` as any, onLeave as any)
emitter.off(`${type}:click` as any, onClick as any) emitter.off(`${type}:click` as any, onClick as any)
@@ -1185,12 +1200,18 @@ export const SelectionManager = () => {
'roof-segment', 'roof-segment',
'stair', 'stair',
'stair-segment', 'stair-segment',
'shelf',
'spawn', 'spawn',
'window', 'window',
'door', '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) emitter.on(`${type}:click` as any, onClick as any)
}) })
@@ -1211,7 +1232,7 @@ export const SelectionManager = () => {
emitter.on('grid:click', onGridClick) emitter.on('grid:click', onGridClick)
return () => { return () => {
allTypes.forEach((type) => { subscribedKinds.forEach((type) => {
emitter.off(`${type}:click` as any, onClick as any) emitter.off(`${type}:click` as any, onClick as any)
}) })
emitter.off('grid:click', onGridClick) emitter.off('grid:click', onGridClick)
@@ -1337,21 +1358,25 @@ export const SelectionManager = () => {
'roof-segment', 'roof-segment',
'stair', 'stair',
'stair-segment', 'stair-segment',
'shelf',
'spawn', 'spawn',
'window', 'window',
'door', 'door',
'zone', 'zone',
'site', '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}:enter` as any, onEnter as any)
emitter.on(`${type}:leave` as any, onLeave as any) emitter.on(`${type}:leave` as any, onLeave as any)
emitter.on(`${type}:double-click` as any, onDoubleClick as any) emitter.on(`${type}:double-click` as any, onDoubleClick as any)
}) })
return () => { return () => {
allTypes.forEach((type) => { subscribedKinds.forEach((type) => {
emitter.off(`${type}:enter` as any, onEnter as any) emitter.off(`${type}:enter` as any, onEnter as any)
emitter.off(`${type}:leave` as any, onLeave as any) emitter.off(`${type}:leave` as any, onLeave as any)
emitter.off(`${type}:double-click` as any, onDoubleClick as any) emitter.off(`${type}:double-click` as any, onDoubleClick as any)
@@ -1412,21 +1437,25 @@ export const SelectionManager = () => {
'roof-segment', 'roof-segment',
'stair', 'stair',
'stair-segment', 'stair-segment',
'shelf',
'spawn', 'spawn',
'window', 'window',
'door', 'door',
'zone', 'zone',
] as const ] 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}:click` as any, onClick as any)
emitter.on(`${type}:enter` as any, onEnter as any) emitter.on(`${type}:enter` as any, onEnter as any)
emitter.on(`${type}:leave` as any, onLeave as any) emitter.on(`${type}:leave` as any, onLeave as any)
} }
return () => { return () => {
for (const type of allTypes) { for (const type of subscribedKinds) {
emitter.off(`${type}:click` as any, onClick as any) emitter.off(`${type}:click` as any, onClick as any)
emitter.off(`${type}:enter` as any, onEnter as any) emitter.off(`${type}:enter` as any, onEnter as any)
emitter.off(`${type}:leave` as any, onLeave as any) emitter.off(`${type}:leave` as any, onLeave as any)
+11 -33
View File
@@ -2,52 +2,30 @@ import type { AnyNodeDefinition, Plugin } from '@pascal-app/core'
import { shelfDefinition } from './shelf' import { shelfDefinition } from './shelf'
import { spawnDefinition } from './spawn' import { spawnDefinition } from './spawn'
/**
* Feature flag for the Phase 2 spike. When `NEXT_PUBLIC_USE_REGISTRY_FOR_SPAWN`
* is truthy, spawn registers through the registry path; otherwise the legacy
* `SpawnRenderer` and `SpawnTool` in viewer/editor packages own the kind.
*
* Removed in the PR that signs off parity (legacy spawn files deleted in the
* same commit). All other built-in node migrations follow the same pattern.
*/
function isSpawnRegistryEnabled(): boolean {
// Next.js / Turbopack inlines `process.env.NEXT_PUBLIC_*` references at
// build time, but ONLY when the access is a literal property — dynamic
// bracket access (`env[name]`) is not substituted and resolves to
// undefined in the browser. Keep this as a literal so the value is baked
// into the client bundle.
if (typeof process === 'undefined') return false
const flag = process.env.NEXT_PUBLIC_USE_REGISTRY_FOR_SPAWN
return flag === '1' || flag === 'true'
}
function getBuiltinNodes(): AnyNodeDefinition[] {
const nodes: AnyNodeDefinition[] = [
// Shelf is a new kind — no legacy code to flag against. It ships
// unconditionally so users can place it from the tool palette.
shelfDefinition as unknown as AnyNodeDefinition,
]
if (isSpawnRegistryEnabled()) {
nodes.push(spawnDefinition as unknown as AnyNodeDefinition)
}
return nodes
}
/** /**
* Built-in plugin bundling every node kind shipped with the Pascal editor. * Built-in plugin bundling every node kind shipped with the Pascal editor.
* *
* Apps load this once at bootstrap (`loadPlugin(builtinPlugin)`) before * Apps load this once at bootstrap (`loadPlugin(builtinPlugin)`) before
* mounting the viewer. New built-in nodes are added by creating a folder * mounting the viewer. New built-in nodes are added by creating a folder
* here under `src/<kind>/` and appending its `NodeDefinition` to `getBuiltinNodes`. * here under `src/<kind>/` and appending its `NodeDefinition` below.
* *
* External plugins follow the exact same shape — same `Plugin` type, same * External plugins follow the exact same shape — same `Plugin` type, same
* `loadPlugin` call path. This is intentional: the API is stress-tested * `loadPlugin` call path. This is intentional: the API is stress-tested
* by built-ins before any third-party plugin lands. * by built-ins before any third-party plugin lands.
*
* Phase 2 status: shelf is a brand-new kind. Spawn is migrated to the
* registry path — the legacy SpawnRenderer / SpawnTool files are still
* present in viewer/editor packages but short-circuited by the Phase 0
* dispatch shims (`nodeRegistry.has('spawn')` is true → legacy path
* yields). Legacy spawn files are deleted in a follow-up PR.
*/ */
export const builtinPlugin: Plugin = { export const builtinPlugin: Plugin = {
id: 'pascal:core', id: 'pascal:core',
apiVersion: 1, apiVersion: 1,
nodes: getBuiltinNodes(), nodes: [
shelfDefinition as unknown as AnyNodeDefinition,
spawnDefinition as unknown as AnyNodeDefinition,
],
} }
export { shelfDefinition } from './shelf' export { shelfDefinition } from './shelf'
+1 -7
View File
@@ -5,13 +5,7 @@ import { useNodeEvents, useViewer } from '@pascal-app/viewer'
import { useMemo, useRef } from 'react' import { useMemo, useRef } from 'react'
import { Color, type Group, Shape } from 'three' import { Color, type Group, Shape } from 'three'
// TEMPORARY (Phase 2 verification): the registry-driven renderer paints const SPAWN_COLOR = new Color('#22c55e')
// spawns RED so you can visually tell which dispatch path is live. The
// legacy renderer in @pascal-app/viewer is still green. Revert this to
// '#22c55e' once the registry path is signed off for parity. Tracked by
// the NEXT_PUBLIC_USE_REGISTRY_FOR_SPAWN flag — if a spawn renders red
// you're on the new path; green = legacy.
const SPAWN_COLOR = new Color('#ef4444')
/** /**
* Registry-driven spawn renderer. Behaviorally identical to the legacy * Registry-driven spawn renderer. Behaviorally identical to the legacy
@@ -6,6 +6,7 @@ import {
type BuildingNode, type BuildingNode,
type ColumnNode, type ColumnNode,
emitter, emitter,
getSelectableKinds,
type ItemNode, type ItemNode,
type LevelNode, type LevelNode,
type NodeEvent, type NodeEvent,
@@ -25,6 +26,10 @@ const tempWorldPos = new Vector3()
// Tolerance for edge detection (in meters) // Tolerance for edge detection (in meters)
const EDGE_TOLERANCE = 0.5 const EDGE_TOLERANCE = 0.5
// Hardcoded kinds the viewer's selection manager knows about. Registry kinds
// (any NodeDefinition with `capabilities.selectable`) are merged in at
// runtime via getSelectableKinds() — Phase 6 collapses this into a single
// registry-driven list.
type SelectableNodeType = type SelectableNodeType =
| 'building' | 'building'
| 'level' | 'level'
@@ -35,11 +40,11 @@ type SelectableNodeType =
| 'door' | 'door'
| 'column' | 'column'
| 'item' | 'item'
| 'shelf'
| 'slab' | 'slab'
| 'ceiling' | 'ceiling'
| 'roof' | 'roof'
| 'roof-segment' | 'roof-segment'
| (string & {})
// Expand polygon outward by a small amount to include items on edges // Expand polygon outward by a small amount to include items on edges
const expandPolygon = (polygon: [number, number][], tolerance: number): [number, number][] => { const expandPolygon = (polygon: [number, number][], tolerance: number): [number, number][] => {
@@ -330,7 +335,9 @@ export const SelectionManager = () => {
useViewer.setState({ hoveredId: null }) useViewer.setState({ hoveredId: null })
} }
// Subscribe to all node types // Subscribe to all node types. Hardcoded kinds + registry-supplied kinds
// (any NodeDefinition declaring `capabilities.selectable`). Phase 6
// collapses these into a single registry-driven list.
const allTypes: SelectableNodeType[] = [ const allTypes: SelectableNodeType[] = [
'building', 'building',
'level', 'level',
@@ -339,7 +346,6 @@ export const SelectionManager = () => {
'fence', 'fence',
'item', 'item',
'column', 'column',
'shelf',
'slab', 'slab',
'ceiling', 'ceiling',
'roof', 'roof',
@@ -347,17 +353,22 @@ export const SelectionManager = () => {
'window', 'window',
'door', 'door',
] ]
for (const type of allTypes) { const registryKinds = getSelectableKinds().filter(
emitter.on(`${type}:enter`, onEnter) (k) => !(allTypes as readonly string[]).includes(k),
emitter.on(`${type}:leave`, onLeave) ) as SelectableNodeType[]
emitter.on(`${type}:click`, onClick) const subscribedKinds = [...allTypes, ...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 () => { return () => {
for (const type of allTypes) { for (const type of subscribedKinds) {
emitter.off(`${type}:enter`, onEnter) emitter.off(`${type}:enter` as any, onEnter as any)
emitter.off(`${type}:leave`, onLeave) emitter.off(`${type}:leave` as any, onLeave as any)
emitter.off(`${type}:click`, onClick) emitter.off(`${type}:click` as any, onClick as any)
} }
} }
}, []) }, [])