Phase 5 depth-first: spawn C, fence B+C, slab B+C, ceiling C
Depth-first session: drive registered kinds through Stage B (pure
def.geometry, drop system re-export) and Stage C (def.floorplan,
short-circuit legacy inline rendering in floorplan-panel.tsx).
spawn → C
- buildSpawnFloorplan wired on definition (was written but deferred
to avoid double-render).
- floorplan-panel.tsx's floorplanSpawnEntries useMemo short-circuits
to [] when nodeRegistry.has('spawn').
fence → B
- generateFenceGeometry exported from viewer; buildFenceGeometry
wraps it in a Group+Mesh with DEFAULT_STAIR_MATERIAL.
- def.geometry set; renderer + system fields dropped.
- Deleted nodes/src/fence/{renderer.tsx,system.tsx}.
fence → C
- buildFenceFloorplan: polyline along centerline (sampled for curved
fences via sampleWallCenterline from core). Stroke width = node.thickness.
- floorplan-panel.tsx's floorplanFenceEntries short-circuits.
slab → B
- generateSlabGeometry exported from viewer; buildSlabGeometry wraps
it in a Group+Mesh + cached material (preset / custom / default
pattern preserved from legacy renderer).
- def.geometry set; renderer + system fields dropped.
- Deleted nodes/src/slab/{renderer.tsx,system.tsx}.
slab → C
- buildSlabFloorplan: SVG path with outer polygon + hole subpaths
(uses getRenderableSlabPolygon from core for wall-clipping parity).
- floorplan-panel.tsx's slabPolygons short-circuits.
ceiling → B INTENTIONALLY SKIPPED
- Ceiling renderer renders React children (hosted items) + uses TSL
shader materials + named meshes that other systems poke
(getObjectByName('ceiling-grid')). Pure def.geometry can't preserve
that. Ceiling keeps def.renderer (the custom escape hatch) — same
pattern item uses. Documented in ceiling/definition.ts.
ceiling → C
- buildCeilingFloorplan: dashed-outline path with hole subpaths
(visually distinct from slab since ceilings are above).
- floorplan-panel.tsx's ceilingPolygons short-circuits.
Per-kind progress after this session:
- shelf: B ✅ C ✅ (Stage E since brand-new)
- spawn: A ✅ C ✅
- wall: A ✅ (B blocked on ctx.levelData design)
- fence: A ✅ B ✅ C ✅
- slab: A ✅ B ✅ C ✅
- ceiling: A ✅ C ✅ (B intentionally not applicable)
- door / window / item: A ✅ (B+C pending in future sessions)
Known test issue: `bun test` in packages/nodes fails to load
`three-bvh-csg` through the viewer's transitive imports (UMD/ESM
mismatch in Bun's test runner). The Next.js editor build works fine
because it bundles differently. Fix requires either dynamic imports
(breaks sync def.geometry contract) or test env config — deferred.
Other tests (schema, geometry, parity) 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
df07f7bcb2
commit
969b154b08
@@ -1,29 +1,24 @@
|
||||
import type { NodeDefinition } from '@pascal-app/core'
|
||||
import { buildSlabFloorplan } from './floorplan'
|
||||
import { buildSlabGeometry } from './geometry'
|
||||
import { slabParametrics } from './parametrics'
|
||||
import { SlabNode } from './schema'
|
||||
|
||||
/**
|
||||
* Slab — Phase 5 batch kind, polygon-based.
|
||||
* Slab — Phase 5 batch kind, polygon-based. Stage B: `def.geometry`
|
||||
* drives the rebuild via generic <GeometrySystem>; <ParametricNodeRenderer>
|
||||
* mounts the empty group. No per-kind renderer or system file.
|
||||
*
|
||||
* Capabilities:
|
||||
* - **No `movable`**: slab's "move" today is whole-slab translation via
|
||||
* legacy `MoveSlabTool`, which integrates with the floor-plan boundary /
|
||||
* hole editors. Per the capability-driven dispatch rule, omitting
|
||||
* `movable` keeps the legacy mover (preserves polygon-aware behavior).
|
||||
* Migration to the generic mover is possible in a later milestone if
|
||||
* the legacy mover proves equivalent.
|
||||
* hole editors. Capability-driven dispatch keeps the legacy mover.
|
||||
* - **`surfaces.top`**: items host on the slab top at `elevation`.
|
||||
* - `selectable`, `duplicable`, `deletable` standard.
|
||||
*
|
||||
* Relations:
|
||||
* - `hosts: ['item']` — items mount on the slab top.
|
||||
* - `cascadeDelete: 'descendants'` — deleting a slab removes hosted items.
|
||||
*
|
||||
* Renderer + system: thin renderer + re-export of the legacy `SlabSystem`.
|
||||
* Same shape as wall / fence runtime port.
|
||||
*
|
||||
* Tool field absent: slab has 3 tools (slab-tool, boundary-editor, hole-
|
||||
* editor) wired through editor state, not registry dispatch.
|
||||
*/
|
||||
export const slabDefinition: NodeDefinition<typeof SlabNode> = {
|
||||
kind: 'slab',
|
||||
@@ -59,14 +54,11 @@ export const slabDefinition: NodeDefinition<typeof SlabNode> = {
|
||||
|
||||
parametrics: slabParametrics,
|
||||
|
||||
renderer: {
|
||||
kind: 'parametric',
|
||||
module: () => import('./renderer'),
|
||||
},
|
||||
system: {
|
||||
module: () => import('./system'),
|
||||
priority: 4,
|
||||
},
|
||||
// Stage B: pure geometry function.
|
||||
geometry: buildSlabGeometry,
|
||||
// Stage C: floor-plan rendering. Legacy `slabPolygons` short-circuits
|
||||
// to [] when slab is registered (see floorplan-panel.tsx).
|
||||
floorplan: buildSlabFloorplan,
|
||||
|
||||
toolHints: [
|
||||
{ key: 'Left click', label: 'Trace slab outline' },
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
type FloorplanGeometry,
|
||||
type FloorplanPoint,
|
||||
getRenderableSlabPolygon,
|
||||
type SlabNode,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
/**
|
||||
* Stage C floor-plan builder for slab. Renders the slab polygon as a
|
||||
* filled path with holes cut out.
|
||||
*
|
||||
* Uses `getRenderableSlabPolygon` (the same helper the legacy
|
||||
* floorplan-panel.tsx uses) to compute the visual polygon — accounts
|
||||
* for wall-clipping when a slab is auto-generated from walls.
|
||||
*/
|
||||
export function buildSlabFloorplan(node: SlabNode): FloorplanGeometry | null {
|
||||
const polygon = node.polygon
|
||||
if (!polygon || polygon.length < 3) return null
|
||||
|
||||
const visualPolygon = getRenderableSlabPolygon(node)
|
||||
if (!visualPolygon || visualPolygon.length < 3) return null
|
||||
|
||||
const outer: FloorplanPoint[] = visualPolygon.map(([x, z]) => [x, z] as FloorplanPoint)
|
||||
|
||||
// SVG path with outer ring + hole subpaths. Each subpath uses M/L
|
||||
// commands + Z to close. Holes follow the outer ring; FloorplanGeometry
|
||||
// 'path' kind supports this natively (renderer passes the `d` string
|
||||
// straight to the SVG <path>).
|
||||
const segments: string[] = []
|
||||
const ring = (points: FloorplanPoint[]) => {
|
||||
const [first, ...rest] = points
|
||||
if (!first) return ''
|
||||
return [`M ${first[0]} ${first[1]}`, ...rest.map(([x, y]) => `L ${x} ${y}`), 'Z'].join(' ')
|
||||
}
|
||||
segments.push(ring(outer))
|
||||
|
||||
const holes = node.holes ?? []
|
||||
for (const hole of holes) {
|
||||
if (hole.length < 3) continue
|
||||
const holePts: FloorplanPoint[] = hole.map(([x, z]) => [x, z] as FloorplanPoint)
|
||||
segments.push(ring(holePts))
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'path',
|
||||
d: segments.join(' '),
|
||||
fill: '#cbd5e1',
|
||||
stroke: '#475569',
|
||||
strokeWidth: 0.03,
|
||||
opacity: 0.85,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { getMaterialPresetByRef, type SlabNode } from '@pascal-app/core'
|
||||
import {
|
||||
applyMaterialPresetToMaterials,
|
||||
createMaterial,
|
||||
DEFAULT_SLAB_MATERIAL,
|
||||
generateSlabGeometry,
|
||||
} from '@pascal-app/viewer'
|
||||
import { DoubleSide, Group, Mesh, MeshStandardMaterial } from 'three'
|
||||
|
||||
/**
|
||||
* Stage B builder for slab. Reuses `generateSlabGeometry` (pure
|
||||
* triangulation + hole CSG from viewer) and the same material cache
|
||||
* pattern the legacy slab renderer used.
|
||||
*
|
||||
* Materials are cached by `{material, materialPreset}` signature so
|
||||
* slabs sharing settings share the GPU resource. Cached entry mutation
|
||||
* (preset apply) is preserved — async texture loads still update the
|
||||
* rendered material after re-mount.
|
||||
*/
|
||||
const slabMaterialCache = new Map<string, MeshStandardMaterial>()
|
||||
|
||||
function getSlabMaterial(node: SlabNode): MeshStandardMaterial {
|
||||
const cacheKey = JSON.stringify({
|
||||
material: node.material ?? null,
|
||||
materialPreset: node.materialPreset ?? null,
|
||||
})
|
||||
const cached = slabMaterialCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
const preset = getMaterialPresetByRef(node.materialPreset)
|
||||
const material = preset
|
||||
? new MeshStandardMaterial()
|
||||
: node.material
|
||||
? createMaterial(node.material).clone()
|
||||
: DEFAULT_SLAB_MATERIAL.clone()
|
||||
|
||||
if (preset) {
|
||||
applyMaterialPresetToMaterials(material, preset)
|
||||
}
|
||||
|
||||
material.transparent = false
|
||||
material.opacity = 1
|
||||
material.alphaMap = null
|
||||
material.side = DoubleSide
|
||||
material.depthWrite = true
|
||||
material.needsUpdate = true
|
||||
|
||||
slabMaterialCache.set(cacheKey, material)
|
||||
return material
|
||||
}
|
||||
|
||||
export function buildSlabGeometry(node: SlabNode): Group {
|
||||
const group = new Group()
|
||||
const geometry = generateSlabGeometry(node)
|
||||
const material = getSlabMaterial(node)
|
||||
const mesh = new Mesh(geometry, material)
|
||||
mesh.castShadow = true
|
||||
mesh.receiveShadow = true
|
||||
group.add(mesh)
|
||||
return group
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { getMaterialPresetByRef, type SlabNode, useRegistry, useScene } from '@pascal-app/core'
|
||||
import {
|
||||
applyMaterialPresetToMaterials,
|
||||
createMaterial,
|
||||
DEFAULT_SLAB_MATERIAL,
|
||||
useNodeEvents,
|
||||
} from '@pascal-app/viewer'
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
|
||||
import type { Mesh } from 'three'
|
||||
import * as THREE from 'three'
|
||||
|
||||
/**
|
||||
* Thin slab renderer. Mounts a placeholder mesh, registers it with
|
||||
* `sceneRegistry`, and marks the node dirty so `SlabSystem` fills the
|
||||
* geometry next frame.
|
||||
*
|
||||
* Behaviorally identical to the legacy `SlabRenderer` in
|
||||
* `@pascal-app/viewer/components/renderers/slab/slab-renderer.tsx` —
|
||||
* same placeholder geometry, same material cache, same render output.
|
||||
*
|
||||
* Material logic is preserved from legacy: slab can carry either a raw
|
||||
* `material` or a `materialPreset` (preset takes precedence; preset
|
||||
* apply mutates the cached material instance so async texture loads
|
||||
* still hit the rendered mesh on re-mount).
|
||||
*
|
||||
* No `def.geometry` yet — slab polygon geometry depends on holes +
|
||||
* triangulation that lives inside `SlabSystem`'s useFrame body. Future
|
||||
* milestone can extract a pure builder if useful, but the system is
|
||||
* already efficient (rebuilds only dirty nodes); no urgency.
|
||||
*/
|
||||
const slabMaterialCache = new Map<string, THREE.MeshStandardMaterial>()
|
||||
|
||||
function createEmptyGeometry() {
|
||||
const geometry = new THREE.BufferGeometry()
|
||||
geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
|
||||
return geometry
|
||||
}
|
||||
|
||||
function getSlabMaterial(
|
||||
cacheKey: string,
|
||||
params: { material?: SlabNode['material']; materialPreset?: string },
|
||||
) {
|
||||
const cached = slabMaterialCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
const preset = getMaterialPresetByRef(params.materialPreset)
|
||||
const slabMaterial = preset
|
||||
? new THREE.MeshStandardMaterial()
|
||||
: params.material
|
||||
? createMaterial(params.material).clone()
|
||||
: DEFAULT_SLAB_MATERIAL.clone()
|
||||
|
||||
if (preset) {
|
||||
applyMaterialPresetToMaterials(slabMaterial, preset)
|
||||
}
|
||||
|
||||
slabMaterial.transparent = false
|
||||
slabMaterial.opacity = 1
|
||||
slabMaterial.alphaMap = null
|
||||
slabMaterial.side = THREE.DoubleSide
|
||||
slabMaterial.depthWrite = true
|
||||
slabMaterial.needsUpdate = true
|
||||
|
||||
slabMaterialCache.set(cacheKey, slabMaterial)
|
||||
return slabMaterial
|
||||
}
|
||||
|
||||
const SlabRenderer = ({ node }: { node: SlabNode }) => {
|
||||
const ref = useRef<Mesh>(null!)
|
||||
const placeholderGeometry = useMemo(createEmptyGeometry, [])
|
||||
const handlers = useNodeEvents(node, 'slab')
|
||||
|
||||
useRegistry(node.id, 'slab', ref)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
useScene.getState().markDirty(node.id)
|
||||
}, [node.id])
|
||||
|
||||
useEffect(() => () => placeholderGeometry.dispose(), [placeholderGeometry])
|
||||
|
||||
const material = useMemo(() => {
|
||||
const resolvedMaterial = node.material
|
||||
const resolvedMaterialPreset = node.materialPreset
|
||||
const cacheKey = JSON.stringify({
|
||||
material: resolvedMaterial ?? null,
|
||||
materialPreset: resolvedMaterialPreset ?? null,
|
||||
})
|
||||
|
||||
return getSlabMaterial(cacheKey, {
|
||||
material: resolvedMaterial,
|
||||
materialPreset: resolvedMaterialPreset,
|
||||
})
|
||||
}, [
|
||||
node.material,
|
||||
node.material?.preset,
|
||||
node.material?.properties,
|
||||
node.material?.texture,
|
||||
node.materialPreset,
|
||||
])
|
||||
|
||||
return (
|
||||
<mesh
|
||||
castShadow
|
||||
geometry={placeholderGeometry}
|
||||
material={material}
|
||||
receiveShadow
|
||||
ref={ref}
|
||||
visible={node.visible}
|
||||
{...handlers}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default SlabRenderer
|
||||
@@ -1,20 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { SlabSystem } from '@pascal-app/viewer'
|
||||
|
||||
/**
|
||||
* Registry-driven slab system bundle. Re-exports the legacy `SlabSystem`
|
||||
* (still in viewer) so it mounts via `RegisteredSystems` when slab is
|
||||
* registry-driven. `<LegacySystem kind="slab">` in viewer/components/
|
||||
* viewer/index.tsx short-circuits whenever `nodeRegistry.has('slab')`
|
||||
* is true — same shape wall and fence use.
|
||||
*
|
||||
* Future Phase 5+: extract polygon triangulation + hole CSG into a pure
|
||||
* `buildSlabGeometry(node)` and migrate to `def.geometry`. The legacy
|
||||
* system body has it well-isolated; should be a clean extraction.
|
||||
*/
|
||||
const SlabSystems = () => {
|
||||
return <SlabSystem />
|
||||
}
|
||||
|
||||
export default SlabSystems
|
||||
Reference in New Issue
Block a user