Add roof material targets and UV mapping
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<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)
|
||||
}
|
||||
|
||||
@@ -224,6 +224,63 @@ function migrateStairSurfaceMaterials(node: Record<string, any>) {
|
||||
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> {
|
||||
const patchedNodes = { ...nodes }
|
||||
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') {
|
||||
patchedNodes[id] = migrateWallSurfaceMaterials(patchedNodes[id])
|
||||
}
|
||||
|
||||
if (node.type === 'roof') {
|
||||
patchedNodes[id] = migrateRoofSurfaceMaterials(patchedNodes[id])
|
||||
}
|
||||
}
|
||||
return patchedNodes as Record<string, AnyNode>
|
||||
}
|
||||
|
||||
@@ -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<AnyNodeId>()
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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<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() {
|
||||
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() {
|
||||
</ActionGroup>
|
||||
</PanelSection>
|
||||
<PanelSection title="Material">
|
||||
<MaterialPicker
|
||||
nodeType="roof"
|
||||
onChange={handleMaterialChange}
|
||||
onSelectMaterialPreset={handleMaterialPresetChange}
|
||||
selectedMaterialPreset={node.materialPreset}
|
||||
value={node.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
|
||||
hideSideControl
|
||||
nodeType="roof"
|
||||
onChange={handleTargetedMaterialChange}
|
||||
onSelectMaterialPreset={handleTargetedMaterialPresetChange}
|
||||
selectedMaterialPreset={materialPickerValue.materialPreset}
|
||||
value={materialPickerValue.material}
|
||||
/>
|
||||
) : null}
|
||||
</PanelSection>
|
||||
</PanelWrapper>
|
||||
)
|
||||
|
||||
@@ -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<EditorState>()(
|
||||
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: {},
|
||||
|
||||
@@ -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 (
|
||||
<mesh
|
||||
geometry={placeholderGeometry}
|
||||
material={material}
|
||||
position={node.position}
|
||||
ref={ref}
|
||||
rotation-y={node.rotation}
|
||||
visible={node.visible}
|
||||
{...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 { 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 (
|
||||
<group
|
||||
position={node.position}
|
||||
@@ -33,9 +57,13 @@ export const RoofRenderer = ({ node }: { node: RoofNode }) => {
|
||||
visible={node.visible}
|
||||
{...handlers}
|
||||
>
|
||||
<mesh castShadow material={material} name="merged-roof" receiveShadow>
|
||||
<boxGeometry args={[0, 0, 0]} />
|
||||
</mesh>
|
||||
<mesh
|
||||
castShadow
|
||||
geometry={placeholderGeometry}
|
||||
material={material}
|
||||
name="merged-roof"
|
||||
receiveShadow
|
||||
/>
|
||||
<group name="segments-wrapper" visible={false}>
|
||||
{(node.children ?? []).map((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(),
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user