From fb3de8debc062408242f8df253ddfe413da3ccf1 Mon Sep 17 00:00:00 2001 From: sudhir Date: Wed, 22 Apr 2026 18:16:20 +0530 Subject: [PATCH] Refactor material paint preview handling --- .../editor/src/components/editor/index.tsx | 61 +-- .../components/editor/selection-manager.tsx | 346 +++++++++++++----- packages/editor/src/store/use-editor.tsx | 5 - 3 files changed, 253 insertions(+), 159 deletions(-) diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index 648e9cb8..b61644af 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -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(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 }} > ['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) - } - useViewer.setState({ hoveredId: null }) - setHoverHighlightMode('default') - useViewer.getState().clearMaterialPreview() + clearActivePreview() } - }, [curvingWall, mode, movingNode, setHoverHighlightMode]) + }, [curvingWall, mode, movingNode]) useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index da6a952a..eb030b0a 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -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()( activePaintMaterial: activePaintMaterial ?? get().activePaintMaterial, } }, - paintDisabledFeedbackTick: 0, - triggerPaintDisabledFeedback: () => - set((state) => ({ paintDisabledFeedbackTick: state.paintDisabledFeedbackTick + 1 })), hoveredPaintTarget: null, setHoveredPaintTarget: (target) => set((state) =>