diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts index 6648f7cf..147bcf41 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -1,4 +1,10 @@ -export { loadPlugin, nodeRegistry, registerNode } from './registry' +export { + getSelectableKinds, + isRegistrySelectable, + loadPlugin, + nodeRegistry, + registerNode, +} from './registry' export { type CascadeContext, type ChildQuery, diff --git a/packages/core/src/registry/registry.ts b/packages/core/src/registry/registry.ts index 29432918..3a37664b 100644 --- a/packages/core/src/registry/registry.ts +++ b/packages/core/src/registry/registry.ts @@ -57,6 +57,34 @@ export function registerNode(def: AnyNodeDefinition): void { 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 { if (plugin.apiVersion !== HOST_API_VERSION) { throw new Error( diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index b9fb6bda..88962699 100644 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -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) => { diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index 0d2c0291..e9907d4d 100644 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -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 = { 'roof-segment', 'stair', 'stair-segment', - 'shelf', 'spawn', 'window', 'door', @@ -649,6 +650,11 @@ const SELECTION_STRATEGIES: Record = { } 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) diff --git a/packages/nodes/src/index.ts b/packages/nodes/src/index.ts index e2474ee0..3362af91 100644 --- a/packages/nodes/src/index.ts +++ b/packages/nodes/src/index.ts @@ -2,52 +2,30 @@ import type { AnyNodeDefinition, Plugin } from '@pascal-app/core' import { shelfDefinition } from './shelf' 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. * * Apps load this once at bootstrap (`loadPlugin(builtinPlugin)`) before * mounting the viewer. New built-in nodes are added by creating a folder - * here under `src//` and appending its `NodeDefinition` to `getBuiltinNodes`. + * here under `src//` and appending its `NodeDefinition` below. * * External plugins follow the exact same shape — same `Plugin` type, same * `loadPlugin` call path. This is intentional: the API is stress-tested * 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 = { id: 'pascal:core', apiVersion: 1, - nodes: getBuiltinNodes(), + nodes: [ + shelfDefinition as unknown as AnyNodeDefinition, + spawnDefinition as unknown as AnyNodeDefinition, + ], } export { shelfDefinition } from './shelf' diff --git a/packages/nodes/src/spawn/renderer.tsx b/packages/nodes/src/spawn/renderer.tsx index fe42d176..afca500f 100644 --- a/packages/nodes/src/spawn/renderer.tsx +++ b/packages/nodes/src/spawn/renderer.tsx @@ -5,13 +5,7 @@ import { useNodeEvents, useViewer } from '@pascal-app/viewer' import { useMemo, useRef } from 'react' import { Color, type Group, Shape } from 'three' -// TEMPORARY (Phase 2 verification): the registry-driven renderer paints -// 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') +const SPAWN_COLOR = new Color('#22c55e') /** * Registry-driven spawn renderer. Behaviorally identical to the legacy diff --git a/packages/viewer/src/components/viewer/selection-manager.tsx b/packages/viewer/src/components/viewer/selection-manager.tsx index b465b3f6..30de805f 100644 --- a/packages/viewer/src/components/viewer/selection-manager.tsx +++ b/packages/viewer/src/components/viewer/selection-manager.tsx @@ -6,6 +6,7 @@ import { type BuildingNode, type ColumnNode, emitter, + getSelectableKinds, type ItemNode, type LevelNode, type NodeEvent, @@ -25,6 +26,10 @@ const tempWorldPos = new Vector3() // Tolerance for edge detection (in meters) 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 = | 'building' | 'level' @@ -35,11 +40,11 @@ type SelectableNodeType = | 'door' | 'column' | 'item' - | 'shelf' | 'slab' | 'ceiling' | 'roof' | 'roof-segment' + | (string & {}) // Expand polygon outward by a small amount to include items on edges const expandPolygon = (polygon: [number, number][], tolerance: number): [number, number][] => { @@ -330,7 +335,9 @@ export const SelectionManager = () => { 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[] = [ 'building', 'level', @@ -339,7 +346,6 @@ export const SelectionManager = () => { 'fence', 'item', 'column', - 'shelf', 'slab', 'ceiling', 'roof', @@ -347,17 +353,22 @@ export const SelectionManager = () => { 'window', 'door', ] - for (const type of allTypes) { - emitter.on(`${type}:enter`, onEnter) - emitter.on(`${type}:leave`, onLeave) - emitter.on(`${type}:click`, onClick) + const registryKinds = getSelectableKinds().filter( + (k) => !(allTypes as readonly string[]).includes(k), + ) as SelectableNodeType[] + 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 () => { - for (const type of allTypes) { - emitter.off(`${type}:enter`, onEnter) - emitter.off(`${type}:leave`, onLeave) - emitter.off(`${type}:click`, onClick) + 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) } } }, [])