Add paint hover previews to viewer

This commit is contained in:
sudhir
2026-04-22 10:52:35 +05:30
parent 65399731f7
commit 87dfdf1b38
15 changed files with 638 additions and 156 deletions
@@ -685,10 +685,12 @@ function PaintCursorLayer({
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) {
@@ -748,10 +750,26 @@ function PaintCursorLayer({
)
const label = !hasMaterial
? 'Choose material'
: showDisabledFeedback
: showBlockedState
? 'Unsupported'
: `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(() => {
if (!paintDisabledFeedbackTick) return
@@ -797,7 +815,7 @@ function PaintCursorLayer({
style={{ display: 'none', position: 'absolute', left: 0, top: 0 }}
>
<PaintCursorBadge
disabled={!hasMaterial || showDisabledFeedback}
disabled={!hasMaterial || showBlockedState}
icon={icon}
label={label}
position={{ x: 0, y: 0 }}
@@ -70,6 +70,14 @@ type ModifierKeys = {
ctrl: boolean
}
type SceneMaterialPreview = ReturnType<typeof useViewer.getState>['materialPreview']
type PaintInteraction = {
apply: (() => void) | null
preview: SceneMaterialPreview
hoveredId: AnyNodeId
}
interface SelectionStrategy {
types: SelectableNodeType[]
handleSelect: (node: AnyNode, nativeEvent?: any, modifierKeys?: ModifierKeys) => void
@@ -503,6 +511,32 @@ export const SelectionManager = () => {
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
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 = () =>
useEditor.getState().activePaintMaterial ??
@@ -515,11 +549,7 @@ export const SelectionManager = () => {
selectedMaterialTarget: useEditor.getState().selectedMaterialTarget,
})
const getPaintInteraction = (
event: NodeEvent,
): {
apply: (() => void) | null
} | null => {
const getPaintInteraction = (event: NodeEvent): PaintInteraction | null => {
const activePaintMaterial = resolveActivePaintMaterial()
const node = event.node
@@ -530,6 +560,7 @@ export const SelectionManager = () => {
const compatible =
role !== null && isActivePaintMaterialCompatible(activePaintMaterial, 'wall')
return {
hoveredId: node.id as AnyNodeId,
apply:
compatible && hasActivePaintMaterial(activePaintMaterial)
? () => {
@@ -546,6 +577,16 @@ export const SelectionManager = () => {
)
}
: 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 =
role !== null && isActivePaintMaterialCompatible(activePaintMaterial, 'roof')
return {
hoveredId: roofNode.id as AnyNodeId,
apply:
compatible && hasActivePaintMaterial(activePaintMaterial)
? () => {
@@ -578,6 +620,16 @@ export const SelectionManager = () => {
)
}
: 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 =
role !== null && isActivePaintMaterialCompatible(activePaintMaterial, 'stair')
return {
hoveredId: stairNode.id as AnyNodeId,
apply:
compatible && hasActivePaintMaterial(activePaintMaterial)
? () => {
@@ -610,6 +663,16 @@ export const SelectionManager = () => {
)
}
: 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)
return {
hoveredId: node.id as AnyNodeId,
apply: compatible
? () => {
useScene
@@ -633,19 +697,62 @@ export const SelectionManager = () => {
)
}
: null,
preview: compatible
? {
nodeId: node.id as AnyNodeId,
target,
role: 'surface',
material: activePaintMaterial.material,
materialPreset: activePaintMaterial.materialPreset,
}
: null,
}
}
const disabledNodeTypes = ['item', 'window', 'door', 'zone']
if (disabledNodeTypes.includes(node.type)) {
return {
hoveredId: node.id as AnyNodeId,
apply: null,
preview: 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) => {
if (boxSelectHandled) return
@@ -654,12 +761,19 @@ 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()
}
const allTypes = [
@@ -679,14 +793,24 @@ export const SelectionManager = () => {
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)
}
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)
}
if (hoverFrame !== 0) {
window.cancelAnimationFrame(hoverFrame)
}
useViewer.setState({ hoveredId: null })
setHoverHighlightMode('default')
useViewer.getState().clearMaterialPreview()
}
}, [curvingWall, mode, movingNode])
}, [curvingWall, mode, movingNode, setHoverHighlightMode])
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
@@ -1241,7 +1365,8 @@ const SelectionMaterialSync = () => {
}, [hoverHighlightMode, hoveredId, previewSelectedIds, selectedIds, syncSelectionMaterials])
useEffect(() => {
return useScene.subscribe(() => {
return useScene.subscribe((state, prevState) => {
if (state.nodes === prevState.nodes) return
syncSelectionMaterials()
})
}, [syncSelectionMaterials])