Refactor material paint preview handling

This commit is contained in:
sudhir
2026-04-22 18:16:20 +05:30
parent 87dfdf1b38
commit fb3de8debc
3 changed files with 253 additions and 159 deletions
@@ -684,13 +684,8 @@ function PaintCursorLayer({
const mode = useEditor((s) => s.mode)
const activePaintMaterial = useEditor((s) => s.activePaintMaterial)
const activePaintTarget = useEditor((s) => s.activePaintTarget)
const paintDisabledFeedbackTick = useEditor((s) => s.paintDisabledFeedbackTick)
const hoverHighlightMode = useViewer((s) => s.hoverHighlightMode)
const badgeRef = useRef<HTMLDivElement>(null)
const lastPointerPositionRef = useRef<{ x: number; y: number } | null>(null)
const [showDisabledFeedback, setShowDisabledFeedback] = useState(false)
const active = mode === 'material-paint' && !isVersionPreviewMode
const showBlockedState = showDisabledFeedback || hoverHighlightMode === 'paint-disabled'
useEffect(() => {
if (!active) {
@@ -717,7 +712,6 @@ function PaintCursorLayer({
const rect = el.getBoundingClientRect()
nextX = e.clientX - rect.left
nextY = e.clientY - rect.top
lastPointerPositionRef.current = { x: nextX, y: nextY }
if (frame === 0) {
frame = window.requestAnimationFrame(flushPosition)
@@ -748,57 +742,8 @@ function PaintCursorLayer({
(activePaintMaterial.material !== undefined ||
activePaintMaterial.materialPreset !== undefined),
)
const label = !hasMaterial
? 'Choose material'
: showBlockedState
? 'Unsupported'
: `Paint ${activePaintTarget}`
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(() => {
if (!paintDisabledFeedbackTick) return
const el = containerRef.current
const badge = badgeRef.current
const lastPointerPosition = lastPointerPositionRef.current
setShowDisabledFeedback(true)
if (el) {
el.style.cursor = 'not-allowed'
}
if (badge && lastPointerPosition) {
badge.style.display = 'block'
badge.style.transform = `translate(${lastPointerPosition.x + PAINT_CURSOR_BADGE_OFFSET_X}px, ${lastPointerPosition.y + PAINT_CURSOR_BADGE_OFFSET_Y}px)`
}
const timeoutId = window.setTimeout(() => {
setShowDisabledFeedback(false)
if (containerRef.current) {
containerRef.current.style.cursor = ''
}
}, 320)
return () => {
window.clearTimeout(timeoutId)
if (el) {
el.style.cursor = ''
}
}
}, [containerRef, paintDisabledFeedbackTick])
const label = !hasMaterial ? 'Choose material' : `Paint ${activePaintTarget}`
const icon = 'mdi:format-color-fill'
useLayoutEffect(() => {
if (!active && badgeRef.current) {
@@ -815,7 +760,7 @@ function PaintCursorLayer({
style={{ display: 'none', position: 'absolute', left: 0, top: 0 }}
>
<PaintCursorBadge
disabled={!hasMaterial || showBlockedState}
disabled={!hasMaterial}
icon={icon}
label={label}
position={{ x: 0, y: 0 }}
@@ -5,12 +5,14 @@ import {
type CeilingNode,
emitter,
type FenceNode,
getMaterialPresetByRef,
type ItemNode,
type NodeEvent,
type RoofEvent,
type RoofNode,
type RoofSegmentEvent,
resolveLevelId,
resolveMaterial,
type SlabNode,
type StairEvent,
type StairNode,
@@ -27,6 +29,18 @@ import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef } from 'react'
import { type BufferGeometry, Color, type Material, type Mesh, type Object3D } from 'three'
import {
applyMaterialPresetToMaterials,
createMaterial,
createMaterialFromPresetRef,
} from '../../../../viewer/src/lib/materials'
import { getRoofMaterialArray } from '../../../../viewer/src/systems/roof/roof-materials'
import {
getStairBodyMaterials,
getStairRailingMaterial,
} from '../../../../viewer/src/systems/stair/stair-materials'
import { getVisibleWallMaterials } from '../../../../viewer/src/systems/wall/wall-materials'
import {
type ActivePaintMaterial,
buildRoofSurfaceMaterialPatch,
buildSingleSurfaceMaterialPatch,
buildStairSurfaceMaterialPatch,
@@ -70,12 +84,12 @@ type ModifierKeys = {
ctrl: boolean
}
type SceneMaterialPreview = ReturnType<typeof useViewer.getState>['materialPreview']
type PaintPreviewCleanup = () => void
type PaintInteraction = {
key: string
apply: (() => void) | null
preview: SceneMaterialPreview
hoveredId: AnyNodeId
preview: (() => PaintPreviewCleanup | null) | null
}
interface SelectionStrategy {
@@ -201,6 +215,196 @@ function getIntersectionMaterialIndex(
return group?.materialIndex
}
function getRegisteredNodeObject(nodeId: string): Object3D | null {
return sceneRegistry.nodes.get(nodeId) ?? null
}
function getRegisteredMesh(nodeId: string): Mesh | null {
const object = getRegisteredNodeObject(nodeId)
return object && (object as Mesh).isMesh ? (object as Mesh) : null
}
function previewMeshMaterial(mesh: Mesh, material: Material | Material[]): PaintPreviewCleanup {
const previousMaterial = mesh.material
mesh.material = material
return () => {
mesh.material = previousMaterial
}
}
function previewCursor(cursor: string): PaintPreviewCleanup {
const previousCursor = document.body.style.cursor
document.body.style.cursor = cursor
return () => {
document.body.style.cursor = previousCursor
}
}
function getSingleSurfacePreviewMaterial(material: ActivePaintMaterial): Material | null {
if (material.materialPreset) {
return createMaterialFromPresetRef(material.materialPreset)
}
if (material.material) {
return createMaterial(material.material)
}
return null
}
function applyWallPaintPreview(
node: WallNode,
role: WallSurfaceSide,
material: ActivePaintMaterial,
): PaintPreviewCleanup | null {
const mesh = getRegisteredMesh(node.id)
if (!mesh) return null
const previewNode = {
...node,
...buildWallSurfaceMaterialPatch(node, role, material.material, material.materialPreset),
}
return previewMeshMaterial(mesh, getVisibleWallMaterials(previewNode))
}
function applyRoofPaintPreview(
node: RoofNode,
role: 'top' | 'edge' | 'wall',
material: ActivePaintMaterial,
): PaintPreviewCleanup | null {
const root = getRegisteredNodeObject(node.id)
const mesh = root?.getObjectByName('merged-roof') as Mesh | undefined
if (!mesh) return null
const previewNode = {
...node,
...buildRoofSurfaceMaterialPatch(node, role, material.material, material.materialPreset),
}
const previewMaterial = getRoofMaterialArray(previewNode)
if (!previewMaterial) return null
return previewMeshMaterial(mesh, previewMaterial)
}
function applyStairPaintPreview(
node: StairNode,
role: StairSurfaceMaterialRole,
material: ActivePaintMaterial,
): PaintPreviewCleanup | null {
const root = getRegisteredNodeObject(node.id)
if (!root) return null
const previewNode = {
...node,
...buildStairSurfaceMaterialPatch(node, role, material.material, material.materialPreset),
}
const bodyMaterials = getStairBodyMaterials(previewNode)
const railingMaterial = getStairRailingMaterial(previewNode)
const restores: PaintPreviewCleanup[] = []
root.traverse((object) => {
if (!(object as Mesh).isMesh) return
const mesh = object as Mesh
if (mesh.name.startsWith('stair-railing')) {
restores.push(previewMeshMaterial(mesh, railingMaterial))
return
}
if (Array.isArray(mesh.material) && mesh.material.length === 2) {
restores.push(previewMeshMaterial(mesh, bodyMaterials))
return
}
if (mesh.name === 'merged-stair') {
restores.push(previewMeshMaterial(mesh, bodyMaterials))
return
}
if (mesh.name.startsWith('stair-side')) {
restores.push(previewMeshMaterial(mesh, bodyMaterials[1]))
}
})
if (restores.length === 0) return null
return () => {
for (let index = restores.length - 1; index >= 0; index -= 1) {
restores[index]?.()
}
}
}
function applySingleSurfacePaintPreview(
node: FenceNode | SlabNode | CeilingNode,
material: ActivePaintMaterial,
): PaintPreviewCleanup | null {
if (node.type === 'ceiling') {
const root = getRegisteredMesh(node.id)
const overlay = root?.getObjectByName('ceiling-grid') as Mesh | undefined
if (!root || !overlay) return null
const previewColor =
getMaterialPresetByRef(material.materialPreset)?.mapProperties.color ??
resolveMaterial(material.material).color ??
'#999999'
const previousRootMaterial = root.material
const previousOverlayMaterial = overlay.material
const rootPreviewMaterial = Array.isArray(previousRootMaterial)
? previousRootMaterial.map((entry) => entry.clone())
: previousRootMaterial.clone()
const overlayPreviewMaterial = Array.isArray(previousOverlayMaterial)
? previousOverlayMaterial.map((entry) => entry.clone())
: previousOverlayMaterial.clone()
const applyColor = (input: Material | Material[]) => {
const materials = Array.isArray(input) ? input : [input]
for (const entry of materials) {
const materialWithColor = entry as Material & { color?: Color; needsUpdate?: boolean }
if (materialWithColor.color instanceof Color) {
materialWithColor.color = new Color(previewColor)
}
materialWithColor.needsUpdate = true
}
}
applyColor(rootPreviewMaterial)
applyColor(overlayPreviewMaterial)
root.material = rootPreviewMaterial
overlay.material = overlayPreviewMaterial
return () => {
root.material = previousRootMaterial
overlay.material = previousOverlayMaterial
}
}
const mesh = getRegisteredMesh(node.id)
if (!mesh) return null
const previewMaterial = getSingleSurfacePreviewMaterial(material)
if (!previewMaterial) return null
if (node.type === 'slab') {
const slabMaterial = previewMaterial.clone()
applyMaterialPresetToMaterials(slabMaterial, getMaterialPresetByRef(material.materialPreset))
const previewMeshMaterialInput = slabMaterial as Material & {
alphaMap?: unknown
depthWrite?: boolean
needsUpdate?: boolean
opacity?: number
side?: number
transparent?: boolean
}
previewMeshMaterialInput.transparent = false
previewMeshMaterialInput.opacity = 1
previewMeshMaterialInput.alphaMap = null
previewMeshMaterialInput.depthWrite = true
previewMeshMaterialInput.needsUpdate = true
return previewMeshMaterial(mesh, slabMaterial)
}
return previewMeshMaterial(mesh, previewMaterial)
}
function setSelectedMaterialTargetForNode(node: AnyNode, role: MaterialTargetRole | null) {
if (!role) {
const currentTarget = useEditor.getState().selectedMaterialTarget
@@ -510,32 +714,11 @@ export const SelectionManager = () => {
if (mode !== 'material-paint') return
if (movingNode || curvingWall) return
const triggerPaintDisabledFeedback = useEditor.getState().triggerPaintDisabledFeedback
let hoverFrame = 0
let pendingHoveredId: AnyNodeId | null = null
let pendingHoverMode: HoverHighlightMode = 'default'
let pendingPreview: SceneMaterialPreview = null
let activePreview: { key: string; restore: PaintPreviewCleanup } | null = 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 clearActivePreview = () => {
activePreview?.restore()
activePreview = null
}
const resolveActivePaintMaterial = () =>
@@ -560,7 +743,7 @@ export const SelectionManager = () => {
const compatible =
role !== null && isActivePaintMaterialCompatible(activePaintMaterial, 'wall')
return {
hoveredId: node.id as AnyNodeId,
key: `wall:${node.id}:${role ?? 'unsupported'}`,
apply:
compatible && hasActivePaintMaterial(activePaintMaterial)
? () => {
@@ -579,14 +762,8 @@ export const SelectionManager = () => {
: null,
preview:
compatible && hasActivePaintMaterial(activePaintMaterial) && role
? {
nodeId: node.id as AnyNodeId,
target: 'wall',
role,
material: activePaintMaterial.material,
materialPreset: activePaintMaterial.materialPreset,
}
: null,
? () => applyWallPaintPreview(node as WallNode, role, activePaintMaterial)
: () => previewCursor('not-allowed'),
}
}
@@ -603,7 +780,7 @@ export const SelectionManager = () => {
const compatible =
role !== null && isActivePaintMaterialCompatible(activePaintMaterial, 'roof')
return {
hoveredId: roofNode.id as AnyNodeId,
key: `roof:${roofNode.id}:${role ?? 'unsupported'}`,
apply:
compatible && hasActivePaintMaterial(activePaintMaterial)
? () => {
@@ -622,14 +799,8 @@ export const SelectionManager = () => {
: null,
preview:
compatible && hasActivePaintMaterial(activePaintMaterial) && role
? {
nodeId: roofNode.id as AnyNodeId,
target: 'roof',
role,
material: activePaintMaterial.material,
materialPreset: activePaintMaterial.materialPreset,
}
: null,
? () => applyRoofPaintPreview(roofNode as RoofNode, role, activePaintMaterial)
: () => previewCursor('not-allowed'),
}
}
@@ -646,7 +817,7 @@ export const SelectionManager = () => {
const compatible =
role !== null && isActivePaintMaterialCompatible(activePaintMaterial, 'stair')
return {
hoveredId: stairNode.id as AnyNodeId,
key: `stair:${stairNode.id}:${role ?? 'unsupported'}`,
apply:
compatible && hasActivePaintMaterial(activePaintMaterial)
? () => {
@@ -665,14 +836,8 @@ export const SelectionManager = () => {
: null,
preview:
compatible && hasActivePaintMaterial(activePaintMaterial) && role
? {
nodeId: stairNode.id as AnyNodeId,
target: 'stair',
role,
material: activePaintMaterial.material,
materialPreset: activePaintMaterial.materialPreset,
}
: null,
? () => applyStairPaintPreview(stairNode as StairNode, role, activePaintMaterial)
: () => previewCursor('not-allowed'),
}
}
@@ -683,7 +848,7 @@ export const SelectionManager = () => {
hasActivePaintMaterial(activePaintMaterial)
return {
hoveredId: node.id as AnyNodeId,
key: `${target}:${node.id}:surface`,
apply: compatible
? () => {
useScene
@@ -698,23 +863,21 @@ export const SelectionManager = () => {
}
: null,
preview: compatible
? {
nodeId: node.id as AnyNodeId,
target,
role: 'surface',
material: activePaintMaterial.material,
materialPreset: activePaintMaterial.materialPreset,
}
: null,
? () =>
applySingleSurfacePaintPreview(
node as FenceNode | SlabNode | CeilingNode,
activePaintMaterial,
)
: () => previewCursor('not-allowed'),
}
}
const disabledNodeTypes = ['item', 'window', 'door', 'zone']
if (disabledNodeTypes.includes(node.type)) {
return {
hoveredId: node.id as AnyNodeId,
key: `${node.type}:${node.id}:unsupported`,
apply: null,
preview: null,
preview: () => previewCursor('not-allowed'),
}
}
@@ -729,28 +892,27 @@ export const SelectionManager = () => {
event.stopPropagation()
if (!interaction.preview) {
pendingHoveredId = interaction.hoveredId
pendingHoverMode = 'paint-disabled'
pendingPreview = null
scheduleHoverState()
if (activePreview?.key === interaction.key) {
return
}
pendingHoveredId = interaction.hoveredId
pendingHoverMode = 'paint-ready'
pendingPreview = interaction.preview
scheduleHoverState()
clearActivePreview()
const restore = interaction.preview?.()
if (restore) {
activePreview = { key: interaction.key, restore }
}
}
const onLeave = (event: NodeEvent) => {
const interaction = getPaintInteraction(event)
if (!interaction) return
pendingHoveredId = null
pendingHoverMode = 'default'
pendingPreview = null
scheduleHoverState()
if (activePreview?.key !== interaction.key) {
return
}
clearActivePreview()
}
const onClick = (event: NodeEvent) => {
@@ -761,19 +923,16 @@ export const SelectionManager = () => {
event.stopPropagation()
if (hoverFrame !== 0) {
window.cancelAnimationFrame(hoverFrame)
flushHoverState()
}
if (!interaction.apply) {
useViewer.getState().clearMaterialPreview()
triggerPaintDisabledFeedback()
return
}
interaction.apply()
useViewer.getState().clearMaterialPreview()
if (activePreview?.key === interaction.key) {
activePreview = null
} else {
clearActivePreview()
}
}
const allTypes = [
@@ -792,25 +951,20 @@ export const SelectionManager = () => {
] as const
for (const type of allTypes) {
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)
emitter.on(`${type}:click` as any, onClick as any)
}
return () => {
for (const type of allTypes) {
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)
emitter.off(`${type}:click` as any, onClick as any)
}
if (hoverFrame !== 0) {
window.cancelAnimationFrame(hoverFrame)
clearActivePreview()
}
useViewer.setState({ hoveredId: null })
setHoverHighlightMode('default')
useViewer.getState().clearMaterialPreview()
}
}, [curvingWall, mode, movingNode, setHoverHighlightMode])
}, [curvingWall, mode, movingNode])
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
-5
View File
@@ -162,8 +162,6 @@ type EditorState = {
activePaintTarget: PaintableMaterialTarget
setActivePaintTarget: (target: PaintableMaterialTarget) => void
primeMaterialPaintFromSelection: () => MaterialPaintSelectionSnapshot
paintDisabledFeedbackTick: number
triggerPaintDisabledFeedback: () => void
hoveredPaintTarget: PaintableMaterialTarget | null
setHoveredPaintTarget: (target: PaintableMaterialTarget | null) => void
selectedReferenceId: string | null
@@ -579,9 +577,6 @@ const useEditor = create<EditorState>()(
activePaintMaterial: activePaintMaterial ?? get().activePaintMaterial,
}
},
paintDisabledFeedbackTick: 0,
triggerPaintDisabledFeedback: () =>
set((state) => ({ paintDisabledFeedbackTick: state.paintDisabledFeedbackTick + 1 })),
hoveredPaintTarget: null,
setHoveredPaintTarget: (target) =>
set((state) =>