Add paint hover previews to viewer
This commit is contained in:
@@ -157,14 +157,16 @@ function migrateStairSurfaceMaterials(node: Record<string, any>) {
|
|||||||
if (node.treadMaterial !== undefined || typeof node.treadMaterialPreset === 'string') {
|
if (node.treadMaterial !== undefined || typeof node.treadMaterialPreset === 'string') {
|
||||||
return {
|
return {
|
||||||
material: node.treadMaterial,
|
material: node.treadMaterial,
|
||||||
materialPreset: typeof node.treadMaterialPreset === 'string' ? node.treadMaterialPreset : undefined,
|
materialPreset:
|
||||||
|
typeof node.treadMaterialPreset === 'string' ? node.treadMaterialPreset : undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node.sideMaterial !== undefined || typeof node.sideMaterialPreset === 'string') {
|
if (node.sideMaterial !== undefined || typeof node.sideMaterialPreset === 'string') {
|
||||||
return {
|
return {
|
||||||
material: node.sideMaterial,
|
material: node.sideMaterial,
|
||||||
materialPreset: typeof node.sideMaterialPreset === 'string' ? node.sideMaterialPreset : undefined,
|
materialPreset:
|
||||||
|
typeof node.sideMaterialPreset === 'string' ? node.sideMaterialPreset : undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -685,10 +685,12 @@ function PaintCursorLayer({
|
|||||||
const activePaintMaterial = useEditor((s) => s.activePaintMaterial)
|
const activePaintMaterial = useEditor((s) => s.activePaintMaterial)
|
||||||
const activePaintTarget = useEditor((s) => s.activePaintTarget)
|
const activePaintTarget = useEditor((s) => s.activePaintTarget)
|
||||||
const paintDisabledFeedbackTick = useEditor((s) => s.paintDisabledFeedbackTick)
|
const paintDisabledFeedbackTick = useEditor((s) => s.paintDisabledFeedbackTick)
|
||||||
|
const hoverHighlightMode = useViewer((s) => s.hoverHighlightMode)
|
||||||
const badgeRef = useRef<HTMLDivElement>(null)
|
const badgeRef = useRef<HTMLDivElement>(null)
|
||||||
const lastPointerPositionRef = useRef<{ x: number; y: number } | null>(null)
|
const lastPointerPositionRef = useRef<{ x: number; y: number } | null>(null)
|
||||||
const [showDisabledFeedback, setShowDisabledFeedback] = useState(false)
|
const [showDisabledFeedback, setShowDisabledFeedback] = useState(false)
|
||||||
const active = mode === 'material-paint' && !isVersionPreviewMode
|
const active = mode === 'material-paint' && !isVersionPreviewMode
|
||||||
|
const showBlockedState = showDisabledFeedback || hoverHighlightMode === 'paint-disabled'
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!active) {
|
if (!active) {
|
||||||
@@ -748,10 +750,26 @@ function PaintCursorLayer({
|
|||||||
)
|
)
|
||||||
const label = !hasMaterial
|
const label = !hasMaterial
|
||||||
? 'Choose material'
|
? 'Choose material'
|
||||||
: showDisabledFeedback
|
: showBlockedState
|
||||||
? 'Unsupported'
|
? 'Unsupported'
|
||||||
: `Paint ${activePaintTarget}`
|
: `Paint ${activePaintTarget}`
|
||||||
const icon = showDisabledFeedback ? 'mdi:block-helper' : 'mdi:format-color-fill'
|
const icon = showBlockedState ? 'mdi:block-helper' : 'mdi:format-color-fill'
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return
|
||||||
|
|
||||||
|
const el = containerRef.current
|
||||||
|
if (!el) return
|
||||||
|
|
||||||
|
if (hoverHighlightMode === 'paint-disabled') {
|
||||||
|
el.style.cursor = 'not-allowed'
|
||||||
|
return () => {
|
||||||
|
el.style.cursor = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
el.style.cursor = ''
|
||||||
|
}, [active, containerRef, hoverHighlightMode])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!paintDisabledFeedbackTick) return
|
if (!paintDisabledFeedbackTick) return
|
||||||
@@ -797,7 +815,7 @@ function PaintCursorLayer({
|
|||||||
style={{ display: 'none', position: 'absolute', left: 0, top: 0 }}
|
style={{ display: 'none', position: 'absolute', left: 0, top: 0 }}
|
||||||
>
|
>
|
||||||
<PaintCursorBadge
|
<PaintCursorBadge
|
||||||
disabled={!hasMaterial || showDisabledFeedback}
|
disabled={!hasMaterial || showBlockedState}
|
||||||
icon={icon}
|
icon={icon}
|
||||||
label={label}
|
label={label}
|
||||||
position={{ x: 0, y: 0 }}
|
position={{ x: 0, y: 0 }}
|
||||||
|
|||||||
@@ -70,6 +70,14 @@ type ModifierKeys = {
|
|||||||
ctrl: boolean
|
ctrl: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SceneMaterialPreview = ReturnType<typeof useViewer.getState>['materialPreview']
|
||||||
|
|
||||||
|
type PaintInteraction = {
|
||||||
|
apply: (() => void) | null
|
||||||
|
preview: SceneMaterialPreview
|
||||||
|
hoveredId: AnyNodeId
|
||||||
|
}
|
||||||
|
|
||||||
interface SelectionStrategy {
|
interface SelectionStrategy {
|
||||||
types: SelectableNodeType[]
|
types: SelectableNodeType[]
|
||||||
handleSelect: (node: AnyNode, nativeEvent?: any, modifierKeys?: ModifierKeys) => void
|
handleSelect: (node: AnyNode, nativeEvent?: any, modifierKeys?: ModifierKeys) => void
|
||||||
@@ -503,6 +511,32 @@ export const SelectionManager = () => {
|
|||||||
if (movingNode || curvingWall) return
|
if (movingNode || curvingWall) return
|
||||||
|
|
||||||
const triggerPaintDisabledFeedback = useEditor.getState().triggerPaintDisabledFeedback
|
const triggerPaintDisabledFeedback = useEditor.getState().triggerPaintDisabledFeedback
|
||||||
|
let hoverFrame = 0
|
||||||
|
let pendingHoveredId: AnyNodeId | null = null
|
||||||
|
let pendingHoverMode: HoverHighlightMode = 'default'
|
||||||
|
let pendingPreview: SceneMaterialPreview = null
|
||||||
|
|
||||||
|
const flushHoverState = () => {
|
||||||
|
hoverFrame = 0
|
||||||
|
const viewerState = useViewer.getState()
|
||||||
|
|
||||||
|
if (viewerState.hoveredId !== pendingHoveredId) {
|
||||||
|
useViewer.setState({ hoveredId: pendingHoveredId })
|
||||||
|
}
|
||||||
|
|
||||||
|
setHoverHighlightMode(pendingHoverMode)
|
||||||
|
|
||||||
|
if (pendingPreview) {
|
||||||
|
viewerState.setMaterialPreview(pendingPreview)
|
||||||
|
} else {
|
||||||
|
viewerState.clearMaterialPreview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduleHoverState = () => {
|
||||||
|
if (hoverFrame !== 0) return
|
||||||
|
hoverFrame = window.requestAnimationFrame(flushHoverState)
|
||||||
|
}
|
||||||
|
|
||||||
const resolveActivePaintMaterial = () =>
|
const resolveActivePaintMaterial = () =>
|
||||||
useEditor.getState().activePaintMaterial ??
|
useEditor.getState().activePaintMaterial ??
|
||||||
@@ -515,11 +549,7 @@ export const SelectionManager = () => {
|
|||||||
selectedMaterialTarget: useEditor.getState().selectedMaterialTarget,
|
selectedMaterialTarget: useEditor.getState().selectedMaterialTarget,
|
||||||
})
|
})
|
||||||
|
|
||||||
const getPaintInteraction = (
|
const getPaintInteraction = (event: NodeEvent): PaintInteraction | null => {
|
||||||
event: NodeEvent,
|
|
||||||
): {
|
|
||||||
apply: (() => void) | null
|
|
||||||
} | null => {
|
|
||||||
const activePaintMaterial = resolveActivePaintMaterial()
|
const activePaintMaterial = resolveActivePaintMaterial()
|
||||||
const node = event.node
|
const node = event.node
|
||||||
|
|
||||||
@@ -530,6 +560,7 @@ export const SelectionManager = () => {
|
|||||||
const compatible =
|
const compatible =
|
||||||
role !== null && isActivePaintMaterialCompatible(activePaintMaterial, 'wall')
|
role !== null && isActivePaintMaterialCompatible(activePaintMaterial, 'wall')
|
||||||
return {
|
return {
|
||||||
|
hoveredId: node.id as AnyNodeId,
|
||||||
apply:
|
apply:
|
||||||
compatible && hasActivePaintMaterial(activePaintMaterial)
|
compatible && hasActivePaintMaterial(activePaintMaterial)
|
||||||
? () => {
|
? () => {
|
||||||
@@ -546,6 +577,16 @@ export const SelectionManager = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
|
preview:
|
||||||
|
compatible && hasActivePaintMaterial(activePaintMaterial) && role
|
||||||
|
? {
|
||||||
|
nodeId: node.id as AnyNodeId,
|
||||||
|
target: 'wall',
|
||||||
|
role,
|
||||||
|
material: activePaintMaterial.material,
|
||||||
|
materialPreset: activePaintMaterial.materialPreset,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -562,6 +603,7 @@ export const SelectionManager = () => {
|
|||||||
const compatible =
|
const compatible =
|
||||||
role !== null && isActivePaintMaterialCompatible(activePaintMaterial, 'roof')
|
role !== null && isActivePaintMaterialCompatible(activePaintMaterial, 'roof')
|
||||||
return {
|
return {
|
||||||
|
hoveredId: roofNode.id as AnyNodeId,
|
||||||
apply:
|
apply:
|
||||||
compatible && hasActivePaintMaterial(activePaintMaterial)
|
compatible && hasActivePaintMaterial(activePaintMaterial)
|
||||||
? () => {
|
? () => {
|
||||||
@@ -578,6 +620,16 @@ export const SelectionManager = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
|
preview:
|
||||||
|
compatible && hasActivePaintMaterial(activePaintMaterial) && role
|
||||||
|
? {
|
||||||
|
nodeId: roofNode.id as AnyNodeId,
|
||||||
|
target: 'roof',
|
||||||
|
role,
|
||||||
|
material: activePaintMaterial.material,
|
||||||
|
materialPreset: activePaintMaterial.materialPreset,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -594,6 +646,7 @@ export const SelectionManager = () => {
|
|||||||
const compatible =
|
const compatible =
|
||||||
role !== null && isActivePaintMaterialCompatible(activePaintMaterial, 'stair')
|
role !== null && isActivePaintMaterialCompatible(activePaintMaterial, 'stair')
|
||||||
return {
|
return {
|
||||||
|
hoveredId: stairNode.id as AnyNodeId,
|
||||||
apply:
|
apply:
|
||||||
compatible && hasActivePaintMaterial(activePaintMaterial)
|
compatible && hasActivePaintMaterial(activePaintMaterial)
|
||||||
? () => {
|
? () => {
|
||||||
@@ -610,6 +663,16 @@ export const SelectionManager = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
|
preview:
|
||||||
|
compatible && hasActivePaintMaterial(activePaintMaterial) && role
|
||||||
|
? {
|
||||||
|
nodeId: stairNode.id as AnyNodeId,
|
||||||
|
target: 'stair',
|
||||||
|
role,
|
||||||
|
material: activePaintMaterial.material,
|
||||||
|
materialPreset: activePaintMaterial.materialPreset,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -620,6 +683,7 @@ export const SelectionManager = () => {
|
|||||||
hasActivePaintMaterial(activePaintMaterial)
|
hasActivePaintMaterial(activePaintMaterial)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
hoveredId: node.id as AnyNodeId,
|
||||||
apply: compatible
|
apply: compatible
|
||||||
? () => {
|
? () => {
|
||||||
useScene
|
useScene
|
||||||
@@ -633,19 +697,62 @@ export const SelectionManager = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
|
preview: compatible
|
||||||
|
? {
|
||||||
|
nodeId: node.id as AnyNodeId,
|
||||||
|
target,
|
||||||
|
role: 'surface',
|
||||||
|
material: activePaintMaterial.material,
|
||||||
|
materialPreset: activePaintMaterial.materialPreset,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const disabledNodeTypes = ['item', 'window', 'door', 'zone']
|
const disabledNodeTypes = ['item', 'window', 'door', 'zone']
|
||||||
if (disabledNodeTypes.includes(node.type)) {
|
if (disabledNodeTypes.includes(node.type)) {
|
||||||
return {
|
return {
|
||||||
|
hoveredId: node.id as AnyNodeId,
|
||||||
apply: null,
|
apply: null,
|
||||||
|
preview: null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onEnter = (event: NodeEvent) => {
|
||||||
|
if (boxSelectHandled) return
|
||||||
|
|
||||||
|
const interaction = getPaintInteraction(event)
|
||||||
|
if (!interaction) return
|
||||||
|
|
||||||
|
event.stopPropagation()
|
||||||
|
|
||||||
|
if (!interaction.preview) {
|
||||||
|
pendingHoveredId = interaction.hoveredId
|
||||||
|
pendingHoverMode = 'paint-disabled'
|
||||||
|
pendingPreview = null
|
||||||
|
scheduleHoverState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingHoveredId = interaction.hoveredId
|
||||||
|
pendingHoverMode = 'paint-ready'
|
||||||
|
pendingPreview = interaction.preview
|
||||||
|
scheduleHoverState()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onLeave = (event: NodeEvent) => {
|
||||||
|
const interaction = getPaintInteraction(event)
|
||||||
|
if (!interaction) return
|
||||||
|
|
||||||
|
pendingHoveredId = null
|
||||||
|
pendingHoverMode = 'default'
|
||||||
|
pendingPreview = null
|
||||||
|
scheduleHoverState()
|
||||||
|
}
|
||||||
|
|
||||||
const onClick = (event: NodeEvent) => {
|
const onClick = (event: NodeEvent) => {
|
||||||
if (boxSelectHandled) return
|
if (boxSelectHandled) return
|
||||||
|
|
||||||
@@ -654,12 +761,19 @@ export const SelectionManager = () => {
|
|||||||
|
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
|
|
||||||
|
if (hoverFrame !== 0) {
|
||||||
|
window.cancelAnimationFrame(hoverFrame)
|
||||||
|
flushHoverState()
|
||||||
|
}
|
||||||
|
|
||||||
if (!interaction.apply) {
|
if (!interaction.apply) {
|
||||||
|
useViewer.getState().clearMaterialPreview()
|
||||||
triggerPaintDisabledFeedback()
|
triggerPaintDisabledFeedback()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
interaction.apply()
|
interaction.apply()
|
||||||
|
useViewer.getState().clearMaterialPreview()
|
||||||
}
|
}
|
||||||
|
|
||||||
const allTypes = [
|
const allTypes = [
|
||||||
@@ -679,14 +793,24 @@ export const SelectionManager = () => {
|
|||||||
|
|
||||||
for (const type of allTypes) {
|
for (const type of allTypes) {
|
||||||
emitter.on(`${type}:click` as any, onClick as any)
|
emitter.on(`${type}:click` as any, onClick as any)
|
||||||
|
emitter.on(`${type}:enter` as any, onEnter as any)
|
||||||
|
emitter.on(`${type}:leave` as any, onLeave as any)
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
for (const type of allTypes) {
|
for (const type of allTypes) {
|
||||||
emitter.off(`${type}:click` as any, onClick as any)
|
emitter.off(`${type}:click` as any, onClick as any)
|
||||||
|
emitter.off(`${type}:enter` as any, onEnter as any)
|
||||||
|
emitter.off(`${type}:leave` as any, onLeave as any)
|
||||||
}
|
}
|
||||||
|
if (hoverFrame !== 0) {
|
||||||
|
window.cancelAnimationFrame(hoverFrame)
|
||||||
|
}
|
||||||
|
useViewer.setState({ hoveredId: null })
|
||||||
|
setHoverHighlightMode('default')
|
||||||
|
useViewer.getState().clearMaterialPreview()
|
||||||
}
|
}
|
||||||
}, [curvingWall, mode, movingNode])
|
}, [curvingWall, mode, movingNode, setHoverHighlightMode])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
@@ -1241,7 +1365,8 @@ const SelectionMaterialSync = () => {
|
|||||||
}, [hoverHighlightMode, hoveredId, previewSelectedIds, selectedIds, syncSelectionMaterials])
|
}, [hoverHighlightMode, hoveredId, previewSelectedIds, selectedIds, syncSelectionMaterials])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return useScene.subscribe(() => {
|
return useScene.subscribe((state, prevState) => {
|
||||||
|
if (state.nodes === prevState.nodes) return
|
||||||
syncSelectionMaterials()
|
syncSelectionMaterials()
|
||||||
})
|
})
|
||||||
}, [syncSelectionMaterials])
|
}, [syncSelectionMaterials])
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
import { type CeilingNode, getMaterialPresetByRef, resolveMaterial, useRegistry } from '@pascal-app/core'
|
import {
|
||||||
|
type CeilingNode,
|
||||||
|
getMaterialPresetByRef,
|
||||||
|
resolveMaterial,
|
||||||
|
useRegistry,
|
||||||
|
} from '@pascal-app/core'
|
||||||
import { useMemo, useRef } from 'react'
|
import { useMemo, useRef } from 'react'
|
||||||
import { float, mix, positionWorld, smoothstep } from 'three/tsl'
|
import { float, mix, positionWorld, smoothstep } from 'three/tsl'
|
||||||
import { BackSide, FrontSide, type Mesh, MeshBasicNodeMaterial } from 'three/webgpu'
|
import { BackSide, FrontSide, type Mesh, MeshBasicNodeMaterial } from 'three/webgpu'
|
||||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||||
|
import useViewer from '../../../store/use-viewer'
|
||||||
import { NodeRenderer } from '../node-renderer'
|
import { NodeRenderer } from '../node-renderer'
|
||||||
|
|
||||||
const gridScale = 5
|
const gridScale = 5
|
||||||
@@ -32,18 +38,47 @@ function createCeilingMaterials(color = '#999999') {
|
|||||||
return { topMaterial, bottomMaterial }
|
return { topMaterial, bottomMaterial }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ceilingMaterialCache = new Map<string, ReturnType<typeof createCeilingMaterials>>()
|
||||||
|
|
||||||
|
function getCeilingMaterials(color = '#999999') {
|
||||||
|
const cacheKey = color
|
||||||
|
const cached = ceilingMaterialCache.get(cacheKey)
|
||||||
|
if (cached) return cached
|
||||||
|
|
||||||
|
const materials = createCeilingMaterials(color)
|
||||||
|
ceilingMaterialCache.set(cacheKey, materials)
|
||||||
|
return materials
|
||||||
|
}
|
||||||
|
|
||||||
export const CeilingRenderer = ({ node }: { node: CeilingNode }) => {
|
export const CeilingRenderer = ({ node }: { node: CeilingNode }) => {
|
||||||
const ref = useRef<Mesh>(null!)
|
const ref = useRef<Mesh>(null!)
|
||||||
|
|
||||||
useRegistry(node.id, 'ceiling', ref)
|
useRegistry(node.id, 'ceiling', ref)
|
||||||
const handlers = useNodeEvents(node, 'ceiling')
|
const handlers = useNodeEvents(node, 'ceiling')
|
||||||
|
const materialPreview = useViewer((state) =>
|
||||||
|
state.materialPreview?.target === 'ceiling' && state.materialPreview.nodeId === node.id
|
||||||
|
? state.materialPreview
|
||||||
|
: null,
|
||||||
|
)
|
||||||
|
|
||||||
const materials = useMemo(() => {
|
const materials = useMemo(() => {
|
||||||
const preset = getMaterialPresetByRef(node.materialPreset)
|
const preset = getMaterialPresetByRef(materialPreview?.materialPreset ?? node.materialPreset)
|
||||||
const props = preset?.mapProperties ?? resolveMaterial(node.material)
|
const props =
|
||||||
|
preset?.mapProperties ?? resolveMaterial(materialPreview?.material ?? node.material)
|
||||||
const color = props.color || '#999999'
|
const color = props.color || '#999999'
|
||||||
return createCeilingMaterials(color)
|
return getCeilingMaterials(color)
|
||||||
}, [node.materialPreset, node.material, node.material?.preset, node.material?.properties, node.material?.texture])
|
}, [
|
||||||
|
node.materialPreset,
|
||||||
|
node.material,
|
||||||
|
node.material?.preset,
|
||||||
|
node.material?.properties,
|
||||||
|
node.material?.texture,
|
||||||
|
materialPreview?.materialPreset,
|
||||||
|
materialPreview?.material,
|
||||||
|
materialPreview?.material?.preset,
|
||||||
|
materialPreview?.material?.properties,
|
||||||
|
materialPreview?.material?.texture,
|
||||||
|
])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<mesh material={materials.bottomMaterial} ref={ref}>
|
<mesh material={materials.bottomMaterial} ref={ref}>
|
||||||
|
|||||||
@@ -7,14 +7,22 @@ import {
|
|||||||
createMaterialFromPresetRef,
|
createMaterialFromPresetRef,
|
||||||
DEFAULT_STAIR_MATERIAL,
|
DEFAULT_STAIR_MATERIAL,
|
||||||
} from '../../../lib/materials'
|
} from '../../../lib/materials'
|
||||||
|
import useViewer from '../../../store/use-viewer'
|
||||||
|
|
||||||
export const FenceRenderer = ({ node }: { node: FenceNode }) => {
|
export const FenceRenderer = ({ node }: { node: FenceNode }) => {
|
||||||
const ref = useRef<Mesh>(null!)
|
const ref = useRef<Mesh>(null!)
|
||||||
const handlers = useNodeEvents(node, 'fence')
|
const handlers = useNodeEvents(node, 'fence')
|
||||||
|
const materialPreview = useViewer((state) =>
|
||||||
|
state.materialPreview?.target === 'fence' && state.materialPreview.nodeId === node.id
|
||||||
|
? state.materialPreview
|
||||||
|
: null,
|
||||||
|
)
|
||||||
const material = useMemo(() => {
|
const material = useMemo(() => {
|
||||||
const presetMaterial = createMaterialFromPresetRef(node.materialPreset)
|
const presetMaterial = createMaterialFromPresetRef(
|
||||||
|
materialPreview?.materialPreset ?? node.materialPreset,
|
||||||
|
)
|
||||||
if (presetMaterial) return presetMaterial
|
if (presetMaterial) return presetMaterial
|
||||||
const mat = node.material
|
const mat = materialPreview?.material ?? node.material
|
||||||
if (!mat) return DEFAULT_STAIR_MATERIAL
|
if (!mat) return DEFAULT_STAIR_MATERIAL
|
||||||
return createMaterial(mat)
|
return createMaterial(mat)
|
||||||
}, [
|
}, [
|
||||||
@@ -23,6 +31,11 @@ export const FenceRenderer = ({ node }: { node: FenceNode }) => {
|
|||||||
node.material?.preset,
|
node.material?.preset,
|
||||||
node.material?.properties,
|
node.material?.properties,
|
||||||
node.material?.texture,
|
node.material?.texture,
|
||||||
|
materialPreview?.materialPreset,
|
||||||
|
materialPreview?.material,
|
||||||
|
materialPreview?.material?.preset,
|
||||||
|
materialPreview?.material?.properties,
|
||||||
|
materialPreview?.material?.texture,
|
||||||
])
|
])
|
||||||
|
|
||||||
useRegistry(node.id, 'fence', ref)
|
useRegistry(node.id, 'fence', ref)
|
||||||
@@ -31,7 +44,14 @@ export const FenceRenderer = ({ node }: { node: FenceNode }) => {
|
|||||||
}, [node.id])
|
}, [node.id])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<mesh castShadow material={material} receiveShadow ref={ref} visible={node.visible} {...handlers}>
|
<mesh
|
||||||
|
castShadow
|
||||||
|
material={material}
|
||||||
|
receiveShadow
|
||||||
|
ref={ref}
|
||||||
|
visible={node.visible}
|
||||||
|
{...handlers}
|
||||||
|
>
|
||||||
<boxGeometry args={[0, 0, 0]} />
|
<boxGeometry args={[0, 0, 0]} />
|
||||||
</mesh>
|
</mesh>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
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 { useEffect, useMemo, useRef } from 'react'
|
import { useEffect, useMemo, useRef } from 'react'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||||
@@ -14,8 +20,14 @@ export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => {
|
|||||||
|
|
||||||
const handlers = useNodeEvents(node, 'roof-segment')
|
const handlers = useNodeEvents(node, 'roof-segment')
|
||||||
const debugColors = useViewer((s) => s.debugColors)
|
const debugColors = useViewer((s) => s.debugColors)
|
||||||
const parentNode =
|
const parentNode = node.parentId
|
||||||
node.parentId ? (nodes[node.parentId as AnyNodeId] as RoofNode | undefined) : undefined
|
? (nodes[node.parentId as AnyNodeId] as RoofNode | undefined)
|
||||||
|
: undefined
|
||||||
|
const materialPreview = useViewer((state) =>
|
||||||
|
state.materialPreview?.target === 'roof' && state.materialPreview.nodeId === parentNode?.id
|
||||||
|
? state.materialPreview
|
||||||
|
: null,
|
||||||
|
)
|
||||||
const placeholderGeometry = useMemo(() => {
|
const placeholderGeometry = useMemo(() => {
|
||||||
const geometry = new THREE.BufferGeometry()
|
const geometry = new THREE.BufferGeometry()
|
||||||
geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
|
geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
|
||||||
@@ -26,30 +38,40 @@ export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => {
|
|||||||
return geometry
|
return geometry
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const previewParentNode = !parentNode
|
||||||
|
? undefined
|
||||||
|
: materialPreview?.role === 'top'
|
||||||
|
? {
|
||||||
|
...parentNode,
|
||||||
|
topMaterial: materialPreview.material,
|
||||||
|
topMaterialPreset: materialPreview.materialPreset,
|
||||||
|
material: undefined,
|
||||||
|
materialPreset: undefined,
|
||||||
|
}
|
||||||
|
: materialPreview?.role === 'edge'
|
||||||
|
? {
|
||||||
|
...parentNode,
|
||||||
|
edgeMaterial: materialPreview.material,
|
||||||
|
edgeMaterialPreset: materialPreview.materialPreset,
|
||||||
|
material: undefined,
|
||||||
|
materialPreset: undefined,
|
||||||
|
}
|
||||||
|
: materialPreview?.role === 'wall'
|
||||||
|
? {
|
||||||
|
...parentNode,
|
||||||
|
wallMaterial: materialPreview.material,
|
||||||
|
wallMaterialPreset: materialPreview.materialPreset,
|
||||||
|
material: undefined,
|
||||||
|
materialPreset: undefined,
|
||||||
|
}
|
||||||
|
: parentNode
|
||||||
const customMaterial = useMemo(() => {
|
const customMaterial = useMemo(() => {
|
||||||
if (node.material !== undefined || typeof node.materialPreset === 'string') {
|
if (node.material !== undefined || typeof node.materialPreset === 'string') {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return parentNode ? getRoofMaterialArray(parentNode) : null
|
return previewParentNode ? getRoofMaterialArray(previewParentNode) : null
|
||||||
}, [
|
}, [node, previewParentNode])
|
||||||
node.materialPreset,
|
|
||||||
node.material,
|
|
||||||
node.material?.preset,
|
|
||||||
node.material?.properties,
|
|
||||||
node.material?.texture,
|
|
||||||
parentNode?.materialPreset,
|
|
||||||
parentNode?.material,
|
|
||||||
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
|
const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ 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 materialPreview = useViewer((state) =>
|
||||||
|
state.materialPreview?.target === 'roof' && state.materialPreview.nodeId === node.id
|
||||||
|
? state.materialPreview
|
||||||
|
: null,
|
||||||
|
)
|
||||||
const placeholderGeometry = useMemo(() => {
|
const placeholderGeometry = useMemo(() => {
|
||||||
const geometry = new THREE.BufferGeometry()
|
const geometry = new THREE.BufferGeometry()
|
||||||
geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
|
geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
|
||||||
@@ -24,22 +29,33 @@ export const RoofRenderer = ({ node }: { node: RoofNode }) => {
|
|||||||
return geometry
|
return geometry
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const customMaterial = useMemo(
|
const previewNode =
|
||||||
() => getRoofMaterialArray(node),
|
materialPreview?.role === 'top'
|
||||||
[
|
? {
|
||||||
node.materialPreset,
|
...node,
|
||||||
node.material,
|
topMaterial: materialPreview.material,
|
||||||
node.material?.preset,
|
topMaterialPreset: materialPreview.materialPreset,
|
||||||
node.material?.properties,
|
material: undefined,
|
||||||
node.material?.texture,
|
materialPreset: undefined,
|
||||||
node.topMaterial,
|
}
|
||||||
node.topMaterialPreset,
|
: materialPreview?.role === 'edge'
|
||||||
node.edgeMaterial,
|
? {
|
||||||
node.edgeMaterialPreset,
|
...node,
|
||||||
node.wallMaterial,
|
edgeMaterial: materialPreview.material,
|
||||||
node.wallMaterialPreset,
|
edgeMaterialPreset: materialPreview.materialPreset,
|
||||||
],
|
material: undefined,
|
||||||
)
|
materialPreset: undefined,
|
||||||
|
}
|
||||||
|
: materialPreview?.role === 'wall'
|
||||||
|
? {
|
||||||
|
...node,
|
||||||
|
wallMaterial: materialPreview.material,
|
||||||
|
wallMaterialPreset: materialPreview.materialPreset,
|
||||||
|
material: undefined,
|
||||||
|
materialPreset: undefined,
|
||||||
|
}
|
||||||
|
: node
|
||||||
|
const customMaterial = useMemo(() => getRoofMaterialArray(previewNode), [previewNode])
|
||||||
|
|
||||||
const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials
|
const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,50 @@
|
|||||||
import { getMaterialPresetByRef, type SlabNode, useRegistry } from '@pascal-app/core'
|
import { getMaterialPresetByRef, type SlabNode, useRegistry } from '@pascal-app/core'
|
||||||
import { useEffect, useMemo, useRef } from 'react'
|
import { useMemo, useRef } from 'react'
|
||||||
import * as THREE from 'three'
|
|
||||||
import type { Mesh } from 'three'
|
import type { Mesh } from 'three'
|
||||||
|
import * as THREE from 'three'
|
||||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||||
import {
|
import {
|
||||||
applyMaterialPresetToMaterials,
|
applyMaterialPresetToMaterials,
|
||||||
createMaterial,
|
createMaterial,
|
||||||
DEFAULT_SLAB_MATERIAL,
|
DEFAULT_SLAB_MATERIAL,
|
||||||
} from '../../../lib/materials'
|
} from '../../../lib/materials'
|
||||||
|
import useViewer from '../../../store/use-viewer'
|
||||||
|
|
||||||
|
const slabMaterialCache = new Map<string, THREE.MeshStandardMaterial>()
|
||||||
|
|
||||||
|
function getSlabMaterial(
|
||||||
|
cacheKey: string,
|
||||||
|
params: { material?: SlabNode['material']; materialPreset?: string },
|
||||||
|
) {
|
||||||
|
const cached = slabMaterialCache.get(cacheKey)
|
||||||
|
if (cached) return cached
|
||||||
|
|
||||||
|
const preset = getMaterialPresetByRef(params.materialPreset)
|
||||||
|
const slabMaterial = preset
|
||||||
|
? new THREE.MeshStandardMaterial()
|
||||||
|
: params.material
|
||||||
|
? createMaterial(params.material).clone()
|
||||||
|
: DEFAULT_SLAB_MATERIAL.clone()
|
||||||
|
|
||||||
|
if (preset) {
|
||||||
|
// Apply the preset to the slab-owned material so async texture loads update
|
||||||
|
// the instance we actually render after refresh as well.
|
||||||
|
applyMaterialPresetToMaterials(slabMaterial, preset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slabs participate in the WebGPU MRT scene pass. Keeping them opaque avoids
|
||||||
|
// pipeline variants that can fail when geometry is regenerated while a
|
||||||
|
// transparent/custom material is attached.
|
||||||
|
slabMaterial.transparent = false
|
||||||
|
slabMaterial.opacity = 1
|
||||||
|
slabMaterial.alphaMap = null
|
||||||
|
slabMaterial.side = THREE.DoubleSide
|
||||||
|
slabMaterial.depthWrite = true
|
||||||
|
slabMaterial.needsUpdate = true
|
||||||
|
|
||||||
|
slabMaterialCache.set(cacheKey, slabMaterial)
|
||||||
|
return slabMaterial
|
||||||
|
}
|
||||||
|
|
||||||
export const SlabRenderer = ({ node }: { node: SlabNode }) => {
|
export const SlabRenderer = ({ node }: { node: SlabNode }) => {
|
||||||
const ref = useRef<Mesh>(null!)
|
const ref = useRef<Mesh>(null!)
|
||||||
@@ -15,46 +52,37 @@ export const SlabRenderer = ({ node }: { node: SlabNode }) => {
|
|||||||
useRegistry(node.id, 'slab', ref)
|
useRegistry(node.id, 'slab', ref)
|
||||||
|
|
||||||
const handlers = useNodeEvents(node, 'slab')
|
const handlers = useNodeEvents(node, 'slab')
|
||||||
|
const materialPreview = useViewer((state) =>
|
||||||
|
state.materialPreview?.target === 'slab' && state.materialPreview.nodeId === node.id
|
||||||
|
? state.materialPreview
|
||||||
|
: null,
|
||||||
|
)
|
||||||
|
|
||||||
const material = useMemo(() => {
|
const material = useMemo(() => {
|
||||||
const preset = getMaterialPresetByRef(node.materialPreset)
|
const resolvedMaterial = materialPreview?.material ?? node.material
|
||||||
const slabMaterial = preset
|
const resolvedMaterialPreset = materialPreview?.materialPreset ?? node.materialPreset
|
||||||
? new THREE.MeshStandardMaterial()
|
const cacheKey = JSON.stringify({
|
||||||
: node.material
|
material: resolvedMaterial ?? null,
|
||||||
? createMaterial(node.material).clone()
|
materialPreset: resolvedMaterialPreset ?? null,
|
||||||
: DEFAULT_SLAB_MATERIAL.clone()
|
})
|
||||||
|
|
||||||
if (preset) {
|
return getSlabMaterial(cacheKey, {
|
||||||
// Apply the preset to the slab-owned material so async texture loads update
|
material: resolvedMaterial,
|
||||||
// the instance we actually render after refresh as well.
|
materialPreset: resolvedMaterialPreset,
|
||||||
applyMaterialPresetToMaterials(slabMaterial, preset)
|
})
|
||||||
}
|
|
||||||
|
|
||||||
// Slabs participate in the WebGPU MRT scene pass. Keeping them opaque avoids
|
|
||||||
// pipeline variants that can fail when geometry is regenerated while a
|
|
||||||
// transparent/custom material is attached.
|
|
||||||
slabMaterial.transparent = false
|
|
||||||
slabMaterial.opacity = 1
|
|
||||||
slabMaterial.alphaMap = null
|
|
||||||
slabMaterial.side = THREE.DoubleSide
|
|
||||||
slabMaterial.depthWrite = true
|
|
||||||
slabMaterial.needsUpdate = true
|
|
||||||
|
|
||||||
return slabMaterial
|
|
||||||
}, [
|
}, [
|
||||||
node.material,
|
node.material,
|
||||||
node.material?.preset,
|
node.material?.preset,
|
||||||
node.material?.properties,
|
node.material?.properties,
|
||||||
node.material?.texture,
|
node.material?.texture,
|
||||||
node.materialPreset,
|
node.materialPreset,
|
||||||
|
materialPreview?.material,
|
||||||
|
materialPreview?.material?.preset,
|
||||||
|
materialPreview?.material?.properties,
|
||||||
|
materialPreview?.material?.texture,
|
||||||
|
materialPreview?.materialPreset,
|
||||||
])
|
])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
material.dispose()
|
|
||||||
}
|
|
||||||
}, [material])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<mesh
|
<mesh
|
||||||
castShadow
|
castShadow
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import { type AnyNodeId, type StairNode, type StairSegmentNode, useRegistry, useScene } from '@pascal-app/core'
|
import {
|
||||||
|
type AnyNodeId,
|
||||||
|
type StairNode,
|
||||||
|
type StairSegmentNode,
|
||||||
|
useRegistry,
|
||||||
|
useScene,
|
||||||
|
} from '@pascal-app/core'
|
||||||
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
|
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||||
|
import useViewer from '../../../store/use-viewer'
|
||||||
import { getStraightStairSegmentBodyMaterials } from '../../../systems/stair/stair-materials'
|
import { getStraightStairSegmentBodyMaterials } from '../../../systems/stair/stair-materials'
|
||||||
|
|
||||||
export const StairSegmentRenderer = ({ node }: { node: StairSegmentNode }) => {
|
export const StairSegmentRenderer = ({ node }: { node: StairSegmentNode }) => {
|
||||||
@@ -15,29 +22,45 @@ export const StairSegmentRenderer = ({ node }: { node: StairSegmentNode }) => {
|
|||||||
}, [node.id])
|
}, [node.id])
|
||||||
|
|
||||||
const handlers = useNodeEvents(node, 'stair-segment')
|
const handlers = useNodeEvents(node, 'stair-segment')
|
||||||
const parentNode =
|
const parentNode = node.parentId
|
||||||
node.parentId ? (nodes[node.parentId as AnyNodeId] as StairNode | undefined) : undefined
|
? (nodes[node.parentId as AnyNodeId] as StairNode | undefined)
|
||||||
|
: undefined
|
||||||
|
const materialPreview = useViewer((state) =>
|
||||||
|
state.materialPreview?.target === 'stair' && state.materialPreview.nodeId === parentNode?.id
|
||||||
|
? state.materialPreview
|
||||||
|
: null,
|
||||||
|
)
|
||||||
|
const previewParentNode = !parentNode
|
||||||
|
? undefined
|
||||||
|
: materialPreview?.role === 'railing'
|
||||||
|
? {
|
||||||
|
...parentNode,
|
||||||
|
railingMaterial: materialPreview.material,
|
||||||
|
railingMaterialPreset: materialPreview.materialPreset,
|
||||||
|
material: undefined,
|
||||||
|
materialPreset: undefined,
|
||||||
|
}
|
||||||
|
: materialPreview?.role === 'tread'
|
||||||
|
? {
|
||||||
|
...parentNode,
|
||||||
|
treadMaterial: materialPreview.material,
|
||||||
|
treadMaterialPreset: materialPreview.materialPreset,
|
||||||
|
material: undefined,
|
||||||
|
materialPreset: undefined,
|
||||||
|
}
|
||||||
|
: materialPreview?.role === 'side'
|
||||||
|
? {
|
||||||
|
...parentNode,
|
||||||
|
sideMaterial: materialPreview.material,
|
||||||
|
sideMaterialPreset: materialPreview.materialPreset,
|
||||||
|
material: undefined,
|
||||||
|
materialPreset: undefined,
|
||||||
|
}
|
||||||
|
: parentNode
|
||||||
|
|
||||||
const material = useMemo(() => {
|
const material = useMemo(() => {
|
||||||
return getStraightStairSegmentBodyMaterials(node, parentNode)
|
return getStraightStairSegmentBodyMaterials(node, previewParentNode)
|
||||||
}, [
|
}, [node, previewParentNode])
|
||||||
node.materialPreset,
|
|
||||||
node.material,
|
|
||||||
node.material?.preset,
|
|
||||||
node.material?.properties,
|
|
||||||
node.material?.texture,
|
|
||||||
parentNode?.materialPreset,
|
|
||||||
parentNode?.material,
|
|
||||||
parentNode?.material?.preset,
|
|
||||||
parentNode?.material?.properties,
|
|
||||||
parentNode?.material?.texture,
|
|
||||||
parentNode?.railingMaterialPreset,
|
|
||||||
parentNode?.railingMaterial,
|
|
||||||
parentNode?.sideMaterialPreset,
|
|
||||||
parentNode?.sideMaterial,
|
|
||||||
parentNode?.treadMaterialPreset,
|
|
||||||
parentNode?.treadMaterial,
|
|
||||||
])
|
|
||||||
|
|
||||||
const placeholderGeometry = useMemo(() => {
|
const placeholderGeometry = useMemo(() => {
|
||||||
const geometry = new THREE.BufferGeometry()
|
const geometry = new THREE.BufferGeometry()
|
||||||
|
|||||||
@@ -8,10 +8,15 @@ import {
|
|||||||
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
|
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
|
||||||
import * 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, DEFAULT_STAIR_MATERIAL } from '../../../lib/materials'
|
|
||||||
import {
|
import {
|
||||||
getStairRailingMaterial,
|
createMaterial,
|
||||||
|
createMaterialFromPresetRef,
|
||||||
|
DEFAULT_STAIR_MATERIAL,
|
||||||
|
} from '../../../lib/materials'
|
||||||
|
import useViewer from '../../../store/use-viewer'
|
||||||
|
import {
|
||||||
getStairBodyMaterials,
|
getStairBodyMaterials,
|
||||||
|
getStairRailingMaterial,
|
||||||
type StairBodyMaterials,
|
type StairBodyMaterials,
|
||||||
} from '../../../systems/stair/stair-materials'
|
} from '../../../systems/stair/stair-materials'
|
||||||
import { NodeRenderer } from '../node-renderer'
|
import { NodeRenderer } from '../node-renderer'
|
||||||
@@ -57,48 +62,55 @@ export const StairRenderer = ({ node }: { node: StairNode }) => {
|
|||||||
}, [node.id])
|
}, [node.id])
|
||||||
|
|
||||||
const handlers = useNodeEvents(node, 'stair')
|
const handlers = useNodeEvents(node, 'stair')
|
||||||
|
const materialPreview = useViewer((state) =>
|
||||||
|
state.materialPreview?.target === 'stair' && state.materialPreview.nodeId === node.id
|
||||||
|
? state.materialPreview
|
||||||
|
: null,
|
||||||
|
)
|
||||||
|
const previewNode =
|
||||||
|
materialPreview?.role === 'railing'
|
||||||
|
? {
|
||||||
|
...node,
|
||||||
|
railingMaterial: materialPreview.material,
|
||||||
|
railingMaterialPreset: materialPreview.materialPreset,
|
||||||
|
material: undefined,
|
||||||
|
materialPreset: undefined,
|
||||||
|
}
|
||||||
|
: materialPreview?.role === 'tread'
|
||||||
|
? {
|
||||||
|
...node,
|
||||||
|
treadMaterial: materialPreview.material,
|
||||||
|
treadMaterialPreset: materialPreview.materialPreset,
|
||||||
|
material: undefined,
|
||||||
|
materialPreset: undefined,
|
||||||
|
}
|
||||||
|
: materialPreview?.role === 'side'
|
||||||
|
? {
|
||||||
|
...node,
|
||||||
|
sideMaterial: materialPreview.material,
|
||||||
|
sideMaterialPreset: materialPreview.materialPreset,
|
||||||
|
material: undefined,
|
||||||
|
materialPreset: undefined,
|
||||||
|
}
|
||||||
|
: node
|
||||||
|
|
||||||
const material = useMemo(() => {
|
const material = useMemo(() => {
|
||||||
const presetMaterial = createMaterialFromPresetRef(node.materialPreset)
|
const presetMaterial = createMaterialFromPresetRef(previewNode.materialPreset)
|
||||||
if (presetMaterial) return presetMaterial
|
if (presetMaterial) return presetMaterial
|
||||||
const mat = node.material
|
const mat = previewNode.material
|
||||||
if (!mat) return DEFAULT_STAIR_MATERIAL
|
if (!mat) return DEFAULT_STAIR_MATERIAL
|
||||||
return createMaterial(mat)
|
return createMaterial(mat)
|
||||||
}, [
|
}, [
|
||||||
node.materialPreset,
|
previewNode.materialPreset,
|
||||||
node.material,
|
previewNode.material,
|
||||||
node.material?.preset,
|
previewNode.material?.preset,
|
||||||
node.material?.properties,
|
previewNode.material?.properties,
|
||||||
node.material?.texture,
|
previewNode.material?.texture,
|
||||||
])
|
])
|
||||||
|
|
||||||
const straightBodyMaterials = useMemo(
|
const straightBodyMaterials = useMemo(() => getStairBodyMaterials(previewNode), [previewNode])
|
||||||
() => getStairBodyMaterials(node),
|
|
||||||
[
|
|
||||||
node.material,
|
|
||||||
node.materialPreset,
|
|
||||||
node.railingMaterial,
|
|
||||||
node.railingMaterialPreset,
|
|
||||||
node.sideMaterial,
|
|
||||||
node.sideMaterialPreset,
|
|
||||||
node.treadMaterial,
|
|
||||||
node.treadMaterialPreset,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
const railingMaterial = useMemo(
|
const railingMaterial = useMemo(() => getStairRailingMaterial(previewNode), [previewNode])
|
||||||
() => getStairRailingMaterial(node),
|
|
||||||
[
|
|
||||||
node.material,
|
|
||||||
node.materialPreset,
|
|
||||||
node.railingMaterial,
|
|
||||||
node.railingMaterialPreset,
|
|
||||||
node.sideMaterial,
|
|
||||||
node.sideMaterialPreset,
|
|
||||||
node.treadMaterial,
|
|
||||||
node.treadMaterialPreset,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
const straightPlaceholderGeometry = useMemo(() => {
|
const straightPlaceholderGeometry = useMemo(() => {
|
||||||
const geometry = new THREE.BufferGeometry()
|
const geometry = new THREE.BufferGeometry()
|
||||||
@@ -132,7 +144,9 @@ export const StairRenderer = ({ node }: { node: StairNode }) => {
|
|||||||
receiveShadow
|
receiveShadow
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{!isSegmentBasedStair ? <CurvedStairBody bodyMaterials={straightBodyMaterials} stair={node} /> : null}
|
{!isSegmentBasedStair ? (
|
||||||
|
<CurvedStairBody bodyMaterials={straightBodyMaterials} stair={node} />
|
||||||
|
) : null}
|
||||||
<StairRailings material={railingMaterial} stair={node} />
|
<StairRailings material={railingMaterial} stair={node} />
|
||||||
{isSegmentBasedStair ? (
|
{isSegmentBasedStair ? (
|
||||||
<group name="segments-wrapper" visible={false}>
|
<group name="segments-wrapper" visible={false}>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useRegistry, useScene, type WallNode } from '@pascal-app/core'
|
|||||||
import { useLayoutEffect, useRef } from 'react'
|
import { useLayoutEffect, useRef } from 'react'
|
||||||
import type { Mesh } from 'three'
|
import type { Mesh } from 'three'
|
||||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||||
|
import useViewer from '../../../store/use-viewer'
|
||||||
import { getVisibleWallMaterials } from '../../../systems/wall/wall-materials'
|
import { getVisibleWallMaterials } from '../../../systems/wall/wall-materials'
|
||||||
import { NodeRenderer } from '../node-renderer'
|
import { NodeRenderer } from '../node-renderer'
|
||||||
|
|
||||||
@@ -15,7 +16,30 @@ export const WallRenderer = ({ node }: { node: WallNode }) => {
|
|||||||
}, [node.id])
|
}, [node.id])
|
||||||
|
|
||||||
const handlers = useNodeEvents(node, 'wall')
|
const handlers = useNodeEvents(node, 'wall')
|
||||||
const material = getVisibleWallMaterials(node)
|
const materialPreview = useViewer((state) =>
|
||||||
|
state.materialPreview?.target === 'wall' && state.materialPreview.nodeId === node.id
|
||||||
|
? state.materialPreview
|
||||||
|
: null,
|
||||||
|
)
|
||||||
|
const previewNode =
|
||||||
|
materialPreview && materialPreview.role === 'interior'
|
||||||
|
? {
|
||||||
|
...node,
|
||||||
|
interiorMaterial: materialPreview.material,
|
||||||
|
interiorMaterialPreset: materialPreview.materialPreset,
|
||||||
|
material: undefined,
|
||||||
|
materialPreset: undefined,
|
||||||
|
}
|
||||||
|
: materialPreview && materialPreview.role === 'exterior'
|
||||||
|
? {
|
||||||
|
...node,
|
||||||
|
exteriorMaterial: materialPreview.material,
|
||||||
|
exteriorMaterialPreset: materialPreview.materialPreset,
|
||||||
|
material: undefined,
|
||||||
|
materialPreset: undefined,
|
||||||
|
}
|
||||||
|
: node
|
||||||
|
const material = getVisibleWallMaterials(previewNode)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<mesh castShadow material={material} receiveShadow ref={ref} visible={node.visible}>
|
<mesh castShadow material={material} receiveShadow ref={ref} visible={node.visible}>
|
||||||
|
|||||||
+41
-3
@@ -1,4 +1,14 @@
|
|||||||
import type { AnyNode, BaseNode, BuildingNode, LevelNode, ZoneNode } from '@pascal-app/core'
|
import type {
|
||||||
|
AnyNode,
|
||||||
|
BaseNode,
|
||||||
|
BuildingNode,
|
||||||
|
LevelNode,
|
||||||
|
MaterialSchema,
|
||||||
|
RoofSurfaceMaterialRole,
|
||||||
|
StairSurfaceMaterialRole,
|
||||||
|
WallSurfaceSide,
|
||||||
|
ZoneNode,
|
||||||
|
} from '@pascal-app/core'
|
||||||
import type { Object3D } from 'three'
|
import type { Object3D } from 'three'
|
||||||
type SelectionPath = {
|
type SelectionPath = {
|
||||||
buildingId: BuildingNode['id'] | null
|
buildingId: BuildingNode['id'] | null
|
||||||
@@ -10,12 +20,40 @@ type Outliner = {
|
|||||||
selectedObjects: Object3D[]
|
selectedObjects: Object3D[]
|
||||||
hoveredObjects: Object3D[]
|
hoveredObjects: Object3D[]
|
||||||
}
|
}
|
||||||
|
type MaterialPreview = {
|
||||||
|
nodeId: string
|
||||||
|
target: 'wall'
|
||||||
|
role: WallSurfaceSide
|
||||||
|
material?: MaterialSchema
|
||||||
|
materialPreset?: string
|
||||||
|
} | {
|
||||||
|
nodeId: string
|
||||||
|
target: 'roof'
|
||||||
|
role: RoofSurfaceMaterialRole
|
||||||
|
material?: MaterialSchema
|
||||||
|
materialPreset?: string
|
||||||
|
} | {
|
||||||
|
nodeId: string
|
||||||
|
target: 'stair'
|
||||||
|
role: StairSurfaceMaterialRole
|
||||||
|
material?: MaterialSchema
|
||||||
|
materialPreset?: string
|
||||||
|
} | {
|
||||||
|
nodeId: string
|
||||||
|
target: 'fence' | 'slab' | 'ceiling'
|
||||||
|
role: 'surface'
|
||||||
|
material?: MaterialSchema
|
||||||
|
materialPreset?: string
|
||||||
|
} | null
|
||||||
type ViewerState = {
|
type ViewerState = {
|
||||||
selection: SelectionPath
|
selection: SelectionPath
|
||||||
previewSelectedIds: BaseNode['id'][]
|
previewSelectedIds: BaseNode['id'][]
|
||||||
setPreviewSelectedIds: (ids: BaseNode['id'][]) => void
|
setPreviewSelectedIds: (ids: BaseNode['id'][]) => void
|
||||||
hoverHighlightMode: 'default' | 'delete'
|
materialPreview: MaterialPreview
|
||||||
setHoverHighlightMode: (mode: 'default' | 'delete') => void
|
setMaterialPreview: (preview: MaterialPreview) => void
|
||||||
|
clearMaterialPreview: () => void
|
||||||
|
hoverHighlightMode: 'default' | 'delete' | 'paint-ready' | 'paint-disabled'
|
||||||
|
setHoverHighlightMode: (mode: 'default' | 'delete' | 'paint-ready' | 'paint-disabled') => void
|
||||||
hoveredId: AnyNode['id'] | ZoneNode['id'] | null
|
hoveredId: AnyNode['id'] | ZoneNode['id'] | null
|
||||||
setHoveredId: (id: AnyNode['id'] | ZoneNode['id'] | null) => void
|
setHoveredId: (id: AnyNode['id'] | ZoneNode['id'] | null) => void
|
||||||
cameraMode: 'perspective' | 'orthographic'
|
cameraMode: 'perspective' | 'orthographic'
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import type { AnyNode, BaseNode, BuildingNode, LevelNode, ZoneNode } from '@pascal-app/core'
|
import type {
|
||||||
|
AnyNode,
|
||||||
|
AnyNodeId,
|
||||||
|
BaseNode,
|
||||||
|
BuildingNode,
|
||||||
|
LevelNode,
|
||||||
|
MaterialSchema,
|
||||||
|
RoofSurfaceMaterialRole,
|
||||||
|
StairSurfaceMaterialRole,
|
||||||
|
WallSurfaceSide,
|
||||||
|
ZoneNode,
|
||||||
|
} from '@pascal-app/core'
|
||||||
import type { Object3D } from 'three'
|
import type { Object3D } from 'three'
|
||||||
|
|
||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
@@ -18,10 +29,57 @@ type Outliner = {
|
|||||||
hoveredObjects: Object3D[]
|
hoveredObjects: Object3D[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MaterialPreview =
|
||||||
|
| {
|
||||||
|
nodeId: AnyNodeId
|
||||||
|
target: 'wall'
|
||||||
|
role: WallSurfaceSide
|
||||||
|
material?: MaterialSchema
|
||||||
|
materialPreset?: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
nodeId: AnyNodeId
|
||||||
|
target: 'roof'
|
||||||
|
role: RoofSurfaceMaterialRole
|
||||||
|
material?: MaterialSchema
|
||||||
|
materialPreset?: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
nodeId: AnyNodeId
|
||||||
|
target: 'stair'
|
||||||
|
role: StairSurfaceMaterialRole
|
||||||
|
material?: MaterialSchema
|
||||||
|
materialPreset?: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
nodeId: AnyNodeId
|
||||||
|
target: 'fence' | 'slab' | 'ceiling'
|
||||||
|
role: 'surface'
|
||||||
|
material?: MaterialSchema
|
||||||
|
materialPreset?: string
|
||||||
|
}
|
||||||
|
| null
|
||||||
|
|
||||||
|
function isSameMaterialPreview(left: MaterialPreview, right: MaterialPreview) {
|
||||||
|
if (left === right) return true
|
||||||
|
if (!left || !right) return false
|
||||||
|
|
||||||
|
return (
|
||||||
|
left.nodeId === right.nodeId &&
|
||||||
|
left.target === right.target &&
|
||||||
|
left.role === right.role &&
|
||||||
|
left.material === right.material &&
|
||||||
|
left.materialPreset === right.materialPreset
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
type ViewerState = {
|
type ViewerState = {
|
||||||
selection: SelectionPath
|
selection: SelectionPath
|
||||||
previewSelectedIds: BaseNode['id'][]
|
previewSelectedIds: BaseNode['id'][]
|
||||||
setPreviewSelectedIds: (ids: BaseNode['id'][]) => void
|
setPreviewSelectedIds: (ids: BaseNode['id'][]) => void
|
||||||
|
materialPreview: MaterialPreview
|
||||||
|
setMaterialPreview: (preview: MaterialPreview) => void
|
||||||
|
clearMaterialPreview: () => void
|
||||||
hoverHighlightMode: 'default' | 'delete' | 'paint-ready' | 'paint-disabled'
|
hoverHighlightMode: 'default' | 'delete' | 'paint-ready' | 'paint-disabled'
|
||||||
setHoverHighlightMode: (mode: 'default' | 'delete' | 'paint-ready' | 'paint-disabled') => void
|
setHoverHighlightMode: (mode: 'default' | 'delete' | 'paint-ready' | 'paint-disabled') => void
|
||||||
hoveredId: AnyNode['id'] | ZoneNode['id'] | null
|
hoveredId: AnyNode['id'] | ZoneNode['id'] | null
|
||||||
@@ -84,6 +142,15 @@ const useViewer = create<ViewerState>()(
|
|||||||
selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [] },
|
selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [] },
|
||||||
previewSelectedIds: [],
|
previewSelectedIds: [],
|
||||||
setPreviewSelectedIds: (ids) => set({ previewSelectedIds: ids }),
|
setPreviewSelectedIds: (ids) => set({ previewSelectedIds: ids }),
|
||||||
|
materialPreview: null,
|
||||||
|
setMaterialPreview: (materialPreview) =>
|
||||||
|
set((state) =>
|
||||||
|
isSameMaterialPreview(state.materialPreview, materialPreview)
|
||||||
|
? state
|
||||||
|
: { materialPreview },
|
||||||
|
),
|
||||||
|
clearMaterialPreview: () =>
|
||||||
|
set((state) => (state.materialPreview === null ? state : { materialPreview: null })),
|
||||||
hoverHighlightMode: 'default',
|
hoverHighlightMode: 'default',
|
||||||
setHoverHighlightMode: (mode) =>
|
setHoverHighlightMode: (mode) =>
|
||||||
set((state) => (state.hoverHighlightMode === mode ? state : { hoverHighlightMode: mode })),
|
set((state) => (state.hoverHighlightMode === mode ? state : { hoverHighlightMode: mode })),
|
||||||
@@ -188,6 +255,7 @@ const useViewer = create<ViewerState>()(
|
|||||||
selectedIds: [],
|
selectedIds: [],
|
||||||
},
|
},
|
||||||
previewSelectedIds: [],
|
previewSelectedIds: [],
|
||||||
|
materialPreview: null,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
outliner: { selectedObjects: [], hoveredObjects: [] },
|
outliner: { selectedObjects: [], hoveredObjects: [] },
|
||||||
|
|||||||
@@ -8,6 +8,17 @@ import { createMaterial, createMaterialFromPresetRef } from '../../lib/materials
|
|||||||
|
|
||||||
export type RoofMaterialArray = [THREE.Material, THREE.Material, THREE.Material, THREE.Material]
|
export type RoofMaterialArray = [THREE.Material, THREE.Material, THREE.Material, THREE.Material]
|
||||||
|
|
||||||
|
const roofMaterialArrayCache = new Map<string, RoofMaterialArray>()
|
||||||
|
|
||||||
|
function getSurfaceMaterialSignature(
|
||||||
|
spec: ReturnType<typeof getEffectiveRoofSurfaceMaterial>,
|
||||||
|
): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
material: spec.material ?? null,
|
||||||
|
materialPreset: spec.materialPreset ?? null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function createResolvedMaterial(
|
function createResolvedMaterial(
|
||||||
material: RoofNode['material'] | RoofSegmentNode['material'] | undefined,
|
material: RoofNode['material'] | RoofSegmentNode['material'] | undefined,
|
||||||
materialPreset: string | undefined,
|
materialPreset: string | undefined,
|
||||||
@@ -27,6 +38,14 @@ export function getRoofMaterialArray(node: RoofNode): RoofMaterialArray | null {
|
|||||||
const top = getEffectiveRoofSurfaceMaterial(node, 'top')
|
const top = getEffectiveRoofSurfaceMaterial(node, 'top')
|
||||||
const edge = getEffectiveRoofSurfaceMaterial(node, 'edge')
|
const edge = getEffectiveRoofSurfaceMaterial(node, 'edge')
|
||||||
const wall = getEffectiveRoofSurfaceMaterial(node, 'wall')
|
const wall = getEffectiveRoofSurfaceMaterial(node, 'wall')
|
||||||
|
const cacheKey = JSON.stringify({
|
||||||
|
top: getSurfaceMaterialSignature(top),
|
||||||
|
edge: getSurfaceMaterialSignature(edge),
|
||||||
|
wall: getSurfaceMaterialSignature(wall),
|
||||||
|
})
|
||||||
|
|
||||||
|
const cached = roofMaterialArrayCache.get(cacheKey)
|
||||||
|
if (cached) return cached
|
||||||
|
|
||||||
const topMaterial = createResolvedMaterial(top.material, top.materialPreset)
|
const topMaterial = createResolvedMaterial(top.material, top.materialPreset)
|
||||||
const edgeMaterial = createResolvedMaterial(edge.material, edge.materialPreset)
|
const edgeMaterial = createResolvedMaterial(edge.material, edge.materialPreset)
|
||||||
@@ -36,11 +55,13 @@ export function getRoofMaterialArray(node: RoofNode): RoofMaterialArray | null {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
const materialArray: RoofMaterialArray = [
|
||||||
edgeMaterial ?? wallMaterial ?? topMaterial ?? new THREE.MeshStandardMaterial(),
|
edgeMaterial ?? wallMaterial ?? topMaterial ?? new THREE.MeshStandardMaterial(),
|
||||||
wallMaterial ?? edgeMaterial ?? topMaterial ?? new THREE.MeshStandardMaterial(),
|
wallMaterial ?? edgeMaterial ?? topMaterial ?? new THREE.MeshStandardMaterial(),
|
||||||
wallMaterial ?? edgeMaterial ?? topMaterial ?? new THREE.MeshStandardMaterial(),
|
wallMaterial ?? edgeMaterial ?? topMaterial ?? new THREE.MeshStandardMaterial(),
|
||||||
topMaterial ?? wallMaterial ?? edgeMaterial ?? new THREE.MeshStandardMaterial(),
|
topMaterial ?? wallMaterial ?? edgeMaterial ?? new THREE.MeshStandardMaterial(),
|
||||||
]
|
]
|
||||||
}
|
|
||||||
|
|
||||||
|
roofMaterialArrayCache.set(cacheKey, materialArray)
|
||||||
|
return materialArray
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,18 @@ import {
|
|||||||
|
|
||||||
export type StairBodyMaterials = [THREE.Material, THREE.Material]
|
export type StairBodyMaterials = [THREE.Material, THREE.Material]
|
||||||
|
|
||||||
|
const stairBodyMaterialCache = new Map<string, StairBodyMaterials>()
|
||||||
|
const stairRailingMaterialCache = new Map<string, THREE.Material>()
|
||||||
|
|
||||||
|
function getSurfaceMaterialSignature(
|
||||||
|
spec: ReturnType<typeof getEffectiveStairSurfaceMaterial>,
|
||||||
|
): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
material: spec.material ?? null,
|
||||||
|
materialPreset: spec.materialPreset ?? null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function createResolvedMaterial(
|
function createResolvedMaterial(
|
||||||
material: StairNode['material'] | StairSegmentNode['material'] | undefined,
|
material: StairNode['material'] | StairSegmentNode['material'] | undefined,
|
||||||
materialPreset: string | undefined,
|
materialPreset: string | undefined,
|
||||||
@@ -30,16 +42,32 @@ function createResolvedMaterial(
|
|||||||
export function getStairBodyMaterials(stair: StairNode): StairBodyMaterials {
|
export function getStairBodyMaterials(stair: StairNode): StairBodyMaterials {
|
||||||
const tread = getEffectiveStairSurfaceMaterial(stair, 'tread')
|
const tread = getEffectiveStairSurfaceMaterial(stair, 'tread')
|
||||||
const side = getEffectiveStairSurfaceMaterial(stair, 'side')
|
const side = getEffectiveStairSurfaceMaterial(stair, 'side')
|
||||||
|
const cacheKey = JSON.stringify({
|
||||||
|
tread: getSurfaceMaterialSignature(tread),
|
||||||
|
side: getSurfaceMaterialSignature(side),
|
||||||
|
})
|
||||||
|
|
||||||
return [
|
const cached = stairBodyMaterialCache.get(cacheKey)
|
||||||
|
if (cached) return cached
|
||||||
|
|
||||||
|
const materials: StairBodyMaterials = [
|
||||||
createResolvedMaterial(tread.material, tread.materialPreset),
|
createResolvedMaterial(tread.material, tread.materialPreset),
|
||||||
createResolvedMaterial(side.material, side.materialPreset),
|
createResolvedMaterial(side.material, side.materialPreset),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
stairBodyMaterialCache.set(cacheKey, materials)
|
||||||
|
return materials
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getStairRailingMaterial(stair: StairNode): THREE.Material {
|
export function getStairRailingMaterial(stair: StairNode): THREE.Material {
|
||||||
const railing = getEffectiveStairSurfaceMaterial(stair, 'railing')
|
const railing = getEffectiveStairSurfaceMaterial(stair, 'railing')
|
||||||
return createResolvedMaterial(railing.material, railing.materialPreset)
|
const cacheKey = getSurfaceMaterialSignature(railing)
|
||||||
|
const cached = stairRailingMaterialCache.get(cacheKey)
|
||||||
|
if (cached) return cached
|
||||||
|
|
||||||
|
const material = createResolvedMaterial(railing.material, railing.materialPreset)
|
||||||
|
stairRailingMaterialCache.set(cacheKey, material)
|
||||||
|
return material
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getStraightStairSegmentBodyMaterials(
|
export function getStraightStairSegmentBodyMaterials(
|
||||||
|
|||||||
Reference in New Issue
Block a user