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 mode = useEditor((s) => s.mode)
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 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 [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) {
@@ -717,7 +712,6 @@ function PaintCursorLayer({
const rect = el.getBoundingClientRect() const rect = el.getBoundingClientRect()
nextX = e.clientX - rect.left nextX = e.clientX - rect.left
nextY = e.clientY - rect.top nextY = e.clientY - rect.top
lastPointerPositionRef.current = { x: nextX, y: nextY }
if (frame === 0) { if (frame === 0) {
frame = window.requestAnimationFrame(flushPosition) frame = window.requestAnimationFrame(flushPosition)
@@ -748,57 +742,8 @@ function PaintCursorLayer({
(activePaintMaterial.material !== undefined || (activePaintMaterial.material !== undefined ||
activePaintMaterial.materialPreset !== undefined), activePaintMaterial.materialPreset !== undefined),
) )
const label = !hasMaterial const label = !hasMaterial ? 'Choose material' : `Paint ${activePaintTarget}`
? 'Choose material' const icon = 'mdi:format-color-fill'
: 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])
useLayoutEffect(() => { useLayoutEffect(() => {
if (!active && badgeRef.current) { if (!active && badgeRef.current) {
@@ -815,7 +760,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 || showBlockedState} disabled={!hasMaterial}
icon={icon} icon={icon}
label={label} label={label}
position={{ x: 0, y: 0 }} position={{ x: 0, y: 0 }}
@@ -5,12 +5,14 @@ import {
type CeilingNode, type CeilingNode,
emitter, emitter,
type FenceNode, type FenceNode,
getMaterialPresetByRef,
type ItemNode, type ItemNode,
type NodeEvent, type NodeEvent,
type RoofEvent, type RoofEvent,
type RoofNode, type RoofNode,
type RoofSegmentEvent, type RoofSegmentEvent,
resolveLevelId, resolveLevelId,
resolveMaterial,
type SlabNode, type SlabNode,
type StairEvent, type StairEvent,
type StairNode, type StairNode,
@@ -27,6 +29,18 @@ import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef } from 'react' import { useCallback, useEffect, useRef } from 'react'
import { type BufferGeometry, Color, type Material, type Mesh, type Object3D } from 'three' import { type BufferGeometry, Color, type Material, type Mesh, type Object3D } from 'three'
import { 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, buildRoofSurfaceMaterialPatch,
buildSingleSurfaceMaterialPatch, buildSingleSurfaceMaterialPatch,
buildStairSurfaceMaterialPatch, buildStairSurfaceMaterialPatch,
@@ -70,12 +84,12 @@ type ModifierKeys = {
ctrl: boolean ctrl: boolean
} }
type SceneMaterialPreview = ReturnType<typeof useViewer.getState>['materialPreview'] type PaintPreviewCleanup = () => void
type PaintInteraction = { type PaintInteraction = {
key: string
apply: (() => void) | null apply: (() => void) | null
preview: SceneMaterialPreview preview: (() => PaintPreviewCleanup | null) | null
hoveredId: AnyNodeId
} }
interface SelectionStrategy { interface SelectionStrategy {
@@ -201,6 +215,196 @@ function getIntersectionMaterialIndex(
return group?.materialIndex 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) { function setSelectedMaterialTargetForNode(node: AnyNode, role: MaterialTargetRole | null) {
if (!role) { if (!role) {
const currentTarget = useEditor.getState().selectedMaterialTarget const currentTarget = useEditor.getState().selectedMaterialTarget
@@ -510,32 +714,11 @@ export const SelectionManager = () => {
if (mode !== 'material-paint') return if (mode !== 'material-paint') return
if (movingNode || curvingWall) return if (movingNode || curvingWall) return
const triggerPaintDisabledFeedback = useEditor.getState().triggerPaintDisabledFeedback let activePreview: { key: string; restore: PaintPreviewCleanup } | null = null
let hoverFrame = 0
let pendingHoveredId: AnyNodeId | null = null
let pendingHoverMode: HoverHighlightMode = 'default'
let pendingPreview: SceneMaterialPreview = null
const flushHoverState = () => { const clearActivePreview = () => {
hoverFrame = 0 activePreview?.restore()
const viewerState = useViewer.getState() activePreview = null
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 = () =>
@@ -560,7 +743,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, key: `wall:${node.id}:${role ?? 'unsupported'}`,
apply: apply:
compatible && hasActivePaintMaterial(activePaintMaterial) compatible && hasActivePaintMaterial(activePaintMaterial)
? () => { ? () => {
@@ -579,14 +762,8 @@ export const SelectionManager = () => {
: null, : null,
preview: preview:
compatible && hasActivePaintMaterial(activePaintMaterial) && role compatible && hasActivePaintMaterial(activePaintMaterial) && role
? { ? () => applyWallPaintPreview(node as WallNode, role, activePaintMaterial)
nodeId: node.id as AnyNodeId, : () => previewCursor('not-allowed'),
target: 'wall',
role,
material: activePaintMaterial.material,
materialPreset: activePaintMaterial.materialPreset,
}
: null,
} }
} }
@@ -603,7 +780,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, key: `roof:${roofNode.id}:${role ?? 'unsupported'}`,
apply: apply:
compatible && hasActivePaintMaterial(activePaintMaterial) compatible && hasActivePaintMaterial(activePaintMaterial)
? () => { ? () => {
@@ -622,14 +799,8 @@ export const SelectionManager = () => {
: null, : null,
preview: preview:
compatible && hasActivePaintMaterial(activePaintMaterial) && role compatible && hasActivePaintMaterial(activePaintMaterial) && role
? { ? () => applyRoofPaintPreview(roofNode as RoofNode, role, activePaintMaterial)
nodeId: roofNode.id as AnyNodeId, : () => previewCursor('not-allowed'),
target: 'roof',
role,
material: activePaintMaterial.material,
materialPreset: activePaintMaterial.materialPreset,
}
: null,
} }
} }
@@ -646,7 +817,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, key: `stair:${stairNode.id}:${role ?? 'unsupported'}`,
apply: apply:
compatible && hasActivePaintMaterial(activePaintMaterial) compatible && hasActivePaintMaterial(activePaintMaterial)
? () => { ? () => {
@@ -665,14 +836,8 @@ export const SelectionManager = () => {
: null, : null,
preview: preview:
compatible && hasActivePaintMaterial(activePaintMaterial) && role compatible && hasActivePaintMaterial(activePaintMaterial) && role
? { ? () => applyStairPaintPreview(stairNode as StairNode, role, activePaintMaterial)
nodeId: stairNode.id as AnyNodeId, : () => previewCursor('not-allowed'),
target: 'stair',
role,
material: activePaintMaterial.material,
materialPreset: activePaintMaterial.materialPreset,
}
: null,
} }
} }
@@ -683,7 +848,7 @@ export const SelectionManager = () => {
hasActivePaintMaterial(activePaintMaterial) hasActivePaintMaterial(activePaintMaterial)
return { return {
hoveredId: node.id as AnyNodeId, key: `${target}:${node.id}:surface`,
apply: compatible apply: compatible
? () => { ? () => {
useScene useScene
@@ -698,23 +863,21 @@ export const SelectionManager = () => {
} }
: null, : null,
preview: compatible preview: compatible
? { ? () =>
nodeId: node.id as AnyNodeId, applySingleSurfacePaintPreview(
target, node as FenceNode | SlabNode | CeilingNode,
role: 'surface', activePaintMaterial,
material: activePaintMaterial.material, )
materialPreset: activePaintMaterial.materialPreset, : () => previewCursor('not-allowed'),
}
: 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, key: `${node.type}:${node.id}:unsupported`,
apply: null, apply: null,
preview: null, preview: () => previewCursor('not-allowed'),
} }
} }
@@ -729,28 +892,27 @@ export const SelectionManager = () => {
event.stopPropagation() event.stopPropagation()
if (!interaction.preview) { if (activePreview?.key === interaction.key) {
pendingHoveredId = interaction.hoveredId
pendingHoverMode = 'paint-disabled'
pendingPreview = null
scheduleHoverState()
return return
} }
pendingHoveredId = interaction.hoveredId clearActivePreview()
pendingHoverMode = 'paint-ready'
pendingPreview = interaction.preview const restore = interaction.preview?.()
scheduleHoverState() if (restore) {
activePreview = { key: interaction.key, restore }
}
} }
const onLeave = (event: NodeEvent) => { const onLeave = (event: NodeEvent) => {
const interaction = getPaintInteraction(event) const interaction = getPaintInteraction(event)
if (!interaction) return if (!interaction) return
pendingHoveredId = null if (activePreview?.key !== interaction.key) {
pendingHoverMode = 'default' return
pendingPreview = null }
scheduleHoverState()
clearActivePreview()
} }
const onClick = (event: NodeEvent) => { const onClick = (event: NodeEvent) => {
@@ -761,19 +923,16 @@ export const SelectionManager = () => {
event.stopPropagation() event.stopPropagation()
if (hoverFrame !== 0) {
window.cancelAnimationFrame(hoverFrame)
flushHoverState()
}
if (!interaction.apply) { if (!interaction.apply) {
useViewer.getState().clearMaterialPreview()
triggerPaintDisabledFeedback()
return return
} }
interaction.apply() interaction.apply()
useViewer.getState().clearMaterialPreview() if (activePreview?.key === interaction.key) {
activePreview = null
} else {
clearActivePreview()
}
} }
const allTypes = [ const allTypes = [
@@ -792,25 +951,20 @@ export const SelectionManager = () => {
] as const ] as const
for (const type of allTypes) { 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}:enter` as any, onEnter as any)
emitter.on(`${type}:leave` as any, onLeave as any) emitter.on(`${type}:leave` as any, onLeave as any)
emitter.on(`${type}:click` as any, onClick 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}:enter` as any, onEnter as any) emitter.off(`${type}:enter` as any, onEnter as any)
emitter.off(`${type}:leave` as any, onLeave as any) emitter.off(`${type}:leave` as any, onLeave as any)
emitter.off(`${type}:click` as any, onClick as any)
} }
if (hoverFrame !== 0) { clearActivePreview()
window.cancelAnimationFrame(hoverFrame)
}
useViewer.setState({ hoveredId: null })
setHoverHighlightMode('default')
useViewer.getState().clearMaterialPreview()
} }
}, [curvingWall, mode, movingNode, setHoverHighlightMode]) }, [curvingWall, mode, movingNode])
useEffect(() => { useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
-5
View File
@@ -162,8 +162,6 @@ type EditorState = {
activePaintTarget: PaintableMaterialTarget activePaintTarget: PaintableMaterialTarget
setActivePaintTarget: (target: PaintableMaterialTarget) => void setActivePaintTarget: (target: PaintableMaterialTarget) => void
primeMaterialPaintFromSelection: () => MaterialPaintSelectionSnapshot primeMaterialPaintFromSelection: () => MaterialPaintSelectionSnapshot
paintDisabledFeedbackTick: number
triggerPaintDisabledFeedback: () => void
hoveredPaintTarget: PaintableMaterialTarget | null hoveredPaintTarget: PaintableMaterialTarget | null
setHoveredPaintTarget: (target: PaintableMaterialTarget | null) => void setHoveredPaintTarget: (target: PaintableMaterialTarget | null) => void
selectedReferenceId: string | null selectedReferenceId: string | null
@@ -579,9 +577,6 @@ const useEditor = create<EditorState>()(
activePaintMaterial: activePaintMaterial ?? get().activePaintMaterial, activePaintMaterial: activePaintMaterial ?? get().activePaintMaterial,
} }
}, },
paintDisabledFeedbackTick: 0,
triggerPaintDisabledFeedback: () =>
set((state) => ({ paintDisabledFeedbackTick: state.paintDisabledFeedbackTick + 1 })),
hoveredPaintTarget: null, hoveredPaintTarget: null,
setHoveredPaintTarget: (target) => setHoveredPaintTarget: (target) =>
set((state) => set((state) =>