Phase 5 Stage E: full kind migration into packages/nodes
Wholesale move of every remaining kind into its own subdirectory under
`packages/nodes/src/`, finishing the registry-driven migration. Each
kind now ships its definition, schema (re-exported from core), and any
of `geometry` / `renderer` / `system` / `floorplan` / `tool` /
`move-tool` / `panel` / `floorplan-move` / `floorplan-affordances` /
`parametrics` / `preview` it needs — no per-kind code remains under
`packages/editor/src/components/tools/` or
`packages/viewer/src/components/renderers/`.
Deleted (replaced by registry-driven equivalents):
- `tools/{ceiling,column,door,fence,item,slab,spawn,wall,window}/...`
(boundary editors, hole editors, placement tools, move tools,
endpoint movers, curve tools, helpers, math libs)
- `ui/helpers/{ceiling,slab,wall}-helper.tsx`
- `ui/panels/{column,door,elevator,item,roof,roof-segment,spawn,
stair,stair-segment,wall,window}-panel.tsx`
- `viewer/src/components/renderers/{building,ceiling,column,door,
elevator,fence,guide,item,level,roof,roof-segment,scan,site,slab,
spawn,stair,stair-segment,wall,window,zone}-renderer.tsx`
- `viewer/src/components/viewer/legacy-system.tsx`
Added under `packages/nodes/src/`:
- `building/`, `column/`, `elevator/`, `guide/`, `level/`, `roof/`,
`roof-segment/`, `scan/`, `shared/`, `site/`, `stair/`,
`stair-segment/` packages with definition + schema + renderer / system
/ floorplan / panel as appropriate.
- New `floorplan-move.ts` for every kind that supports 2D moves
(ceiling, door, item, shelf, slab, window) — single registry-driven
dispatch path via `def.floorplanMoveTarget`.
- New `floorplan-affordances.ts` for kinds with polygon / endpoint
drags (ceiling, fence, slab, wall) — using the shared
`polygon-vertex-affordance` factories.
- New per-kind `panel.tsx` for kinds with custom inspector content
(door, item, shelf, spawn, wall, window).
- New per-kind `tool.tsx` for placement (door, item, shelf, window).
- New per-kind `move-tool.tsx` for kinds with custom 3D move flows
(door, item, slab, window).
Coordinator + manager updates in `packages/editor/`:
- `tool-manager.tsx` resolves tools from the registry only — no
hardcoded type→component map.
- `panel-manager.tsx` resolves inspector panels the same way.
- `placement-{coordinator,strategies,types}.ts` extended with
shelf-surface placement.
- `selection-manager.tsx` adds the registry-selectable fallback.
- `floorplan-panel.tsx`, `floorplan-background-placement.ts`,
`floorplan-render-context.tsx` updated for the registry layer's new
contract (props, affordance dispatch, render context).
Viewer updates:
- `viewer/index.tsx` drops legacy renderer mounts.
- `node-renderer.tsx` resolves by registry only.
- `scene-bvh.tsx`, `use-node-events.ts`, `level-system.tsx`,
`wall-cutout.tsx`, `zone-system.tsx`, `materials.ts` adjusted for
the registry-only world.
Sidebar tree nodes for ceiling / fence / slab / shelf / tree-node
updated to read from the registered nodes instead of the deleted
legacy renderer trees.
Wiki: new `plugin-authoring.md` page, README index updated.
Tests in `packages/nodes/src/index.test.ts` validate every registered
kind has the required shape.
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
11015ea1ed
commit
d747d2f0ea
@@ -0,0 +1,49 @@
|
||||
import { type NodeDefinition, SiteNode as SiteNodeSchema } from '@pascal-app/core'
|
||||
import { siteParametrics } from './parametrics'
|
||||
import { SiteNode } from './schema'
|
||||
|
||||
/**
|
||||
* Site — Stage A. Top-level container under the scene root; holds
|
||||
* buildings + property-line polygon + zones. No system (sites don't
|
||||
* have per-frame work). Not movable / deletable — they're the scene
|
||||
* root.
|
||||
*/
|
||||
export const siteDefinition: NodeDefinition<typeof SiteNode> = {
|
||||
kind: 'site',
|
||||
schemaVersion: 1,
|
||||
schema: SiteNode,
|
||||
category: 'site',
|
||||
|
||||
defaults: () => {
|
||||
const stub = SiteNodeSchema.parse({ id: 'site_default' as never, type: 'site' })
|
||||
const { id: _id, type: _type, ...rest } = stub
|
||||
return rest
|
||||
},
|
||||
|
||||
capabilities: {
|
||||
// Site is the root container — sidebar / property-line tool drive
|
||||
// selection, never 3D click (event bubbling from descendants would
|
||||
// override their selection). Same reasoning as `level`.
|
||||
duplicable: false,
|
||||
deletable: false,
|
||||
},
|
||||
|
||||
parametrics: siteParametrics,
|
||||
|
||||
renderer: {
|
||||
kind: 'parametric',
|
||||
module: () => import('./renderer'),
|
||||
},
|
||||
|
||||
presentation: {
|
||||
label: 'Site',
|
||||
description: 'The top-level container holding buildings, zones, and the property boundary.',
|
||||
icon: { kind: 'url', src: '/icons/site.png' },
|
||||
paletteSection: 'site',
|
||||
paletteOrder: 5,
|
||||
},
|
||||
|
||||
mcp: {
|
||||
description: 'Top-level site container.',
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { siteDefinition } from './definition'
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { ParametricDescriptor, SiteNode } from '@pascal-app/core'
|
||||
|
||||
export const siteParametrics: ParametricDescriptor<SiteNode> = {
|
||||
groups: [],
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
'use client'
|
||||
|
||||
import { type SiteNode, type SlabNode, useRegistry, useScene } from '@pascal-app/core'
|
||||
import { NodeRenderer, unionPolygons, useNodeEvents, useViewer } from '@pascal-app/viewer'
|
||||
import { useMemo, useRef } from 'react'
|
||||
import { BufferGeometry, Float32BufferAttribute, type Group, Path, Shape } from 'three'
|
||||
|
||||
const Y_OFFSET = 0.01
|
||||
|
||||
/**
|
||||
* Creates simple line geometry for site boundary
|
||||
* Single horizontal line at ground level
|
||||
*/
|
||||
const createBoundaryLineGeometry = (points: Array<[number, number]>): BufferGeometry => {
|
||||
const geometry = new BufferGeometry()
|
||||
|
||||
if (points.length < 2) return geometry
|
||||
|
||||
const positions: number[] = []
|
||||
|
||||
// Create a simple line loop at ground level
|
||||
for (const [x, z] of points) {
|
||||
positions.push(x ?? 0, Y_OFFSET, z ?? 0)
|
||||
}
|
||||
// Close the loop
|
||||
positions.push(points[0]?.[0] ?? 0, Y_OFFSET, points[0]?.[1] ?? 0)
|
||||
|
||||
geometry.setAttribute('position', new Float32BufferAttribute(positions, 3))
|
||||
|
||||
return geometry
|
||||
}
|
||||
|
||||
type S = ReturnType<typeof useScene.getState>
|
||||
|
||||
export const SiteRenderer = ({ node }: { node: SiteNode }) => {
|
||||
const ref = useRef<Group>(null!)
|
||||
|
||||
useRegistry(node.id, 'site', ref)
|
||||
|
||||
const theme = useViewer((state) => state.theme)
|
||||
const bgColor = theme === 'dark' ? '#1f2433' : '#fafafa'
|
||||
|
||||
// Cache slab polygon references to keep the selector stable across unrelated store updates
|
||||
const slabPolygonsCache = useRef<[number, number][][]>([])
|
||||
const slabPolygons = useScene((state: S) => {
|
||||
const nodeList = Object.values(state.nodes)
|
||||
|
||||
const levelIndexById = new Map<string, number>()
|
||||
let lowestLevelIndex = Number.POSITIVE_INFINITY
|
||||
nodeList.forEach((n) => {
|
||||
if (n.type !== 'level') return
|
||||
levelIndexById.set(n.id, n.level)
|
||||
lowestLevelIndex = Math.min(lowestLevelIndex, n.level)
|
||||
})
|
||||
|
||||
const next = nodeList
|
||||
.filter(
|
||||
(n): n is SlabNode =>
|
||||
n.type === 'slab' &&
|
||||
n.visible &&
|
||||
n.polygon.length >= 3 &&
|
||||
// Only recessed slabs should punch through the site ground.
|
||||
// Positive slabs are real floor geometry and should not create a
|
||||
// ghost footprint in the background ground fill.
|
||||
(n.elevation ?? 0.05) < 0,
|
||||
)
|
||||
.filter((n) => {
|
||||
if (!Number.isFinite(lowestLevelIndex)) return true
|
||||
const parentLevel = n.parentId ? levelIndexById.get(n.parentId as string) : undefined
|
||||
return parentLevel === lowestLevelIndex
|
||||
})
|
||||
.map((n) => n.polygon as [number, number][])
|
||||
|
||||
const prev = slabPolygonsCache.current
|
||||
if (next.length === prev.length && next.every((p, i) => p === prev[i])) return prev
|
||||
slabPolygonsCache.current = next
|
||||
return next
|
||||
})
|
||||
|
||||
// Ground shape: site polygon with slab footprints punched as holes
|
||||
const groundShape = useMemo(() => {
|
||||
if (!node?.polygon?.points || node.polygon.points.length < 3) return null
|
||||
|
||||
const pts = node.polygon.points
|
||||
const shape = new Shape()
|
||||
shape.moveTo(pts[0]![0], -pts[0]![1])
|
||||
for (let i = 1; i < pts.length; i++) shape.lineTo(pts[i]![0], -pts[i]![1])
|
||||
shape.closePath()
|
||||
|
||||
if (slabPolygons.length > 0) {
|
||||
for (const ring of unionPolygons(slabPolygons.map((p) => p.map((pt) => [pt[0], -pt[1]])))) {
|
||||
if (ring.length < 3) continue
|
||||
const hole = new Path()
|
||||
hole.moveTo(ring[0]![0], ring[0]![1])
|
||||
for (let i = 1; i < ring.length; i++) hole.lineTo(ring[i]![0], ring[i]![1])
|
||||
hole.closePath()
|
||||
shape.holes.push(hole)
|
||||
}
|
||||
}
|
||||
|
||||
return shape
|
||||
}, [node?.polygon?.points, slabPolygons])
|
||||
|
||||
// Create boundary line geometry
|
||||
const lineGeometry = useMemo(() => {
|
||||
if (!node?.polygon?.points || node.polygon.points.length < 2) return null
|
||||
return createBoundaryLineGeometry(node.polygon.points)
|
||||
}, [node?.polygon?.points])
|
||||
|
||||
const handlers = useNodeEvents(node, 'site')
|
||||
|
||||
if (!(node && lineGeometry)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<group ref={ref} {...handlers}>
|
||||
{/* Render children (buildings and items) */}
|
||||
{node.children.map((child) => (
|
||||
<NodeRenderer
|
||||
key={typeof child === 'string' ? child : child.id}
|
||||
nodeId={typeof child === 'string' ? child : child.id}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Ground fill: site polygon with slab holes, occludes below-grade geometry */}
|
||||
{groundShape && (
|
||||
<mesh position={[0, -0.05, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<shapeGeometry args={[groundShape]} />
|
||||
<meshBasicMaterial
|
||||
color={bgColor}
|
||||
polygonOffset={true}
|
||||
polygonOffsetFactor={1}
|
||||
polygonOffsetUnits={1}
|
||||
/>
|
||||
</mesh>
|
||||
)}
|
||||
|
||||
{/* Simple boundary line */}
|
||||
{/* @ts-ignore */}
|
||||
<line frustumCulled={false} geometry={lineGeometry} renderOrder={9}>
|
||||
<lineBasicMaterial color="#f59e0b" linewidth={2} opacity={0.6} transparent />
|
||||
</line>
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
export default SiteRenderer
|
||||
@@ -0,0 +1 @@
|
||||
export { SiteNode } from '@pascal-app/core'
|
||||
Reference in New Issue
Block a user