diff --git a/packages/nodes/src/column/definition.ts b/packages/nodes/src/column/definition.ts index 8460ae05..56ae5e5d 100644 --- a/packages/nodes/src/column/definition.ts +++ b/packages/nodes/src/column/definition.ts @@ -7,8 +7,10 @@ import { import { buildColumnFloorplan } from './floorplan' import { columnResizeAffordance, columnRotateAffordance } from './floorplan-affordances' import { columnFloorplanMoveTarget } from './floorplan-move' +import { columnPaint } from './paint' import { columnParametrics } from './parametrics' import { ColumnNode } from './schema' +import { columnSlots } from './slots' // Limits + offsets shared with the in-world arrows. Mirrors the floors // the renderer clamps to (`Math.max(0.2, node.height)` etc.) so a drag @@ -325,6 +327,8 @@ export const columnDefinition: NodeDefinition = { selectable: { hitVolume: 'bbox' }, duplicable: true, deletable: true, + slots: (node) => columnSlots(node as ColumnNodeType), + paint: columnPaint, // Slab elevation lift via the generic ``. floorPlaced: { footprint: (node) => { diff --git a/packages/nodes/src/column/paint.ts b/packages/nodes/src/column/paint.ts new file mode 100644 index 00000000..212d9f9f --- /dev/null +++ b/packages/nodes/src/column/paint.ts @@ -0,0 +1,18 @@ +import type { AnyNode } from '@pascal-app/core' +import { createSlotPaintCapability, previewSlotByUserData } from '../shared/slot-paint' +import type { ColumnNode } from './schema' + +export const columnPaint = createSlotPaintCapability({ + resolveRole: (args) => { + const slotId = args.hitObject?.userData?.slotId + return typeof slotId === 'string' ? slotId : null + }, + applyPreview: previewSlotByUserData, + legacyEffective: (node: AnyNode) => { + const column = node as ColumnNode + if (column.materialPreset || column.material) { + return { material: column.material, materialPreset: column.materialPreset } + } + return null + }, +}) diff --git a/packages/nodes/src/column/renderer.tsx b/packages/nodes/src/column/renderer.tsx index 05c9e5ab..0e80ddb4 100644 --- a/packages/nodes/src/column/renderer.tsx +++ b/packages/nodes/src/column/renderer.tsx @@ -5,6 +5,7 @@ import { useLiveNodeOverrides, useLiveTransforms, useRegistry, + useScene, } from '@pascal-app/core' import { baseMaterial, @@ -17,21 +18,52 @@ import { createMaterialFromPresetRef, createSurfaceRoleMaterial, type RenderShading, + resolveMaterialRef, + resolveSlotDefaultMaterial, useNodeEvents, useViewer, } from '@pascal-app/viewer' -import { createContext, useContext, useEffect, useMemo, useRef } from 'react' +import { createContext, type ReactNode, useContext, useEffect, useMemo, useRef } from 'react' import { BufferGeometry, Float32BufferAttribute, type Group, type Material } from 'three' +import { + COLUMN_BASE_DEFAULT, + COLUMN_CAPITAL_DEFAULT, + COLUMN_FRAME_DEFAULT, + COLUMN_SHAFT_DEFAULT, + type ColumnSlotId, +} from './slots' -const ColumnMaterialContext = createContext(baseMaterial()) +type ColumnSlotMaterials = Record +type SceneMaterials = ReturnType['materials'] + +const DEFAULT_COLUMN_MATERIAL = baseMaterial() +const DEFAULT_COLUMN_SLOT_MATERIALS = createSingleColumnMaterialMap(DEFAULT_COLUMN_MATERIAL) + +const ColumnMaterialContext = createContext(DEFAULT_COLUMN_SLOT_MATERIALS) +const ColumnSlotContext = createContext('shaft') const ColumnEdgeSoftnessContext = createContext(0.025) function ColumnMaterial() { - const material = useContext(ColumnMaterialContext) + const slotId = useContext(ColumnSlotContext) + const materials = useContext(ColumnMaterialContext) + const material = materials[slotId] ?? materials.shaft return } -function createColumnMaterial({ +function ColumnSlot({ children, slotId }: { children: ReactNode; slotId: ColumnSlotId }) { + return {children} +} + +function createSingleColumnMaterialMap(material: Material): ColumnSlotMaterials { + return { + shaft: material, + base: material, + capital: material, + frame: material, + } +} + +function createLegacyColumnMaterial({ material, materialPreset, shading, @@ -50,6 +82,99 @@ function createColumnMaterial({ return baseMaterial(shading) } +function resolveColumnSlotMaterial({ + colorPreset, + legacyMaterial, + node, + sceneMaterials, + shading, + slotId, + textures, +}: { + colorPreset: ColorPreset + legacyMaterial: Material | null + node: ColumnNode + sceneMaterials: SceneMaterials + shading: RenderShading + slotId: ColumnSlotId + textures: boolean +}): Material { + if (!textures) return createSurfaceRoleMaterial('wall', colorPreset) + + const slotRef = node.slots?.[slotId] + if (slotRef) { + const resolved = resolveMaterialRef(slotRef, sceneMaterials, shading) + if (resolved) return resolved + } + + if (legacyMaterial) return legacyMaterial + + if (slotId === 'frame') return resolveSlotDefaultMaterial(COLUMN_FRAME_DEFAULT, shading) + if (slotId === 'base') return resolveSlotDefaultMaterial(COLUMN_BASE_DEFAULT, shading) + if (slotId === 'capital') return resolveSlotDefaultMaterial(COLUMN_CAPITAL_DEFAULT, shading) + return resolveSlotDefaultMaterial(COLUMN_SHAFT_DEFAULT, shading) +} + +function createColumnSlotMaterials({ + colorPreset, + material, + materialPreset, + node, + sceneMaterials, + shading, + textures, +}: Pick & { + colorPreset: ColorPreset + node: ColumnNode + sceneMaterials: SceneMaterials + shading: RenderShading + textures: boolean +}): ColumnSlotMaterials { + const legacyMaterial = + materialPreset || material + ? createLegacyColumnMaterial({ colorPreset, material, materialPreset, shading, textures }) + : null + + return { + shaft: resolveColumnSlotMaterial({ + colorPreset, + legacyMaterial, + node, + sceneMaterials, + shading, + slotId: 'shaft', + textures, + }), + base: resolveColumnSlotMaterial({ + colorPreset, + legacyMaterial, + node, + sceneMaterials, + shading, + slotId: 'base', + textures, + }), + capital: resolveColumnSlotMaterial({ + colorPreset, + legacyMaterial, + node, + sceneMaterials, + shading, + slotId: 'capital', + textures, + }), + frame: resolveColumnSlotMaterial({ + colorPreset, + legacyMaterial, + node, + sceneMaterials, + shading, + slotId: 'frame', + textures, + }), + } +} + function getSegments(node: ColumnNode) { if (node.crossSection === 'octagonal') return 8 if (node.crossSection === 'sixteen-sided') return 16 @@ -126,6 +251,7 @@ function MappedBox({ width: number }) { const edgeSoftness = useContext(ColumnEdgeSoftnessContext) + const slotId = useContext(ColumnSlotContext) const minDimension = Math.max(0, Math.min(width, height, depth)) const bevelRadius = softenEdges ? Math.min(Math.max(0, edgeSoftness), minDimension * 0.35) : 0 const geometry = useMemo(() => { @@ -136,7 +262,14 @@ function MappedBox({ if (!geometry) return null return ( - + @@ -154,6 +287,7 @@ function FlatEndedBeam({ start: VectorTuple width: number }) { + const slotId = useContext(ColumnSlotContext) const dx = end[0] - start[0] const dy = end[1] - start[1] const dz = end[2] - start[2] @@ -244,7 +378,7 @@ function FlatEndedBeam({ if (!geometry) return null return ( - + @@ -763,6 +897,7 @@ function MappedCylinder({ rotation?: VectorTuple segments?: number }) { + const slotId = useContext(ColumnSlotContext) const geometry = useMemo(() => { if (height <= 0 || radius <= 0 || radiusBottom < 0 || radiusTop < 0) return null return createColumnCylinderGeometry({ @@ -778,7 +913,14 @@ function MappedCylinder({ if (!geometry) return null return ( - + @@ -800,6 +942,7 @@ function MappedCone({ rotation?: VectorTuple segments?: number }) { + const slotId = useContext(ColumnSlotContext) const geometry = useMemo(() => { if (height <= 0 || radiusX <= 0 || radiusZ <= 0) return null return createColumnCylinderGeometry({ @@ -815,7 +958,14 @@ function MappedCone({ if (!geometry) return null return ( - + @@ -833,6 +983,7 @@ function MappedSphere({ segments?: number verticalSegments?: number }) { + const slotId = useContext(ColumnSlotContext) const geometry = useMemo(() => { if (radius <= 0) return null return createColumnSphereGeometry(radius, segments, verticalSegments) @@ -841,7 +992,7 @@ function MappedSphere({ if (!geometry) return null return ( - + @@ -867,6 +1018,7 @@ function MappedTorus({ scaleZ?: number tubeRadius: number }) { + const slotId = useContext(ColumnSlotContext) const geometry = useMemo(() => { if (ringRadius <= 0 || tubeRadius <= 0) return null return createColumnTorusGeometry({ @@ -882,7 +1034,14 @@ function MappedTorus({ if (!geometry) return null return ( - + @@ -2094,55 +2253,75 @@ function ColumnBody({ node }: { node: ColumnNode }) { return { baseHeight, capitalHeight, shaftY: baseHeight, shaftHeight } }, [node.baseHeight, node.baseStyle, node.capitalHeight, node.capitalStyle, node.height]) - return node.supportStyle === 'a-frame' ? ( - - ) : node.supportStyle === 'y-frame' ? ( - - ) : node.supportStyle === 'v-frame' ? ( - - ) : node.supportStyle === 'x-brace' ? ( - - ) : node.supportStyle === 'k-brace' ? ( - - ) : node.supportStyle === 'single-strut' ? ( - - ) : node.supportStyle === 'tripod' ? ( - - ) : node.supportStyle === 'trestle' ? ( - - ) : node.supportStyle === 'portal-frame' ? ( - - ) : node.supportStyle === 'box-frame' ? ( - - ) : ( + if (node.supportStyle !== 'vertical') { + const support = + node.supportStyle === 'a-frame' ? ( + + ) : node.supportStyle === 'y-frame' ? ( + + ) : node.supportStyle === 'v-frame' ? ( + + ) : node.supportStyle === 'x-brace' ? ( + + ) : node.supportStyle === 'k-brace' ? ( + + ) : node.supportStyle === 'single-strut' ? ( + + ) : node.supportStyle === 'tripod' ? ( + + ) : node.supportStyle === 'trestle' ? ( + + ) : node.supportStyle === 'portal-frame' ? ( + + ) : ( + + ) + return {support} + } + + return ( <> - - - - - - - - - - - + + + + + + + + + + + + + + + + + ) } @@ -2152,7 +2331,7 @@ function ColumnBody({ node }: { node: ColumnNode }) { * cursor preview, mirroring `ShelfPreview`. Builds the same geometry tree * as the real renderer via `` but: * - clones the material and makes it transparent (cloning is required: - * `createColumnMaterial` can hand back a shared/cached instance, and + * `createLegacyColumnMaterial` can hand back a shared/cached instance, and * mutating it would turn every committed column see-through); * - disables raycast on every mesh so the ghost doesn't intercept the * placement cursor ray (which would stall `grid:move`); @@ -2164,8 +2343,8 @@ export const ColumnPreview = ({ node }: { node: ColumnNode }) => { const colorPreset = useViewer((state) => state.colorPreset) const groupRef = useRef(null) - const material = useMemo(() => { - const ghost = createColumnMaterial({ + const materials = useMemo(() => { + const ghost = createLegacyColumnMaterial({ material: node.material, materialPreset: node.materialPreset, shading, @@ -2175,10 +2354,15 @@ export const ColumnPreview = ({ node }: { node: ColumnNode }) => { ghost.transparent = true ghost.opacity = 0.5 ghost.depthWrite = false - return ghost + return createSingleColumnMaterialMap(ghost) }, [shading, textures, colorPreset, node.material, node.materialPreset]) - useEffect(() => () => material.dispose(), [material]) + useEffect( + () => () => { + for (const material of new Set(Object.values(materials))) material.dispose() + }, + [materials], + ) // Strip pointer events off the freshly-built meshes every render — the // geometry tree rebuilds when the ghost's dimensions change, so a one-shot @@ -2190,7 +2374,7 @@ export const ColumnPreview = ({ node }: { node: ColumnNode }) => { }) return ( - + @@ -2216,11 +2400,14 @@ export const ColumnRenderer = ({ node: rawNode }: { node: ColumnNode }) => { const shading = useViewer((state) => state.shading) const textures = useViewer((state) => state.textures) const colorPreset = useViewer((state) => state.colorPreset) - const material = useMemo( + const sceneMaterials = useScene((state) => state.materials) + const materials = useMemo( () => - createColumnMaterial({ + createColumnSlotMaterials({ material: node.material, materialPreset: node.materialPreset, + node, + sceneMaterials, shading, textures, colorPreset, @@ -2234,13 +2421,15 @@ export const ColumnRenderer = ({ node: rawNode }: { node: ColumnNode }) => { node.material?.properties, node.material?.texture, node.materialPreset, + node.slots, + sceneMaterials, ], ) useRegistry(node.id, node.type, ref) return ( - +