From bbc351d3a4b6f0f1f0c7edbd1733049cfa77c9fc Mon Sep 17 00:00:00 2001 From: sudhir Date: Mon, 20 Apr 2026 12:57:06 +0530 Subject: [PATCH] Add roof material targets and UV mapping --- packages/core/src/material-library.ts | 6 +- packages/core/src/schema/index.ts | 3 +- packages/core/src/schema/nodes/roof.ts | 79 ++++++++++++++++++- packages/core/src/store/use-scene.ts | 61 ++++++++++++++ .../core/src/systems/roof/roof-system.tsx | 39 ++++++++- .../components/editor/selection-manager.tsx | 61 ++++++++++++++ .../src/components/ui/panels/roof-panel.tsx | 72 ++++++++++++++--- packages/editor/src/store/use-editor.tsx | 10 +++ .../roof-segment/roof-segment-renderer.tsx | 44 +++++++---- .../renderers/roof/roof-renderer.tsx | 54 ++++++++++--- .../viewer/src/systems/roof/roof-materials.ts | 46 +++++++++++ 11 files changed, 428 insertions(+), 47 deletions(-) create mode 100644 packages/viewer/src/systems/roof/roof-materials.ts diff --git a/packages/core/src/material-library.ts b/packages/core/src/material-library.ts index 06218708..f5455c96 100644 --- a/packages/core/src/material-library.ts +++ b/packages/core/src/material-library.ts @@ -51,7 +51,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ id: 'wall-wood1', label: 'Wood', description: 'Warm wood finish', - targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS], + targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS, ...ROOF_TARGETS], previewThumbnailUrl: '/material/wood1/wood1_thumbnail.webp', preset: { maps: { @@ -86,7 +86,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ id: 'wall-wood2', label: 'Wood', description: 'Textured wood finish', - targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS], + targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS, ...ROOF_TARGETS], previewThumbnailUrl: '/material/wood2/wood2_thumbnail.webp', preset: { maps: { @@ -122,7 +122,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ id: 'wall-wood3', label: 'Wood', description: 'Knotted timber finish', - targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS], + targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS, ...ROOF_TARGETS], previewThumbnailUrl: '/material/wood3/wood3_thumbnail.webp', preset: { maps: { diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 5599bc46..1383216d 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -43,7 +43,8 @@ export type { } from './nodes/item' export { getScaledDimensions, ItemNode } from './nodes/item' export { LevelNode } from './nodes/level' -export { RoofNode } from './nodes/roof' +export { getEffectiveRoofSurfaceMaterial, RoofNode } from './nodes/roof' +export type { RoofSurfaceMaterialRole, RoofSurfaceMaterialSpec } from './nodes/roof' export { RoofSegmentNode, RoofType } from './nodes/roof-segment' export { ScanNode } from './nodes/scan' // Nodes diff --git a/packages/core/src/schema/nodes/roof.ts b/packages/core/src/schema/nodes/roof.ts index d6529b4e..944cca04 100644 --- a/packages/core/src/schema/nodes/roof.ts +++ b/packages/core/src/schema/nodes/roof.ts @@ -1,14 +1,26 @@ import dedent from 'dedent' import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' -import { MaterialSchema } from '../material' +import { type MaterialSchema, MaterialSchema as MaterialSchemaSchema } from '../material' import { RoofSegmentNode } from './roof-segment' +export type RoofSurfaceMaterialRole = 'top' | 'edge' | 'wall' +export type RoofSurfaceMaterialSpec = { + material?: MaterialSchema + materialPreset?: string +} + export const RoofNode = BaseNode.extend({ id: objectId('roof'), type: nodeType('roof'), - material: MaterialSchema.optional(), + material: MaterialSchemaSchema.optional(), materialPreset: z.string().optional(), + topMaterial: MaterialSchemaSchema.optional(), + topMaterialPreset: z.string().optional(), + edgeMaterial: MaterialSchemaSchema.optional(), + edgeMaterialPreset: z.string().optional(), + wallMaterial: MaterialSchemaSchema.optional(), + wallMaterialPreset: z.string().optional(), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), // Rotation around Y axis in radians rotation: z.number().default(0), @@ -26,3 +38,66 @@ export const RoofNode = BaseNode.extend({ ) export type RoofNode = z.infer + +function getLegacyRoofSurfaceMaterial(node: RoofNode): RoofSurfaceMaterialSpec { + return { + material: node.material, + materialPreset: node.materialPreset, + } +} + +export function getEffectiveRoofSurfaceMaterial( + node: RoofNode, + role: RoofSurfaceMaterialRole, +): RoofSurfaceMaterialSpec { + if (role === 'top') { + if (node.topMaterial !== undefined || typeof node.topMaterialPreset === 'string') { + return { + material: node.topMaterial, + materialPreset: typeof node.topMaterialPreset === 'string' ? node.topMaterialPreset : undefined, + } + } + } + + if (role === 'edge') { + if (node.edgeMaterial !== undefined || typeof node.edgeMaterialPreset === 'string') { + return { + material: node.edgeMaterial, + materialPreset: + typeof node.edgeMaterialPreset === 'string' ? node.edgeMaterialPreset : undefined, + } + } + } + + if (role === 'wall') { + if (node.wallMaterial !== undefined || typeof node.wallMaterialPreset === 'string') { + return { + material: node.wallMaterial, + materialPreset: + typeof node.wallMaterialPreset === 'string' ? node.wallMaterialPreset : undefined, + } + } + } + + if (role === 'edge') { + if (node.wallMaterial !== undefined || typeof node.wallMaterialPreset === 'string') { + return { + material: node.wallMaterial, + materialPreset: + typeof node.wallMaterialPreset === 'string' ? node.wallMaterialPreset : undefined, + } + } + } + + if (role === 'wall') { + if (node.edgeMaterial !== undefined || typeof node.edgeMaterialPreset === 'string') { + return { + material: node.edgeMaterial, + materialPreset: + typeof node.edgeMaterialPreset === 'string' ? node.edgeMaterialPreset : undefined, + } + } + } + + return getLegacyRoofSurfaceMaterial(node) +} diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index 1ce4974c..ca310349 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -224,6 +224,63 @@ function migrateStairSurfaceMaterials(node: Record) { return next } +function migrateRoofSurfaceMaterials(node: Record) { + const hasTop = node.topMaterial !== undefined || typeof node.topMaterialPreset === 'string' + const hasEdge = node.edgeMaterial !== undefined || typeof node.edgeMaterialPreset === 'string' + const hasWall = node.wallMaterial !== undefined || typeof node.wallMaterialPreset === 'string' + const legacyFinish = { + material: node.material, + materialPreset: typeof node.materialPreset === 'string' ? node.materialPreset : undefined, + } + + if (!hasTop && !hasEdge && !hasWall) { + if (legacyFinish.material === undefined && legacyFinish.materialPreset === undefined) { + return node + } + + return { + ...node, + topMaterial: legacyFinish.material, + topMaterialPreset: legacyFinish.materialPreset, + edgeMaterial: legacyFinish.material, + edgeMaterialPreset: legacyFinish.materialPreset, + wallMaterial: legacyFinish.material, + wallMaterialPreset: legacyFinish.materialPreset, + } + } + + const next = { ...node } + + if (!hasTop) { + next.topMaterial = legacyFinish.material + next.topMaterialPreset = legacyFinish.materialPreset + } + + if (!hasEdge) { + if (node.wallMaterial !== undefined || typeof node.wallMaterialPreset === 'string') { + next.edgeMaterial = node.wallMaterial + next.edgeMaterialPreset = + typeof node.wallMaterialPreset === 'string' ? node.wallMaterialPreset : undefined + } else { + next.edgeMaterial = legacyFinish.material + next.edgeMaterialPreset = legacyFinish.materialPreset + } + } + + if (!hasWall) { + if (node.edgeMaterial !== undefined || typeof node.edgeMaterialPreset === 'string') { + next.wallMaterial = node.edgeMaterial + next.wallMaterialPreset = + typeof node.edgeMaterialPreset === 'string' ? node.edgeMaterialPreset : undefined + } else { + next.wallMaterial = legacyFinish.material + next.wallMaterialPreset = legacyFinish.materialPreset + } + } + + return next +} + function migrateNodes(nodes: Record): Record { const patchedNodes = { ...nodes } for (const [id, node] of Object.entries(patchedNodes)) { @@ -281,6 +338,10 @@ function migrateNodes(nodes: Record): Record { if (node.type === 'wall') { patchedNodes[id] = migrateWallSurfaceMaterials(patchedNodes[id]) } + + if (node.type === 'roof') { + patchedNodes[id] = migrateRoofSurfaceMaterials(patchedNodes[id]) + } } return patchedNodes as Record } diff --git a/packages/core/src/systems/roof/roof-system.tsx b/packages/core/src/systems/roof/roof-system.tsx index 0e23364b..5a1f06d1 100644 --- a/packages/core/src/systems/roof/roof-system.tsx +++ b/packages/core/src/systems/roof/roof-system.tsx @@ -11,7 +11,7 @@ import useScene from '../../store/use-scene' const csgEvaluator = new Evaluator() csgEvaluator.useGroups = true ;(csgEvaluator as any).consolidateGroups = false // shared dummyMats across brushes causes consolidation to misalign groupIndices vs groupOrder indices → crash -csgEvaluator.attributes = ['position', 'normal'] +csgEvaluator.attributes = ['position', 'normal', 'uv'] function prepareBrushForCSG(brush: Brush) { brush.geometry.computeBoundsTree = computeBoundsTree @@ -25,6 +25,7 @@ const _position = new THREE.Vector3() const _quaternion = new THREE.Quaternion() const _scale = new THREE.Vector3(1, 1, 1) const _yAxis = new THREE.Vector3(0, 1, 0) +const _uvFaceNormal = new THREE.Vector3() // Pending merged-roof updates carried across frames (for throttling) const pendingRoofUpdates = new Set() @@ -251,6 +252,7 @@ function updateMergedRoofGeometry( g.materialIndex = mapRoofGroupMaterialIndex(g.materialIndex, resultMaterials, matToIndex) } + ensureUv2Attribute(resultGeo) resultGeo.computeVertexNormals() mergedMesh.geometry.dispose() mergedMesh.geometry = resultGeo @@ -641,6 +643,7 @@ export function generateRoofSegmentGeometry(node: RoofSegmentNode): THREE.Buffer wallBrush.geometry.dispose() innerBrush.geometry.dispose() + ensureUv2Attribute(resultGeo) resultGeo.computeVertexNormals() return resultGeo } @@ -936,6 +939,7 @@ function createGeometryFromFaces( ): THREE.BufferGeometry { const positions: number[] = [] const normals: number[] = [] + const uvs: number[] = [] const indices: number[] = [] const groups: { start: number; count: number; materialIndex: number }[] = [] let vertexCount = 0 @@ -974,6 +978,10 @@ function createGeometryFromFaces( normals.push(normal.x, normal.y, normal.z) normals.push(normal.x, normal.y, normal.z) + pushRoofUv(uvs, p0, normal) + pushRoofUv(uvs, fi, normal) + pushRoofUv(uvs, fi1, normal) + indices.push(vertexCount, vertexCount + 1, vertexCount + 2) faceVertexCount += 3 @@ -990,6 +998,7 @@ function createGeometryFromFaces( const geometry = new THREE.BufferGeometry() geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)) geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3)) + geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2)) geometry.setIndex(indices) for (const g of groups) { @@ -999,6 +1008,34 @@ function createGeometryFromFaces( // Merge identical vertices to optimize geometry for CSG and create clean topology const mergedGeo = mergeVertices(geometry, 1e-4) geometry.dispose() + ensureUv2Attribute(mergedGeo) return mergedGeo } + +function pushRoofUv(uvs: number[], point: THREE.Vector3, normal: THREE.Vector3) { + _uvFaceNormal.copy(normal).normalize() + + const absX = Math.abs(_uvFaceNormal.x) + const absY = Math.abs(_uvFaceNormal.y) + const absZ = Math.abs(_uvFaceNormal.z) + + if (absY >= absX && absY >= absZ) { + uvs.push(point.x, point.z) + return + } + + if (absX >= absZ) { + uvs.push(point.z, point.y) + return + } + + uvs.push(point.x, point.y) +} + +function ensureUv2Attribute(geometry: THREE.BufferGeometry) { + const uv = geometry.getAttribute('uv') + if (!uv) return + + geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(Array.from(uv.array), 2)) +} diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index 0680f44b..047481ab 100755 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -5,6 +5,8 @@ import { emitter, type ItemNode, type NodeEvent, + type RoofEvent, + type RoofSegmentEvent, resolveLevelId, sceneRegistry, type StairEvent, @@ -132,6 +134,21 @@ function resolveStairMaterialTarget( return null } +function resolveRoofMaterialTarget( + event: RoofEvent | RoofSegmentEvent, +): 'top' | 'edge' | 'wall' | null { + if (event.materialIndex === 3) return 'top' + if (event.materialIndex === 0) return 'edge' + if (event.materialIndex === 1 || event.materialIndex === 2) return 'wall' + + const normalY = event.normal?.[1] + if (normalY !== undefined && normalY > 0.35) return 'top' + if (normalY !== undefined && Math.abs(normalY) <= 0.35) return 'edge' + if (normalY !== undefined && normalY < -0.35) return 'wall' + + return null +} + const HIGHLIGHT_PROFILES = { delete: { color: new Color('#dc2626'), @@ -542,6 +559,28 @@ export const SelectionManager = () => { useEditor.getState().setSelectedStairMaterialTarget(null) } + if ( + (node.type === 'roof' || node.type === 'roof-segment') && + nodeToSelect.type === 'roof' + ) { + const nextRoofMaterialTarget = resolveRoofMaterialTarget( + event as RoofEvent | RoofSegmentEvent, + ) + if (nextRoofMaterialTarget) { + useEditor.getState().setSelectedRoofMaterialTarget({ + roofId: nodeToSelect.id, + role: nextRoofMaterialTarget, + }) + } else { + const currentRoofMaterialTarget = useEditor.getState().selectedRoofMaterialTarget + if (currentRoofMaterialTarget?.roofId !== nodeToSelect.id) { + useEditor.getState().setSelectedRoofMaterialTarget(null) + } + } + } else if (useEditor.getState().selectedRoofMaterialTarget) { + useEditor.getState().setSelectedRoofMaterialTarget(null) + } + // Reset the handled flag after a short delay to allow grid:click to be ignored setTimeout(() => { clickHandledRef.current = false @@ -576,6 +615,7 @@ export const SelectionManager = () => { if (activeStrategy) activeStrategy.handleDeselect() useEditor.getState().setSelectedWallMaterialTarget(null) useEditor.getState().setSelectedStairMaterialTarget(null) + useEditor.getState().setSelectedRoofMaterialTarget(null) // When deselecting from zone mode, return to structure select if (phase === 'structure' && structureLayer === 'zones') { @@ -813,6 +853,8 @@ const SelectionStateSync = () => { const setSelectedWallMaterialTarget = useEditor((s) => s.setSelectedWallMaterialTarget) const selectedStairMaterialTarget = useEditor((s) => s.selectedStairMaterialTarget) const setSelectedStairMaterialTarget = useEditor((s) => s.setSelectedStairMaterialTarget) + const selectedRoofMaterialTarget = useEditor((s) => s.selectedRoofMaterialTarget) + const setSelectedRoofMaterialTarget = useEditor((s) => s.setSelectedRoofMaterialTarget) const singleSelectedId = useViewer((s) => s.selection.selectedIds.length === 1 ? s.selection.selectedIds[0] : null, ) @@ -883,6 +925,25 @@ const SelectionStateSync = () => { } }, [selectedStairMaterialTarget, setSelectedStairMaterialTarget, singleSelectedId]) + useEffect(() => { + if (!selectedRoofMaterialTarget) return + + if (!singleSelectedId) { + setSelectedRoofMaterialTarget(null) + return + } + + const selectedNode = useScene.getState().nodes[singleSelectedId as AnyNodeId] + if (!(selectedNode?.type === 'roof')) { + setSelectedRoofMaterialTarget(null) + return + } + + if (selectedRoofMaterialTarget.roofId !== selectedNode.id) { + setSelectedRoofMaterialTarget(null) + } + }, [selectedRoofMaterialTarget, setSelectedRoofMaterialTarget, singleSelectedId]) + return null } diff --git a/packages/editor/src/components/ui/panels/roof-panel.tsx b/packages/editor/src/components/ui/panels/roof-panel.tsx index db344ef3..5fbc573f 100755 --- a/packages/editor/src/components/ui/panels/roof-panel.tsx +++ b/packages/editor/src/components/ui/panels/roof-panel.tsx @@ -3,8 +3,10 @@ import { type AnyNode, type AnyNodeId, + getEffectiveRoofSurfaceMaterial, type MaterialSchema, type RoofNode, + type RoofSurfaceMaterialRole, RoofNode as RoofNodeSchema, type RoofSegmentNode, RoofSegmentNode as RoofSegmentNodeSchema, @@ -22,12 +24,39 @@ import { PanelSection } from '../controls/panel-section' import { SliderControl } from '../controls/slider-control' import { PanelWrapper } from './panel-wrapper' +function buildRoofSurfaceMaterialPatch( + node: RoofNode, + targetRole: RoofSurfaceMaterialRole, + material: MaterialSchema | undefined, + materialPreset: string | undefined, +): Partial { + const nextSurfaceMaterial = { material, materialPreset } + const nextTop = + targetRole === 'top' ? nextSurfaceMaterial : getEffectiveRoofSurfaceMaterial(node, 'top') + const nextEdge = + targetRole === 'edge' ? nextSurfaceMaterial : getEffectiveRoofSurfaceMaterial(node, 'edge') + const nextWall = + targetRole === 'wall' ? nextSurfaceMaterial : getEffectiveRoofSurfaceMaterial(node, 'wall') + + return { + topMaterial: nextTop.material, + topMaterialPreset: nextTop.materialPreset, + edgeMaterial: nextEdge.material, + edgeMaterialPreset: nextEdge.materialPreset, + wallMaterial: nextWall.material, + wallMaterialPreset: nextWall.materialPreset, + material: undefined, + materialPreset: undefined, + } +} + export function RoofPanel() { const selectedId = useViewer((s) => s.selection.selectedIds[0]) const setSelection = useViewer((s) => s.setSelection) const updateNode = useScene((s) => s.updateNode) const createNode = useScene((s) => s.createNode) const setMovingNode = useEditor((s) => s.setMovingNode) + const selectedRoofMaterialTarget = useEditor((s) => s.selectedRoofMaterialTarget) const node = useScene((s) => selectedId ? (s.nodes[selectedId as AnyNode['id']] as RoofNode | undefined) : undefined, @@ -50,18 +79,27 @@ export function RoofPanel() { [selectedId, updateNode], ) - const handleMaterialChange = useCallback( + const materialTargetRole = + selectedRoofMaterialTarget && selectedRoofMaterialTarget.roofId === node?.id + ? selectedRoofMaterialTarget.role + : null + const materialPickerValue = + node && materialTargetRole ? getEffectiveRoofSurfaceMaterial(node, materialTargetRole) : {} + + const handleTargetedMaterialChange = useCallback( (material: MaterialSchema) => { - handleUpdate({ material, materialPreset: undefined }) + if (!node || !materialTargetRole) return + handleUpdate(buildRoofSurfaceMaterialPatch(node, materialTargetRole, material, undefined)) }, - [handleUpdate], + [handleUpdate, materialTargetRole, node], ) - const handleMaterialPresetChange = useCallback( + const handleTargetedMaterialPresetChange = useCallback( (materialPreset: string) => { - handleUpdate({ materialPreset, material: undefined }) + if (!node || !materialTargetRole) return + handleUpdate(buildRoofSurfaceMaterialPatch(node, materialTargetRole, undefined, materialPreset)) }, - [handleUpdate], + [handleUpdate, materialTargetRole, node], ) const handleClose = useCallback(() => { @@ -267,13 +305,21 @@ export function RoofPanel() { - + {!materialTargetRole ? ( +
+ Click the roof surface you want to edit. Materials apply to one target at a time. +
+ ) : null} + {materialTargetRole ? ( + + ) : null}
) diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index 91c2158a..6dd55c73 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -8,6 +8,7 @@ import { type FenceNode, type ItemNode, type LevelNode, + type RoofSurfaceMaterialRole, type RoofNode, type RoofSegmentNode, type SlabNode, @@ -93,6 +94,11 @@ export type SelectedStairMaterialTarget = { role: StairMaterialTargetRole } +export type SelectedRoofMaterialTarget = { + roofId: RoofNode['id'] + role: RoofSurfaceMaterialRole +} + type EditorState = { phase: Phase setPhase: (phase: Phase) => void @@ -144,6 +150,8 @@ type EditorState = { setSelectedWallMaterialTarget: (target: SelectedWallMaterialTarget | null) => void selectedStairMaterialTarget: SelectedStairMaterialTarget | null setSelectedStairMaterialTarget: (target: SelectedStairMaterialTarget | null) => void + selectedRoofMaterialTarget: SelectedRoofMaterialTarget | null + setSelectedRoofMaterialTarget: (target: SelectedRoofMaterialTarget | null) => void selectedReferenceId: string | null setSelectedReferenceId: (id: string | null) => void // Space detection for cutaway mode @@ -523,6 +531,8 @@ const useEditor = create()( setSelectedWallMaterialTarget: (target) => set({ selectedWallMaterialTarget: target }), selectedStairMaterialTarget: null, setSelectedStairMaterialTarget: (target) => set({ selectedStairMaterialTarget: target }), + selectedRoofMaterialTarget: null, + setSelectedRoofMaterialTarget: (target) => set({ selectedRoofMaterialTarget: target }), selectedReferenceId: null, setSelectedReferenceId: (id) => set({ selectedReferenceId: id }), spaces: {}, diff --git a/packages/viewer/src/components/renderers/roof-segment/roof-segment-renderer.tsx b/packages/viewer/src/components/renderers/roof-segment/roof-segment-renderer.tsx index b24d5b9c..cd0fbd09 100644 --- a/packages/viewer/src/components/renderers/roof-segment/roof-segment-renderer.tsx +++ b/packages/viewer/src/components/renderers/roof-segment/roof-segment-renderer.tsx @@ -1,9 +1,9 @@ import { type AnyNodeId, type RoofNode, type RoofSegmentNode, useRegistry, useScene } from '@pascal-app/core' -import { useMemo, useRef } from 'react' -import type * as THREE from 'three' +import { useEffect, useMemo, useRef } from 'react' +import * as THREE from 'three' import { useNodeEvents } from '../../../hooks/use-node-events' -import { createMaterial, createMaterialFromPresetRef } from '../../../lib/materials' import useViewer from '../../../store/use-viewer' +import { getRoofMaterialArray } from '../../../systems/roof/roof-materials' import { roofDebugMaterials, roofMaterials } from '../roof/roof-materials' export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => { @@ -16,16 +16,22 @@ export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => { const debugColors = useViewer((s) => s.debugColors) const parentNode = node.parentId ? (nodes[node.parentId as AnyNodeId] as RoofNode | undefined) : undefined + const placeholderGeometry = useMemo(() => { + const geometry = new THREE.BufferGeometry() + geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3)) + geometry.addGroup(0, 0, 0) + geometry.addGroup(0, 0, 1) + geometry.addGroup(0, 0, 2) + geometry.addGroup(0, 0, 3) + return geometry + }, []) const customMaterial = useMemo(() => { - const effectiveMaterialPreset = node.materialPreset ?? parentNode?.materialPreset - const effectiveMaterial = node.material ?? parentNode?.material + if (node.material !== undefined || typeof node.materialPreset === 'string') { + return null + } - const presetMaterial = createMaterialFromPresetRef(effectiveMaterialPreset) - if (presetMaterial) return presetMaterial - const mat = effectiveMaterial - if (!mat) return null - return createMaterial(mat) + return parentNode ? getRoofMaterialArray(parentNode) : null }, [ node.materialPreset, node.material, @@ -37,21 +43,31 @@ export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => { parentNode?.material?.preset, parentNode?.material?.properties, parentNode?.material?.texture, + parentNode?.topMaterial, + parentNode?.topMaterialPreset, + parentNode?.edgeMaterial, + parentNode?.edgeMaterialPreset, + parentNode?.wallMaterial, + parentNode?.wallMaterialPreset, ]) const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials + useEffect(() => { + return () => { + placeholderGeometry.dispose() + } + }, [placeholderGeometry]) + return ( - {/* RoofSystem will replace this geometry in the next frame */} - - + /> ) } diff --git a/packages/viewer/src/components/renderers/roof/roof-renderer.tsx b/packages/viewer/src/components/renderers/roof/roof-renderer.tsx index 776fcbe7..a5fd4ee6 100644 --- a/packages/viewer/src/components/renderers/roof/roof-renderer.tsx +++ b/packages/viewer/src/components/renderers/roof/roof-renderer.tsx @@ -1,9 +1,9 @@ import { type RoofNode, useRegistry } from '@pascal-app/core' -import { useMemo, useRef } from 'react' -import type * as THREE from 'three' +import { useEffect, useMemo, useRef } from 'react' +import * as THREE from 'three' import { useNodeEvents } from '../../../hooks/use-node-events' -import { createMaterial, createMaterialFromPresetRef } from '../../../lib/materials' import useViewer from '../../../store/use-viewer' +import { getRoofMaterialArray } from '../../../systems/roof/roof-materials' import { NodeRenderer } from '../node-renderer' import { roofDebugMaterials, roofMaterials } from './roof-materials' @@ -14,17 +14,41 @@ export const RoofRenderer = ({ node }: { node: RoofNode }) => { const handlers = useNodeEvents(node, 'roof') const debugColors = useViewer((s) => s.debugColors) + const placeholderGeometry = useMemo(() => { + const geometry = new THREE.BufferGeometry() + geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3)) + geometry.addGroup(0, 0, 0) + geometry.addGroup(0, 0, 1) + geometry.addGroup(0, 0, 2) + geometry.addGroup(0, 0, 3) + return geometry + }, []) - const customMaterial = useMemo(() => { - const presetMaterial = createMaterialFromPresetRef(node.materialPreset) - if (presetMaterial) return presetMaterial - const mat = node.material - if (!mat) return null - return createMaterial(mat) - }, [node.materialPreset, node.material, node.material?.preset, node.material?.properties, node.material?.texture]) + const customMaterial = useMemo( + () => getRoofMaterialArray(node), + [ + node.materialPreset, + node.material, + node.material?.preset, + node.material?.properties, + node.material?.texture, + node.topMaterial, + node.topMaterialPreset, + node.edgeMaterial, + node.edgeMaterialPreset, + node.wallMaterial, + node.wallMaterialPreset, + ], + ) const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials + useEffect(() => { + return () => { + placeholderGeometry.dispose() + } + }, [placeholderGeometry]) + return ( { visible={node.visible} {...handlers} > - - - + {(node.children ?? []).map((childId) => ( diff --git a/packages/viewer/src/systems/roof/roof-materials.ts b/packages/viewer/src/systems/roof/roof-materials.ts new file mode 100644 index 00000000..3991050a --- /dev/null +++ b/packages/viewer/src/systems/roof/roof-materials.ts @@ -0,0 +1,46 @@ +import { + getEffectiveRoofSurfaceMaterial, + type RoofNode, + type RoofSegmentNode, +} from '@pascal-app/core' +import * as THREE from 'three' +import { createMaterial, createMaterialFromPresetRef } from '../../lib/materials' + +export type RoofMaterialArray = [THREE.Material, THREE.Material, THREE.Material, THREE.Material] + +function createResolvedMaterial( + material: RoofNode['material'] | RoofSegmentNode['material'] | undefined, + materialPreset: string | undefined, +): THREE.Material | null { + if (materialPreset) { + return createMaterialFromPresetRef(materialPreset) + } + + if (material) { + return createMaterial(material) + } + + return null +} + +export function getRoofMaterialArray(node: RoofNode): RoofMaterialArray | null { + const top = getEffectiveRoofSurfaceMaterial(node, 'top') + const edge = getEffectiveRoofSurfaceMaterial(node, 'edge') + const wall = getEffectiveRoofSurfaceMaterial(node, 'wall') + + const topMaterial = createResolvedMaterial(top.material, top.materialPreset) + const edgeMaterial = createResolvedMaterial(edge.material, edge.materialPreset) + const wallMaterial = createResolvedMaterial(wall.material, wall.materialPreset) + + if (!(topMaterial || edgeMaterial || wallMaterial)) { + return null + } + + return [ + edgeMaterial ?? wallMaterial ?? topMaterial ?? new THREE.MeshStandardMaterial(), + wallMaterial ?? edgeMaterial ?? topMaterial ?? new THREE.MeshStandardMaterial(), + wallMaterial ?? edgeMaterial ?? topMaterial ?? new THREE.MeshStandardMaterial(), + topMaterial ?? wallMaterial ?? edgeMaterial ?? new THREE.MeshStandardMaterial(), + ] +} +