Add roof material targets and UV mapping

This commit is contained in:
sudhir
2026-04-20 12:57:06 +05:30
parent 05002a7865
commit bbc351d3a4
11 changed files with 428 additions and 47 deletions
+3 -3
View File
@@ -51,7 +51,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
id: 'wall-wood1', id: 'wall-wood1',
label: 'Wood', label: 'Wood',
description: 'Warm wood finish', 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', previewThumbnailUrl: '/material/wood1/wood1_thumbnail.webp',
preset: { preset: {
maps: { maps: {
@@ -86,7 +86,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
id: 'wall-wood2', id: 'wall-wood2',
label: 'Wood', label: 'Wood',
description: 'Textured wood finish', 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', previewThumbnailUrl: '/material/wood2/wood2_thumbnail.webp',
preset: { preset: {
maps: { maps: {
@@ -122,7 +122,7 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
id: 'wall-wood3', id: 'wall-wood3',
label: 'Wood', label: 'Wood',
description: 'Knotted timber finish', 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', previewThumbnailUrl: '/material/wood3/wood3_thumbnail.webp',
preset: { preset: {
maps: { maps: {
+2 -1
View File
@@ -43,7 +43,8 @@ export type {
} from './nodes/item' } from './nodes/item'
export { getScaledDimensions, ItemNode } from './nodes/item' export { getScaledDimensions, ItemNode } from './nodes/item'
export { LevelNode } from './nodes/level' 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 { RoofSegmentNode, RoofType } from './nodes/roof-segment'
export { ScanNode } from './nodes/scan' export { ScanNode } from './nodes/scan'
// Nodes // Nodes
+77 -2
View File
@@ -1,14 +1,26 @@
import dedent from 'dedent' import dedent from 'dedent'
import { z } from 'zod' import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base' import { BaseNode, nodeType, objectId } from '../base'
import { MaterialSchema } from '../material' import { type MaterialSchema, MaterialSchema as MaterialSchemaSchema } from '../material'
import { RoofSegmentNode } from './roof-segment' import { RoofSegmentNode } from './roof-segment'
export type RoofSurfaceMaterialRole = 'top' | 'edge' | 'wall'
export type RoofSurfaceMaterialSpec = {
material?: MaterialSchema
materialPreset?: string
}
export const RoofNode = BaseNode.extend({ export const RoofNode = BaseNode.extend({
id: objectId('roof'), id: objectId('roof'),
type: nodeType('roof'), type: nodeType('roof'),
material: MaterialSchema.optional(), material: MaterialSchemaSchema.optional(),
materialPreset: z.string().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]), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
// Rotation around Y axis in radians // Rotation around Y axis in radians
rotation: z.number().default(0), rotation: z.number().default(0),
@@ -26,3 +38,66 @@ export const RoofNode = BaseNode.extend({
) )
export type RoofNode = z.infer<typeof RoofNode> export type RoofNode = z.infer<typeof RoofNode>
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)
}
+61
View File
@@ -224,6 +224,63 @@ function migrateStairSurfaceMaterials(node: Record<string, any>) {
return next return next
} }
function migrateRoofSurfaceMaterials(node: Record<string, any>) {
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<string, any>): Record<string, AnyNode> { function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> {
const patchedNodes = { ...nodes } const patchedNodes = { ...nodes }
for (const [id, node] of Object.entries(patchedNodes)) { for (const [id, node] of Object.entries(patchedNodes)) {
@@ -281,6 +338,10 @@ function migrateNodes(nodes: Record<string, any>): Record<string, AnyNode> {
if (node.type === 'wall') { if (node.type === 'wall') {
patchedNodes[id] = migrateWallSurfaceMaterials(patchedNodes[id]) patchedNodes[id] = migrateWallSurfaceMaterials(patchedNodes[id])
} }
if (node.type === 'roof') {
patchedNodes[id] = migrateRoofSurfaceMaterials(patchedNodes[id])
}
} }
return patchedNodes as Record<string, AnyNode> return patchedNodes as Record<string, AnyNode>
} }
+38 -1
View File
@@ -11,7 +11,7 @@ import useScene from '../../store/use-scene'
const csgEvaluator = new Evaluator() const csgEvaluator = new Evaluator()
csgEvaluator.useGroups = true csgEvaluator.useGroups = true
;(csgEvaluator as any).consolidateGroups = false // shared dummyMats across brushes causes consolidation to misalign groupIndices vs groupOrder indices → crash ;(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) { function prepareBrushForCSG(brush: Brush) {
brush.geometry.computeBoundsTree = computeBoundsTree brush.geometry.computeBoundsTree = computeBoundsTree
@@ -25,6 +25,7 @@ const _position = new THREE.Vector3()
const _quaternion = new THREE.Quaternion() const _quaternion = new THREE.Quaternion()
const _scale = new THREE.Vector3(1, 1, 1) const _scale = new THREE.Vector3(1, 1, 1)
const _yAxis = new THREE.Vector3(0, 1, 0) const _yAxis = new THREE.Vector3(0, 1, 0)
const _uvFaceNormal = new THREE.Vector3()
// Pending merged-roof updates carried across frames (for throttling) // Pending merged-roof updates carried across frames (for throttling)
const pendingRoofUpdates = new Set<AnyNodeId>() const pendingRoofUpdates = new Set<AnyNodeId>()
@@ -251,6 +252,7 @@ function updateMergedRoofGeometry(
g.materialIndex = mapRoofGroupMaterialIndex(g.materialIndex, resultMaterials, matToIndex) g.materialIndex = mapRoofGroupMaterialIndex(g.materialIndex, resultMaterials, matToIndex)
} }
ensureUv2Attribute(resultGeo)
resultGeo.computeVertexNormals() resultGeo.computeVertexNormals()
mergedMesh.geometry.dispose() mergedMesh.geometry.dispose()
mergedMesh.geometry = resultGeo mergedMesh.geometry = resultGeo
@@ -641,6 +643,7 @@ export function generateRoofSegmentGeometry(node: RoofSegmentNode): THREE.Buffer
wallBrush.geometry.dispose() wallBrush.geometry.dispose()
innerBrush.geometry.dispose() innerBrush.geometry.dispose()
ensureUv2Attribute(resultGeo)
resultGeo.computeVertexNormals() resultGeo.computeVertexNormals()
return resultGeo return resultGeo
} }
@@ -936,6 +939,7 @@ function createGeometryFromFaces(
): THREE.BufferGeometry { ): THREE.BufferGeometry {
const positions: number[] = [] const positions: number[] = []
const normals: number[] = [] const normals: number[] = []
const uvs: number[] = []
const indices: number[] = [] const indices: number[] = []
const groups: { start: number; count: number; materialIndex: number }[] = [] const groups: { start: number; count: number; materialIndex: number }[] = []
let vertexCount = 0 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)
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) indices.push(vertexCount, vertexCount + 1, vertexCount + 2)
faceVertexCount += 3 faceVertexCount += 3
@@ -990,6 +998,7 @@ function createGeometryFromFaces(
const geometry = new THREE.BufferGeometry() const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)) geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3)) geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3))
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2))
geometry.setIndex(indices) geometry.setIndex(indices)
for (const g of groups) { for (const g of groups) {
@@ -999,6 +1008,34 @@ function createGeometryFromFaces(
// Merge identical vertices to optimize geometry for CSG and create clean topology // Merge identical vertices to optimize geometry for CSG and create clean topology
const mergedGeo = mergeVertices(geometry, 1e-4) const mergedGeo = mergeVertices(geometry, 1e-4)
geometry.dispose() geometry.dispose()
ensureUv2Attribute(mergedGeo)
return 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))
}
@@ -5,6 +5,8 @@ import {
emitter, emitter,
type ItemNode, type ItemNode,
type NodeEvent, type NodeEvent,
type RoofEvent,
type RoofSegmentEvent,
resolveLevelId, resolveLevelId,
sceneRegistry, sceneRegistry,
type StairEvent, type StairEvent,
@@ -132,6 +134,21 @@ function resolveStairMaterialTarget(
return null 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 = { const HIGHLIGHT_PROFILES = {
delete: { delete: {
color: new Color('#dc2626'), color: new Color('#dc2626'),
@@ -542,6 +559,28 @@ export const SelectionManager = () => {
useEditor.getState().setSelectedStairMaterialTarget(null) 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 // Reset the handled flag after a short delay to allow grid:click to be ignored
setTimeout(() => { setTimeout(() => {
clickHandledRef.current = false clickHandledRef.current = false
@@ -576,6 +615,7 @@ export const SelectionManager = () => {
if (activeStrategy) activeStrategy.handleDeselect() if (activeStrategy) activeStrategy.handleDeselect()
useEditor.getState().setSelectedWallMaterialTarget(null) useEditor.getState().setSelectedWallMaterialTarget(null)
useEditor.getState().setSelectedStairMaterialTarget(null) useEditor.getState().setSelectedStairMaterialTarget(null)
useEditor.getState().setSelectedRoofMaterialTarget(null)
// When deselecting from zone mode, return to structure select // When deselecting from zone mode, return to structure select
if (phase === 'structure' && structureLayer === 'zones') { if (phase === 'structure' && structureLayer === 'zones') {
@@ -813,6 +853,8 @@ const SelectionStateSync = () => {
const setSelectedWallMaterialTarget = useEditor((s) => s.setSelectedWallMaterialTarget) const setSelectedWallMaterialTarget = useEditor((s) => s.setSelectedWallMaterialTarget)
const selectedStairMaterialTarget = useEditor((s) => s.selectedStairMaterialTarget) const selectedStairMaterialTarget = useEditor((s) => s.selectedStairMaterialTarget)
const setSelectedStairMaterialTarget = useEditor((s) => s.setSelectedStairMaterialTarget) const setSelectedStairMaterialTarget = useEditor((s) => s.setSelectedStairMaterialTarget)
const selectedRoofMaterialTarget = useEditor((s) => s.selectedRoofMaterialTarget)
const setSelectedRoofMaterialTarget = useEditor((s) => s.setSelectedRoofMaterialTarget)
const singleSelectedId = useViewer((s) => const singleSelectedId = useViewer((s) =>
s.selection.selectedIds.length === 1 ? s.selection.selectedIds[0] : null, s.selection.selectedIds.length === 1 ? s.selection.selectedIds[0] : null,
) )
@@ -883,6 +925,25 @@ const SelectionStateSync = () => {
} }
}, [selectedStairMaterialTarget, setSelectedStairMaterialTarget, singleSelectedId]) }, [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 return null
} }
@@ -3,8 +3,10 @@
import { import {
type AnyNode, type AnyNode,
type AnyNodeId, type AnyNodeId,
getEffectiveRoofSurfaceMaterial,
type MaterialSchema, type MaterialSchema,
type RoofNode, type RoofNode,
type RoofSurfaceMaterialRole,
RoofNode as RoofNodeSchema, RoofNode as RoofNodeSchema,
type RoofSegmentNode, type RoofSegmentNode,
RoofSegmentNode as RoofSegmentNodeSchema, RoofSegmentNode as RoofSegmentNodeSchema,
@@ -22,12 +24,39 @@ import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control' import { SliderControl } from '../controls/slider-control'
import { PanelWrapper } from './panel-wrapper' import { PanelWrapper } from './panel-wrapper'
function buildRoofSurfaceMaterialPatch(
node: RoofNode,
targetRole: RoofSurfaceMaterialRole,
material: MaterialSchema | undefined,
materialPreset: string | undefined,
): Partial<RoofNode> {
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() { export function RoofPanel() {
const selectedId = useViewer((s) => s.selection.selectedIds[0]) const selectedId = useViewer((s) => s.selection.selectedIds[0])
const setSelection = useViewer((s) => s.setSelection) const setSelection = useViewer((s) => s.setSelection)
const updateNode = useScene((s) => s.updateNode) const updateNode = useScene((s) => s.updateNode)
const createNode = useScene((s) => s.createNode) const createNode = useScene((s) => s.createNode)
const setMovingNode = useEditor((s) => s.setMovingNode) const setMovingNode = useEditor((s) => s.setMovingNode)
const selectedRoofMaterialTarget = useEditor((s) => s.selectedRoofMaterialTarget)
const node = useScene((s) => const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as RoofNode | undefined) : undefined, selectedId ? (s.nodes[selectedId as AnyNode['id']] as RoofNode | undefined) : undefined,
@@ -50,18 +79,27 @@ export function RoofPanel() {
[selectedId, updateNode], [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) => { (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) => { (materialPreset: string) => {
handleUpdate({ materialPreset, material: undefined }) if (!node || !materialTargetRole) return
handleUpdate(buildRoofSurfaceMaterialPatch(node, materialTargetRole, undefined, materialPreset))
}, },
[handleUpdate], [handleUpdate, materialTargetRole, node],
) )
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
@@ -267,13 +305,21 @@ export function RoofPanel() {
</ActionGroup> </ActionGroup>
</PanelSection> </PanelSection>
<PanelSection title="Material"> <PanelSection title="Material">
{!materialTargetRole ? (
<div className="mb-3 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-[11px] text-muted-foreground">
Click the roof surface you want to edit. Materials apply to one target at a time.
</div>
) : null}
{materialTargetRole ? (
<MaterialPicker <MaterialPicker
hideSideControl
nodeType="roof" nodeType="roof"
onChange={handleMaterialChange} onChange={handleTargetedMaterialChange}
onSelectMaterialPreset={handleMaterialPresetChange} onSelectMaterialPreset={handleTargetedMaterialPresetChange}
selectedMaterialPreset={node.materialPreset} selectedMaterialPreset={materialPickerValue.materialPreset}
value={node.material} value={materialPickerValue.material}
/> />
) : null}
</PanelSection> </PanelSection>
</PanelWrapper> </PanelWrapper>
) )
+10
View File
@@ -8,6 +8,7 @@ import {
type FenceNode, type FenceNode,
type ItemNode, type ItemNode,
type LevelNode, type LevelNode,
type RoofSurfaceMaterialRole,
type RoofNode, type RoofNode,
type RoofSegmentNode, type RoofSegmentNode,
type SlabNode, type SlabNode,
@@ -93,6 +94,11 @@ export type SelectedStairMaterialTarget = {
role: StairMaterialTargetRole role: StairMaterialTargetRole
} }
export type SelectedRoofMaterialTarget = {
roofId: RoofNode['id']
role: RoofSurfaceMaterialRole
}
type EditorState = { type EditorState = {
phase: Phase phase: Phase
setPhase: (phase: Phase) => void setPhase: (phase: Phase) => void
@@ -144,6 +150,8 @@ type EditorState = {
setSelectedWallMaterialTarget: (target: SelectedWallMaterialTarget | null) => void setSelectedWallMaterialTarget: (target: SelectedWallMaterialTarget | null) => void
selectedStairMaterialTarget: SelectedStairMaterialTarget | null selectedStairMaterialTarget: SelectedStairMaterialTarget | null
setSelectedStairMaterialTarget: (target: SelectedStairMaterialTarget | null) => void setSelectedStairMaterialTarget: (target: SelectedStairMaterialTarget | null) => void
selectedRoofMaterialTarget: SelectedRoofMaterialTarget | null
setSelectedRoofMaterialTarget: (target: SelectedRoofMaterialTarget | null) => void
selectedReferenceId: string | null selectedReferenceId: string | null
setSelectedReferenceId: (id: string | null) => void setSelectedReferenceId: (id: string | null) => void
// Space detection for cutaway mode // Space detection for cutaway mode
@@ -523,6 +531,8 @@ const useEditor = create<EditorState>()(
setSelectedWallMaterialTarget: (target) => set({ selectedWallMaterialTarget: target }), setSelectedWallMaterialTarget: (target) => set({ selectedWallMaterialTarget: target }),
selectedStairMaterialTarget: null, selectedStairMaterialTarget: null,
setSelectedStairMaterialTarget: (target) => set({ selectedStairMaterialTarget: target }), setSelectedStairMaterialTarget: (target) => set({ selectedStairMaterialTarget: target }),
selectedRoofMaterialTarget: null,
setSelectedRoofMaterialTarget: (target) => set({ selectedRoofMaterialTarget: target }),
selectedReferenceId: null, selectedReferenceId: null,
setSelectedReferenceId: (id) => set({ selectedReferenceId: id }), setSelectedReferenceId: (id) => set({ selectedReferenceId: id }),
spaces: {}, spaces: {},
@@ -1,9 +1,9 @@
import { type AnyNodeId, type RoofNode, type RoofSegmentNode, useRegistry, useScene } from '@pascal-app/core' import { type AnyNodeId, type RoofNode, type RoofSegmentNode, useRegistry, useScene } from '@pascal-app/core'
import { useMemo, useRef } from 'react' import { useEffect, useMemo, useRef } from 'react'
import type * as THREE from 'three' import * as THREE from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events' import { useNodeEvents } from '../../../hooks/use-node-events'
import { createMaterial, createMaterialFromPresetRef } from '../../../lib/materials'
import useViewer from '../../../store/use-viewer' import useViewer from '../../../store/use-viewer'
import { getRoofMaterialArray } from '../../../systems/roof/roof-materials'
import { roofDebugMaterials, roofMaterials } from '../roof/roof-materials' import { roofDebugMaterials, roofMaterials } from '../roof/roof-materials'
export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => { export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => {
@@ -16,16 +16,22 @@ export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => {
const debugColors = useViewer((s) => s.debugColors) const debugColors = useViewer((s) => s.debugColors)
const parentNode = const parentNode =
node.parentId ? (nodes[node.parentId as AnyNodeId] as RoofNode | undefined) : undefined 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 customMaterial = useMemo(() => {
const effectiveMaterialPreset = node.materialPreset ?? parentNode?.materialPreset if (node.material !== undefined || typeof node.materialPreset === 'string') {
const effectiveMaterial = node.material ?? parentNode?.material return null
}
const presetMaterial = createMaterialFromPresetRef(effectiveMaterialPreset) return parentNode ? getRoofMaterialArray(parentNode) : null
if (presetMaterial) return presetMaterial
const mat = effectiveMaterial
if (!mat) return null
return createMaterial(mat)
}, [ }, [
node.materialPreset, node.materialPreset,
node.material, node.material,
@@ -37,21 +43,31 @@ export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => {
parentNode?.material?.preset, parentNode?.material?.preset,
parentNode?.material?.properties, parentNode?.material?.properties,
parentNode?.material?.texture, parentNode?.material?.texture,
parentNode?.topMaterial,
parentNode?.topMaterialPreset,
parentNode?.edgeMaterial,
parentNode?.edgeMaterialPreset,
parentNode?.wallMaterial,
parentNode?.wallMaterialPreset,
]) ])
const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials
useEffect(() => {
return () => {
placeholderGeometry.dispose()
}
}, [placeholderGeometry])
return ( return (
<mesh <mesh
geometry={placeholderGeometry}
material={material} material={material}
position={node.position} position={node.position}
ref={ref} ref={ref}
rotation-y={node.rotation} rotation-y={node.rotation}
visible={node.visible} visible={node.visible}
{...handlers} {...handlers}
> />
{/* RoofSystem will replace this geometry in the next frame */}
<boxGeometry args={[0, 0, 0]} />
</mesh>
) )
} }
@@ -1,9 +1,9 @@
import { type RoofNode, useRegistry } from '@pascal-app/core' import { type RoofNode, useRegistry } from '@pascal-app/core'
import { useMemo, useRef } from 'react' import { useEffect, useMemo, useRef } from 'react'
import type * as THREE from 'three' import * as THREE from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events' import { useNodeEvents } from '../../../hooks/use-node-events'
import { createMaterial, createMaterialFromPresetRef } from '../../../lib/materials'
import useViewer from '../../../store/use-viewer' import useViewer from '../../../store/use-viewer'
import { getRoofMaterialArray } from '../../../systems/roof/roof-materials'
import { NodeRenderer } from '../node-renderer' import { NodeRenderer } from '../node-renderer'
import { roofDebugMaterials, roofMaterials } from './roof-materials' import { roofDebugMaterials, roofMaterials } from './roof-materials'
@@ -14,17 +14,41 @@ export const RoofRenderer = ({ node }: { node: RoofNode }) => {
const handlers = useNodeEvents(node, 'roof') const handlers = useNodeEvents(node, 'roof')
const debugColors = useViewer((s) => s.debugColors) 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 customMaterial = useMemo(
const presetMaterial = createMaterialFromPresetRef(node.materialPreset) () => getRoofMaterialArray(node),
if (presetMaterial) return presetMaterial [
const mat = node.material node.materialPreset,
if (!mat) return null node.material,
return createMaterial(mat) node.material?.preset,
}, [node.materialPreset, node.material, node.material?.preset, node.material?.properties, node.material?.texture]) node.material?.properties,
node.material?.texture,
node.topMaterial,
node.topMaterialPreset,
node.edgeMaterial,
node.edgeMaterialPreset,
node.wallMaterial,
node.wallMaterialPreset,
],
)
const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials
useEffect(() => {
return () => {
placeholderGeometry.dispose()
}
}, [placeholderGeometry])
return ( return (
<group <group
position={node.position} position={node.position}
@@ -33,9 +57,13 @@ export const RoofRenderer = ({ node }: { node: RoofNode }) => {
visible={node.visible} visible={node.visible}
{...handlers} {...handlers}
> >
<mesh castShadow material={material} name="merged-roof" receiveShadow> <mesh
<boxGeometry args={[0, 0, 0]} /> castShadow
</mesh> geometry={placeholderGeometry}
material={material}
name="merged-roof"
receiveShadow
/>
<group name="segments-wrapper" visible={false}> <group name="segments-wrapper" visible={false}>
{(node.children ?? []).map((childId) => ( {(node.children ?? []).map((childId) => (
<NodeRenderer key={childId} nodeId={childId} /> <NodeRenderer key={childId} nodeId={childId} />
@@ -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(),
]
}