diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index 4dbbda1a..20e5669e 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -8,7 +8,15 @@ import { useScene, } from '@pascal-app/core' import { InteractiveSystem, useViewer, Viewer } from '@pascal-app/viewer' -import { memo, type ReactNode, useCallback, useEffect, useRef, useState } from 'react' +import { + memo, + type ReactNode, + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react' import { ViewerOverlay } from '../../components/viewer-overlay' import { ViewerZoneSystem } from '../../components/viewer-zone-system' import { type PresetsAdapter, PresetsProvider } from '../../contexts/presets-context' @@ -22,8 +30,8 @@ import { } from '../../lib/scene' import { initSFXBus } from '../../lib/sfx-bus' import useEditor from '../../store/use-editor' -import { CeilingSystem } from '../systems/ceiling/ceiling-system' import { CeilingSelectionAffordanceSystem } from '../systems/ceiling/ceiling-selection-affordance-system' +import { CeilingSystem } from '../systems/ceiling/ceiling-system' import { RoofEditSystem } from '../systems/roof/roof-edit-system' import { StairEditSystem } from '../systems/stair/stair-edit-system' import { ZoneLabelEditorSystem } from '../systems/zone/zone-label-editor-system' @@ -63,6 +71,10 @@ const CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY = 'editor-camera-controls-hint- const DELETE_CURSOR_BADGE_COLOR = '#ef4444' const DELETE_CURSOR_BADGE_OFFSET_X = 14 const DELETE_CURSOR_BADGE_OFFSET_Y = 14 +const PAINT_CURSOR_BADGE_COLOR = '#f59e0b' +const PAINT_CURSOR_BADGE_DISABLED_COLOR = '#94a3b8' +const PAINT_CURSOR_BADGE_OFFSET_X = 14 +const PAINT_CURSOR_BADGE_OFFSET_Y = 14 /** * Wire up module-level singletons (spatial grid, space detection, SFX) for @@ -501,6 +513,50 @@ function DeleteCursorBadge({ position }: { position: { x: number; y: number } }) ) } +function PaintCursorBadge({ + position, + label, + disabled, + icon, +}: { + position: { x: number; y: number } + label: string + disabled: boolean + icon: string +}) { + const accentColor = disabled ? PAINT_CURSOR_BADGE_DISABLED_COLOR : PAINT_CURSOR_BADGE_COLOR + + return ( + + ) +} + // ── Viewer scene content: memoized so doesn't re-render on mode/viewMode changes ── const ViewerSceneContent = memo(function ViewerSceneContent({ @@ -552,31 +608,194 @@ function DeleteCursorLayer({ isVersionPreviewMode: boolean }) { const mode = useEditor((s) => s.mode) - const [position, setPosition] = useState<{ x: number; y: number } | null>(null) + const badgeRef = useRef(null) const active = mode === 'delete' && !isVersionPreviewMode useEffect(() => { if (!active) { - setPosition(null) + if (badgeRef.current) { + badgeRef.current.style.display = 'none' + } return } const el = containerRef.current if (!el) return + let frame = 0 + let nextX = 0 + let nextY = 0 + const badge = badgeRef.current + + const flushPosition = () => { + frame = 0 + if (!badge) return + badge.style.display = 'block' + badge.style.transform = `translate(${nextX + DELETE_CURSOR_BADGE_OFFSET_X}px, ${nextY + DELETE_CURSOR_BADGE_OFFSET_Y}px)` + } + const onMove = (e: PointerEvent) => { const rect = el.getBoundingClientRect() - setPosition({ x: e.clientX - rect.left, y: e.clientY - rect.top }) + nextX = e.clientX - rect.left + nextY = e.clientY - rect.top + + if (frame === 0) { + frame = window.requestAnimationFrame(flushPosition) + } + } + const onLeave = () => { + if (frame !== 0) { + window.cancelAnimationFrame(frame) + frame = 0 + } + if (badge) { + badge.style.display = 'none' + } } - const onLeave = () => setPosition(null) el.addEventListener('pointermove', onMove) el.addEventListener('pointerleave', onLeave) return () => { + if (frame !== 0) { + window.cancelAnimationFrame(frame) + } el.removeEventListener('pointermove', onMove) el.removeEventListener('pointerleave', onLeave) } }, [active, containerRef]) - if (!(active && position)) return null - return + if (!active) return null + + return ( +
+ +
+ ) +} + +function PaintCursorLayer({ + containerRef, + isVersionPreviewMode, +}: { + containerRef: React.RefObject + isVersionPreviewMode: boolean +}) { + 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 badgeRef = useRef(null) + const [showDisabledFeedback, setShowDisabledFeedback] = useState(false) + const active = mode === 'material-paint' && !isVersionPreviewMode + + useEffect(() => { + if (!active) { + if (badgeRef.current) { + badgeRef.current.style.display = 'none' + } + return + } + const el = containerRef.current + if (!el) return + let frame = 0 + let nextX = 0 + let nextY = 0 + const badge = badgeRef.current + + const flushPosition = () => { + frame = 0 + if (!badge) return + badge.style.display = 'block' + badge.style.transform = `translate(${nextX + PAINT_CURSOR_BADGE_OFFSET_X}px, ${nextY + PAINT_CURSOR_BADGE_OFFSET_Y}px)` + } + + const onMove = (e: PointerEvent) => { + const rect = el.getBoundingClientRect() + nextX = e.clientX - rect.left + nextY = e.clientY - rect.top + + if (frame === 0) { + frame = window.requestAnimationFrame(flushPosition) + } + } + const onLeave = () => { + if (frame !== 0) { + window.cancelAnimationFrame(frame) + frame = 0 + } + if (badge) { + badge.style.display = 'none' + } + } + el.addEventListener('pointermove', onMove) + el.addEventListener('pointerleave', onLeave) + return () => { + if (frame !== 0) { + window.cancelAnimationFrame(frame) + } + el.removeEventListener('pointermove', onMove) + el.removeEventListener('pointerleave', onLeave) + } + }, [active, containerRef]) + + const hasMaterial = Boolean( + activePaintMaterial && + (activePaintMaterial.material !== undefined || + activePaintMaterial.materialPreset !== undefined), + ) + const label = !hasMaterial + ? 'Choose material' + : showDisabledFeedback + ? 'Unsupported' + : `Paint ${activePaintTarget}` + const icon = showDisabledFeedback ? 'mdi:block-helper' : 'mdi:format-color-fill' + + useEffect(() => { + if (!paintDisabledFeedbackTick) return + + const el = containerRef.current + setShowDisabledFeedback(true) + if (el) { + el.style.cursor = 'not-allowed' + } + 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(() => { + if (!active && badgeRef.current) { + badgeRef.current.style.display = 'none' + } + }, [active]) + + if (!active) return null + + return ( +
+ +
+ ) } // ── Viewer canvas: memoized, subscribes to viewMode/floorplanPaneRatio internally ── @@ -684,6 +903,10 @@ const ViewerCanvas = memo(function ViewerCanvas({ containerRef={viewer3dRef} isVersionPreviewMode={isVersionPreviewMode} /> + {!showLoader && isCameraControlsHintVisible && !isFirstPersonMode ? ( { @@ -175,10 +189,7 @@ function getIntersectionMaterialIndex( return group?.materialIndex } -function setSelectedMaterialTargetForNode( - node: AnyNode, - role: MaterialTargetRole | null, -) { +function setSelectedMaterialTargetForNode(node: AnyNode, role: MaterialTargetRole | null) { if (!role) { const currentTarget = useEditor.getState().selectedMaterialTarget if (currentTarget?.nodeId !== node.id) { @@ -209,6 +220,7 @@ const HIGHLIGHT_PROFILES = { } as const type HighlightKind = keyof typeof HIGHLIGHT_PROFILES +type HoverHighlightMode = 'default' | 'delete' | 'paint-ready' | 'paint-disabled' type HighlightableMaterial = Material & { color?: Color @@ -474,13 +486,181 @@ export const SelectionManager = () => { const curvingWall = useEditor((s) => s.curvingWall) useEffect(() => { - setHoverHighlightMode(mode === 'delete' ? 'delete' : 'default') + const nextHoverMode: HoverHighlightMode = mode === 'delete' ? 'delete' : 'default' + setHoverHighlightMode(nextHoverMode) return () => { setHoverHighlightMode('default') } }, [mode, setHoverHighlightMode]) + useEffect(() => { + if (mode !== 'material-paint') return + if (movingNode || curvingWall) return + + const triggerPaintDisabledFeedback = useEditor.getState().triggerPaintDisabledFeedback + + const resolveActivePaintMaterial = () => + useEditor.getState().activePaintMaterial ?? + resolveActivePaintMaterialFromSelection({ + nodes: useScene.getState().nodes, + selectedId: + useViewer.getState().selection.selectedIds.length === 1 + ? (useViewer.getState().selection.selectedIds[0] ?? null) + : null, + selectedMaterialTarget: useEditor.getState().selectedMaterialTarget, + }) + + const getPaintInteraction = ( + event: NodeEvent, + ): { + apply: (() => void) | null + } | null => { + const activePaintMaterial = resolveActivePaintMaterial() + const node = event.node + + if (!isNodeInCurrentLevel(node)) return null + + if (node.type === 'wall') { + const role = resolveWallMaterialTarget(event as WallEvent) + const compatible = + role !== null && isActivePaintMaterialCompatible(activePaintMaterial, 'wall') + return { + apply: + compatible && hasActivePaintMaterial(activePaintMaterial) + ? () => { + useScene + .getState() + .updateNode( + node.id as AnyNodeId, + buildWallSurfaceMaterialPatch( + node as WallNode, + role!, + activePaintMaterial.material, + activePaintMaterial.materialPreset, + ), + ) + } + : null, + } + } + + if (node.type === 'roof' || node.type === 'roof-segment') { + const roofNode = + node.type === 'roof' + ? node + : node.parentId + ? useScene.getState().nodes[node.parentId as AnyNodeId] + : null + if (!roofNode || roofNode.type !== 'roof') return null + + const role = resolveRoofMaterialTarget(event as RoofEvent | RoofSegmentEvent) + const compatible = + role !== null && isActivePaintMaterialCompatible(activePaintMaterial, 'roof') + return { + apply: + compatible && hasActivePaintMaterial(activePaintMaterial) + ? () => { + useScene + .getState() + .updateNode( + roofNode.id as AnyNodeId, + buildRoofSurfaceMaterialPatch( + roofNode as RoofNode, + role!, + activePaintMaterial.material, + activePaintMaterial.materialPreset, + ), + ) + } + : null, + } + } + + if (node.type === 'stair' || node.type === 'stair-segment') { + const stairNode = + node.type === 'stair' + ? node + : node.parentId + ? useScene.getState().nodes[node.parentId as AnyNodeId] + : null + if (!stairNode || stairNode.type !== 'stair') return null + + const role = resolveStairMaterialTarget(event as StairEvent | StairSegmentEvent) + const compatible = + role !== null && isActivePaintMaterialCompatible(activePaintMaterial, 'stair') + return { + apply: + compatible && hasActivePaintMaterial(activePaintMaterial) + ? () => { + useScene + .getState() + .updateNode( + stairNode.id as AnyNodeId, + buildStairSurfaceMaterialPatch( + stairNode as StairNode, + role!, + activePaintMaterial.material, + activePaintMaterial.materialPreset, + ), + ) + } + : null, + } + } + + const disabledNodeTypes = ['fence', 'item', 'slab', 'ceiling', 'window', 'door', 'zone'] + if (disabledNodeTypes.includes(node.type)) { + return { + apply: null, + } + } + + return null + } + + const onClick = (event: NodeEvent) => { + if (boxSelectHandled) return + + const interaction = getPaintInteraction(event) + if (!interaction) return + + event.stopPropagation() + + if (!interaction.apply) { + triggerPaintDisabledFeedback() + return + } + + interaction.apply() + } + + const allTypes = [ + 'wall', + 'fence', + 'item', + 'slab', + 'ceiling', + 'roof', + 'roof-segment', + 'stair', + 'stair-segment', + 'window', + 'door', + 'zone', + ] as const + + for (const type of allTypes) { + emitter.on(`${type}:click` as any, onClick as any) + } + + return () => { + for (const type of allTypes) { + emitter.off(`${type}:click` as any, onClick as any) + } + } + }, [curvingWall, mode, movingNode]) + useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { if (event.key === 'Meta') modifierKeysRef.current.meta = true @@ -911,7 +1091,9 @@ const SelectionStateSync = () => { const selectedNode = useScene.getState().nodes[singleSelectedId as AnyNodeId] if ( !selectedNode || - (selectedNode.type !== 'wall' && selectedNode.type !== 'stair' && selectedNode.type !== 'roof') + (selectedNode.type !== 'wall' && + selectedNode.type !== 'stair' && + selectedNode.type !== 'roof') ) { setSelectedMaterialTarget(null) return diff --git a/packages/editor/src/components/ui/action-menu/control-modes.tsx b/packages/editor/src/components/ui/action-menu/control-modes.tsx index e704ab8f..f85c5e34 100755 --- a/packages/editor/src/components/ui/action-menu/control-modes.tsx +++ b/packages/editor/src/components/ui/action-menu/control-modes.tsx @@ -9,7 +9,15 @@ import { cn } from './../../../lib/utils' import useEditor from './../../../store/use-editor' import { ActionButton } from './action-button' -type ControlId = 'select' | 'box-select' | 'site-edit' | 'build' | 'furnish' | 'zone' | 'delete' +type ControlId = + | 'select' + | 'box-select' + | 'site-edit' + | 'build' + | 'material-paint' + | 'furnish' + | 'zone' + | 'delete' type ControlConfig = { id: ControlId @@ -54,6 +62,14 @@ const controls: ControlConfig[] = [ color: 'hover:bg-green-500/20 hover:text-green-400', activeColor: 'bg-green-500/20 text-green-400', }, + { + id: 'material-paint', + iconifyIcon: 'mdi:format-color-fill', + label: 'Material Paint', + shortcut: 'P', + color: 'hover:bg-amber-500/20 hover:text-amber-400', + activeColor: 'bg-amber-500/20 text-amber-400', + }, { id: 'furnish', imageSrc: '/icons/couch.png', @@ -88,6 +104,7 @@ export function ControlModes() { const setPhase = useEditor((state) => state.setPhase) const setStructureLayer = useEditor((state) => state.setStructureLayer) const setSelectionTool = useEditor((state) => state.setFloorplanSelectionTool) + const primeMaterialPaintFromSelection = useEditor((state) => state.primeMaterialPaintFromSelection) const levelId = useViewer((s) => s.selection.levelId) // Only subscribe to the primitive `level` number — when walls are added to @@ -112,6 +129,7 @@ export function ControlModes() { if (id === 'site-edit') return false if (id === 'build') return mode === 'build' && phase === 'structure' && structureLayer === 'elements' + if (id === 'material-paint') return mode === 'material-paint' if (id === 'furnish') return mode === 'build' && phase === 'furnish' if (id === 'zone') return mode === 'build' && phase === 'structure' && structureLayer === 'zones' @@ -155,6 +173,15 @@ export function ControlModes() { setStructureLayer('elements') setMode('build') } + } else if (id === 'material-paint') { + if (getIsActive('material-paint')) { + setMode('select') + } else { + primeMaterialPaintFromSelection() + setPhase('structure') + setStructureLayer('elements') + setMode('material-paint') + } } else if (id === 'furnish') { if (getIsActive('furnish')) { setMode('select') diff --git a/packages/editor/src/components/ui/action-menu/index.tsx b/packages/editor/src/components/ui/action-menu/index.tsx index 1ef5e794..5eb7bf49 100644 --- a/packages/editor/src/components/ui/action-menu/index.tsx +++ b/packages/editor/src/components/ui/action-menu/index.tsx @@ -1,8 +1,16 @@ 'use client' +import { getMaterialsForTarget, toLibraryMaterialRef, useScene } from '@pascal-app/core' import { AnimatePresence, motion } from 'motion/react' +import { useEffect, useMemo } from 'react' +import { useViewer } from '@pascal-app/viewer' import { TooltipProvider } from './../../../components/ui/primitives/tooltip' +import { MaterialPicker } from './../../../components/ui/controls/material-picker' import { useReducedMotion } from './../../../hooks/use-reduced-motion' +import { + isActivePaintMaterialCompatible, + resolvePaintTargetFromSelection, +} from './../../../lib/material-paint' import { cn } from './../../../lib/utils' import useEditor from './../../../store/use-editor' import { ItemCatalog } from '../item-catalog/item-catalog' @@ -12,12 +20,77 @@ import { FurnishTools } from './furnish-tools' import { StructureTools } from './structure-tools' import { ViewToggles } from './view-toggles' +function PaintMaterialTray() { + const activePaintMaterial = useEditor((state) => state.activePaintMaterial) + const activePaintTarget = useEditor((state) => state.activePaintTarget) + const setActivePaintMaterial = useEditor((state) => state.setActivePaintMaterial) + const setActivePaintTarget = useEditor((state) => state.setActivePaintTarget) + const selectedIds = useViewer((state) => state.selection.selectedIds) + const nodes = useScene((state) => state.nodes) + const selectedId = selectedIds.length === 1 ? (selectedIds[0] ?? null) : null + + useEffect(() => { + const selectedPaintTarget = resolvePaintTargetFromSelection({ + nodes, + selectedId, + }) + + if (selectedPaintTarget) { + setActivePaintTarget(selectedPaintTarget) + } + }, [nodes, selectedId, setActivePaintTarget]) + + const catalogItems = useMemo(() => getMaterialsForTarget(activePaintTarget), [activePaintTarget]) + + useEffect(() => { + const firstMaterial = catalogItems[0] + if (!firstMaterial) return + + const hasCompatibleMaterial = isActivePaintMaterialCompatible( + activePaintMaterial, + activePaintTarget, + ) + if (hasCompatibleMaterial) return + + setActivePaintMaterial({ + materialPreset: toLibraryMaterialRef(firstMaterial.id), + sourceTarget: activePaintTarget, + }) + }, [activePaintMaterial, activePaintTarget, catalogItems, setActivePaintMaterial]) + + return ( +
+ { + setActivePaintMaterial({ material, sourceTarget: activePaintTarget }) + }} + onSelectMaterialPreset={(materialPreset) => { + setActivePaintMaterial({ materialPreset, sourceTarget: activePaintTarget }) + }} + selectedMaterialPreset={ + activePaintMaterial?.sourceTarget === activePaintTarget + ? activePaintMaterial.materialPreset + : undefined + } + value={ + activePaintMaterial?.sourceTarget === activePaintTarget + ? activePaintMaterial.material + : undefined + } + /> +
+ ) +} + export function ActionMenu({ className }: { className?: string }) { const phase = useEditor((state) => state.phase) const mode = useEditor((state) => state.mode) const tool = useEditor((state) => state.tool) const catalogCategory = useEditor((state) => state.catalogCategory) const reducedMotion = useReducedMotion() + const showPaintTray = useMemo(() => mode === 'material-paint', [mode]) const transition = reducedMotion ? { duration: 0 } : { type: 'spring' as const, bounce: 0.2, duration: 0.4 } @@ -138,6 +211,38 @@ export function ActionMenu({ className }: { className?: string }) { )} + + + {showPaintTray && ( + + + + )} + {/* Control Mode Row - Always visible, centered */}
diff --git a/packages/editor/src/components/ui/command-palette/editor-commands.tsx b/packages/editor/src/components/ui/command-palette/editor-commands.tsx index 42d4bc9f..d9b43252 100644 --- a/packages/editor/src/components/ui/command-palette/editor-commands.tsx +++ b/packages/editor/src/components/ui/command-palette/editor-commands.tsx @@ -23,6 +23,7 @@ import { Moon, MousePointer2, Package, + PaintBucket, PencilLine, Plus, Redo2, @@ -48,6 +49,7 @@ export function EditorCommands() { const setMode = useEditor((s) => s.setMode) const setTool = useEditor((s) => s.setTool) const setStructureLayer = useEditor((s) => s.setStructureLayer) + const primeMaterialPaintFromSelection = useEditor((s) => s.primeMaterialPaintFromSelection) const isPreviewMode = useEditor((s) => s.isPreviewMode) const setPreviewMode = useEditor((s) => s.setPreviewMode) @@ -149,6 +151,21 @@ export function EditorCommands() { useScene.getState().deleteNodes(selectedIds as any[]) }), }, + { + id: 'editor.mode.material-paint', + label: 'Material Paint', + group: 'Scene', + icon: , + keywords: ['paint', 'material', 'texture', 'bucket', 'surface'], + shortcut: ['P'], + execute: () => + run(() => { + primeMaterialPaintFromSelection() + setPhase('structure') + setStructureLayer('elements') + setMode('material-paint') + }), + }, // ── Levels ─────────────────────────────────────────────────────────── { @@ -354,7 +371,7 @@ export function EditorCommands() { icon: , keywords: ['export', 'glb', 'gltf', '3d', 'model', 'download'], execute: () => run(() => exportScene()), - } as const, + }, ] : []), { diff --git a/packages/editor/src/components/ui/controls/material-picker.tsx b/packages/editor/src/components/ui/controls/material-picker.tsx index 72b1b5e9..e189276e 100755 --- a/packages/editor/src/components/ui/controls/material-picker.tsx +++ b/packages/editor/src/components/ui/controls/material-picker.tsx @@ -6,7 +6,7 @@ import { type MaterialSchema, type MaterialTarget, } from '@pascal-app/core' -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' type MaterialPickerProps = { nodeType?: MaterialTarget @@ -28,6 +28,7 @@ export function MaterialPicker({ disabled = false, }: MaterialPickerProps) { const [showCustom, setShowCustom] = useState(!!value?.properties) + const catalogScrollRef = useRef(null) const catalogItems = nodeType ? getMaterialsForTarget(nodeType) : [] useEffect(() => { @@ -51,6 +52,27 @@ export function MaterialPicker({ onSelectMaterialPreset?.(toLibraryMaterialRef(materialId)) } + useEffect(() => { + const container = catalogScrollRef.current + if (!container) return + + const handleWheel = (event: WheelEvent) => { + const deltaX = event.deltaX + const deltaY = event.deltaY + const nextScrollLeft = container.scrollLeft + deltaX + deltaY + + if (nextScrollLeft === container.scrollLeft) return + + event.preventDefault() + container.scrollLeft = nextScrollLeft + } + + container.addEventListener('wheel', handleWheel, { passive: false }) + return () => { + container.removeEventListener('wheel', handleWheel) + } + }, [catalogItems.length, onChange, showCustom]) + const handleCustomOpen = () => { if (disabled) return setShowCustom(true) @@ -82,52 +104,59 @@ export function MaterialPicker({ } return ( -
+
{(catalogItems.length > 0 || onChange) && ( -
+
{catalogItems.length > 0 ? (
Library
) : null} -
- {catalogItems.map((item) => ( - - ))} - {onChange ? ( - - ) : null} +
+
+ {catalogItems.map((item) => ( + + ))} + {onChange ? ( + + ) : null} +
)} diff --git a/packages/editor/src/components/ui/panels/roof-panel.tsx b/packages/editor/src/components/ui/panels/roof-panel.tsx index 69cdba15..f546a8f3 100755 --- a/packages/editor/src/components/ui/panels/roof-panel.tsx +++ b/packages/editor/src/components/ui/panels/roof-panel.tsx @@ -6,7 +6,6 @@ import { getEffectiveRoofSurfaceMaterial, type MaterialSchema, type RoofNode, - type RoofSurfaceMaterialRole, RoofNode as RoofNodeSchema, type RoofSegmentNode, RoofSegmentNode as RoofSegmentNodeSchema, @@ -16,6 +15,7 @@ import { useViewer } from '@pascal-app/viewer' import { Copy, Move, Plus, Trash2 } from 'lucide-react' import { useCallback } from 'react' import { useShallow } from 'zustand/react/shallow' +import { buildRoofSurfaceMaterialPatch } from '../../../lib/material-paint' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { ActionButton, ActionGroup } from '../controls/action-button' @@ -24,32 +24,6 @@ import { PanelSection } from '../controls/panel-section' import { SliderControl } from '../controls/slider-control' import { PanelWrapper } from './panel-wrapper' -function buildRoofSurfaceMaterialPatch( - node: RoofNode, - targetRole: RoofSurfaceMaterialRole, - material: MaterialSchema | undefined, - materialPreset: string | undefined, -): Partial { - const nextSurfaceMaterial = { material, materialPreset } - const nextTop = - targetRole === 'top' ? nextSurfaceMaterial : getEffectiveRoofSurfaceMaterial(node, 'top') - const nextEdge = - targetRole === 'edge' ? nextSurfaceMaterial : getEffectiveRoofSurfaceMaterial(node, 'edge') - const nextWall = - targetRole === 'wall' ? nextSurfaceMaterial : getEffectiveRoofSurfaceMaterial(node, 'wall') - - return { - topMaterial: nextTop.material, - topMaterialPreset: nextTop.materialPreset, - edgeMaterial: nextEdge.material, - edgeMaterialPreset: nextEdge.materialPreset, - wallMaterial: nextWall.material, - wallMaterialPreset: nextWall.materialPreset, - material: undefined, - materialPreset: undefined, - } -} - export function RoofPanel() { const selectedId = useViewer((s) => s.selection.selectedIds[0]) const setSelection = useViewer((s) => s.setSelection) @@ -57,6 +31,8 @@ export function RoofPanel() { const createNode = useScene((s) => s.createNode) const setMovingNode = useEditor((s) => s.setMovingNode) const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget) + const setActivePaintMaterial = useEditor((s) => s.setActivePaintMaterial) + const setActivePaintTarget = useEditor((s) => s.setActivePaintTarget) const node = useScene((s) => selectedId ? (s.nodes[selectedId as AnyNode['id']] as RoofNode | undefined) : undefined, @@ -93,17 +69,21 @@ export function RoofPanel() { const handleTargetedMaterialChange = useCallback( (material: MaterialSchema) => { if (!node || !materialTargetRole) return + setActivePaintTarget('roof') + setActivePaintMaterial({ material, sourceTarget: 'roof' }) handleUpdate(buildRoofSurfaceMaterialPatch(node, materialTargetRole, material, undefined)) }, - [handleUpdate, materialTargetRole, node], + [handleUpdate, materialTargetRole, node, setActivePaintMaterial, setActivePaintTarget], ) const handleTargetedMaterialPresetChange = useCallback( (materialPreset: string) => { if (!node || !materialTargetRole) return + setActivePaintTarget('roof') + setActivePaintMaterial({ materialPreset, sourceTarget: 'roof' }) handleUpdate(buildRoofSurfaceMaterialPatch(node, materialTargetRole, undefined, materialPreset)) }, - [handleUpdate, materialTargetRole, node], + [handleUpdate, materialTargetRole, node, setActivePaintMaterial, setActivePaintTarget], ) const handleClose = useCallback(() => { diff --git a/packages/editor/src/components/ui/panels/stair-panel.tsx b/packages/editor/src/components/ui/panels/stair-panel.tsx index 8756cc0b..ea3f1d26 100644 --- a/packages/editor/src/components/ui/panels/stair-panel.tsx +++ b/packages/editor/src/components/ui/panels/stair-panel.tsx @@ -8,7 +8,6 @@ import { type MaterialSchema, type StairNode, type StairRailingMode, - type StairSurfaceMaterialRole, type StairSlabOpeningMode, type StairTopLandingMode, type StairType, @@ -21,6 +20,7 @@ import { useViewer } from '@pascal-app/viewer' import { Copy, Move, Plus, Trash2 } from 'lucide-react' import { useCallback } from 'react' import { useShallow } from 'zustand/react/shallow' +import { buildStairSurfaceMaterialPatch } from '../../../lib/material-paint' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE } from '../../tools/stair/stair-defaults' @@ -33,32 +33,6 @@ import { SliderControl } from '../controls/slider-control' import { ToggleControl } from '../controls/toggle-control' import { PanelWrapper } from './panel-wrapper' -function buildStairSurfaceMaterialPatch( - node: StairNode, - targetRole: StairSurfaceMaterialRole, - material: MaterialSchema | undefined, - materialPreset: string | undefined, -): Partial { - const nextSurfaceMaterial = { material, materialPreset } - const nextRailing = - targetRole === 'railing' ? nextSurfaceMaterial : getEffectiveStairSurfaceMaterial(node, 'railing') - const nextTread = - targetRole === 'tread' ? nextSurfaceMaterial : getEffectiveStairSurfaceMaterial(node, 'tread') - const nextSide = - targetRole === 'side' ? nextSurfaceMaterial : getEffectiveStairSurfaceMaterial(node, 'side') - - return { - railingMaterial: nextRailing.material, - railingMaterialPreset: nextRailing.materialPreset, - treadMaterial: nextTread.material, - treadMaterialPreset: nextTread.materialPreset, - sideMaterial: nextSide.material, - sideMaterialPreset: nextSide.materialPreset, - material: undefined, - materialPreset: undefined, - } -} - const RAILING_MODE_OPTIONS: { label: string; value: StairRailingMode }[] = [ { label: 'None', value: 'none' }, { label: 'Left', value: 'left' }, @@ -91,6 +65,8 @@ export function StairPanel() { const createNodes = useScene((s) => s.createNodes) const setMovingNode = useEditor((s) => s.setMovingNode) const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget) + const setActivePaintMaterial = useEditor((s) => s.setActivePaintMaterial) + const setActivePaintTarget = useEditor((s) => s.setActivePaintTarget) const node = useScene((s) => selectedId ? (s.nodes[selectedId as AnyNode['id']] as StairNode | undefined) : undefined, @@ -135,17 +111,21 @@ export function StairPanel() { const handleTargetedMaterialChange = useCallback( (material: MaterialSchema) => { if (!node || !materialTargetRole) return + setActivePaintTarget('stair') + setActivePaintMaterial({ material, sourceTarget: 'stair' }) handleUpdate(buildStairSurfaceMaterialPatch(node, materialTargetRole, material, undefined)) }, - [handleUpdate, materialTargetRole, node], + [handleUpdate, materialTargetRole, node, setActivePaintMaterial, setActivePaintTarget], ) const handleTargetedMaterialPresetChange = useCallback( (materialPreset: string) => { if (!node || !materialTargetRole) return + setActivePaintTarget('stair') + setActivePaintMaterial({ materialPreset, sourceTarget: 'stair' }) handleUpdate(buildStairSurfaceMaterialPatch(node, materialTargetRole, undefined, materialPreset)) }, - [handleUpdate, materialTargetRole, node], + [handleUpdate, materialTargetRole, node, setActivePaintMaterial, setActivePaintTarget], ) const handleClose = useCallback(() => { diff --git a/packages/editor/src/components/ui/panels/wall-panel.tsx b/packages/editor/src/components/ui/panels/wall-panel.tsx index 35bd8e5f..899c92fc 100755 --- a/packages/editor/src/components/ui/panels/wall-panel.tsx +++ b/packages/editor/src/components/ui/panels/wall-panel.tsx @@ -11,13 +11,13 @@ import { normalizeWallCurveOffset, type MaterialSchema, useScene, - type WallSurfaceSide, type WallNode, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { Move, Spline } from 'lucide-react' import { useCallback, useMemo } from 'react' import { sfxEmitter } from '../../../lib/sfx-bus' +import { buildWallSurfaceMaterialPatch } from '../../../lib/material-paint' import useEditor from '../../../store/use-editor' import { ActionButton, ActionGroup } from '../controls/action-button' import { MaterialPicker } from '../controls/material-picker' @@ -25,32 +25,6 @@ import { PanelSection } from '../controls/panel-section' import { SliderControl } from '../controls/slider-control' import { PanelWrapper } from './panel-wrapper' -function buildWallSurfaceMaterialPatch( - node: WallNode, - targetSide: WallSurfaceSide | null, - material: MaterialSchema | undefined, - materialPreset: string | undefined, -): Partial { - const nextSurfaceMaterial = { material, materialPreset } - const nextInterior = - targetSide === null || targetSide === 'interior' - ? nextSurfaceMaterial - : getEffectiveWallSurfaceMaterial(node, 'interior') - const nextExterior = - targetSide === null || targetSide === 'exterior' - ? nextSurfaceMaterial - : getEffectiveWallSurfaceMaterial(node, 'exterior') - - return { - interiorMaterial: nextInterior.material, - interiorMaterialPreset: nextInterior.materialPreset, - exteriorMaterial: nextExterior.material, - exteriorMaterialPreset: nextExterior.materialPreset, - material: undefined, - materialPreset: undefined, - } -} - export function WallPanel() { const selectedId = useViewer((s) => s.selection.selectedIds[0]) const setSelection = useViewer((s) => s.setSelection) @@ -58,6 +32,8 @@ export function WallPanel() { const setMovingNode = useEditor((s) => s.setMovingNode) const setCurvingWall = useEditor((s) => s.setCurvingWall) const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget) + const setActivePaintMaterial = useEditor((s) => s.setActivePaintMaterial) + const setActivePaintTarget = useEditor((s) => s.setActivePaintTarget) const node = useScene((s) => selectedId ? (s.nodes[selectedId as AnyNode['id']] as WallNode | undefined) : undefined, @@ -143,17 +119,21 @@ export function WallPanel() { const handleMaterialPresetChange = useCallback( (materialPreset: string) => { if (!node || !materialTargetSide) return + setActivePaintTarget('wall') + setActivePaintMaterial({ materialPreset, sourceTarget: 'wall' }) handleUpdate(buildWallSurfaceMaterialPatch(node, materialTargetSide, undefined, materialPreset)) }, - [handleUpdate, materialTargetSide, node], + [handleUpdate, materialTargetSide, node, setActivePaintMaterial, setActivePaintTarget], ) const handleCustomMaterialChange = useCallback( (material: MaterialSchema) => { if (!node || !materialTargetSide) return + setActivePaintTarget('wall') + setActivePaintMaterial({ material, sourceTarget: 'wall' }) handleUpdate(buildWallSurfaceMaterialPatch(node, materialTargetSide, material, undefined)) }, - [handleUpdate, materialTargetSide, node], + [handleUpdate, materialTargetSide, node, setActivePaintMaterial, setActivePaintTarget], ) const handleClose = useCallback(() => { diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts index 511075d9..24b53bed 100755 --- a/packages/editor/src/hooks/use-keyboard.ts +++ b/packages/editor/src/hooks/use-keyboard.ts @@ -88,6 +88,13 @@ export const useKeyboard = ({ isVersionPreviewMode = false } = {}) => { if (isVersionPreviewMode) return e.preventDefault() useEditor.getState().setMode('delete') + } else if (e.key === 'p' && !e.metaKey && !e.ctrlKey) { + if (isVersionPreviewMode) return + e.preventDefault() + useEditor.getState().primeMaterialPaintFromSelection() + useEditor.getState().setPhase('structure') + useEditor.getState().setStructureLayer('elements') + useEditor.getState().setMode('material-paint') } else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) { if (isVersionPreviewMode) return e.preventDefault() diff --git a/packages/editor/src/lib/material-paint.ts b/packages/editor/src/lib/material-paint.ts new file mode 100644 index 00000000..7c7fed68 --- /dev/null +++ b/packages/editor/src/lib/material-paint.ts @@ -0,0 +1,237 @@ +'use client' + +import { + getCatalogMaterialById, + getEffectiveRoofSurfaceMaterial, + getEffectiveStairSurfaceMaterial, + getEffectiveWallSurfaceMaterial, + getLibraryMaterialIdFromRef, + type MaterialSchema, + type MaterialTarget, + type RoofNode, + type RoofSurfaceMaterialRole, + type StairNode, + type StairSurfaceMaterialRole, + type WallNode, + type WallSurfaceSide, +} from '@pascal-app/core' + +export type PaintableMaterialTarget = Extract + +export type ActivePaintMaterial = { + material?: MaterialSchema + materialPreset?: string + sourceTarget: PaintableMaterialTarget +} + +export function hasActivePaintMaterial( + material: ActivePaintMaterial | null | undefined, +): material is ActivePaintMaterial { + return Boolean( + material && (material.material !== undefined || material.materialPreset !== undefined), + ) +} + +function getCatalogEntryForActivePaintMaterial(material: ActivePaintMaterial | null | undefined) { + const catalogId = + getLibraryMaterialIdFromRef(material?.materialPreset) ?? material?.material?.id ?? undefined + + return getCatalogMaterialById(catalogId) +} + +export function getActivePaintMaterialLabel(material: ActivePaintMaterial | null | undefined) { + return getCatalogEntryForActivePaintMaterial(material)?.label ?? 'Custom' +} + +export function isActivePaintMaterialCompatible( + material: ActivePaintMaterial | null | undefined, + target: PaintableMaterialTarget, +) { + if (!hasActivePaintMaterial(material)) return false + + const catalogEntry = getCatalogEntryForActivePaintMaterial(material) + if (!catalogEntry) return true + + return catalogEntry.targets.includes(target) +} + +export function buildWallSurfaceMaterialPatch( + node: WallNode, + targetSide: WallSurfaceSide, + material: MaterialSchema | undefined, + materialPreset: string | undefined, +): Partial { + const nextSurfaceMaterial = { material, materialPreset } + const nextInterior = + targetSide === 'interior' + ? nextSurfaceMaterial + : getEffectiveWallSurfaceMaterial(node, 'interior') + const nextExterior = + targetSide === 'exterior' + ? nextSurfaceMaterial + : getEffectiveWallSurfaceMaterial(node, 'exterior') + + return { + interiorMaterial: nextInterior.material, + interiorMaterialPreset: nextInterior.materialPreset, + exteriorMaterial: nextExterior.material, + exteriorMaterialPreset: nextExterior.materialPreset, + material: undefined, + materialPreset: undefined, + } +} + +export function buildRoofSurfaceMaterialPatch( + node: RoofNode, + targetRole: RoofSurfaceMaterialRole, + material: MaterialSchema | undefined, + materialPreset: string | undefined, +): Partial { + const nextSurfaceMaterial = { material, materialPreset } + const nextTop = + targetRole === 'top' ? nextSurfaceMaterial : getEffectiveRoofSurfaceMaterial(node, 'top') + const nextEdge = + targetRole === 'edge' ? nextSurfaceMaterial : getEffectiveRoofSurfaceMaterial(node, 'edge') + const nextWall = + targetRole === 'wall' ? nextSurfaceMaterial : getEffectiveRoofSurfaceMaterial(node, 'wall') + + return { + topMaterial: nextTop.material, + topMaterialPreset: nextTop.materialPreset, + edgeMaterial: nextEdge.material, + edgeMaterialPreset: nextEdge.materialPreset, + wallMaterial: nextWall.material, + wallMaterialPreset: nextWall.materialPreset, + material: undefined, + materialPreset: undefined, + } +} + +export function buildStairSurfaceMaterialPatch( + node: StairNode, + targetRole: StairSurfaceMaterialRole, + material: MaterialSchema | undefined, + materialPreset: string | undefined, +): Partial { + const nextSurfaceMaterial = { material, materialPreset } + const nextRailing = + targetRole === 'railing' + ? nextSurfaceMaterial + : getEffectiveStairSurfaceMaterial(node, 'railing') + const nextTread = + targetRole === 'tread' ? nextSurfaceMaterial : getEffectiveStairSurfaceMaterial(node, 'tread') + const nextSide = + targetRole === 'side' ? nextSurfaceMaterial : getEffectiveStairSurfaceMaterial(node, 'side') + + return { + railingMaterial: nextRailing.material, + railingMaterialPreset: nextRailing.materialPreset, + treadMaterial: nextTread.material, + treadMaterialPreset: nextTread.materialPreset, + sideMaterial: nextSide.material, + sideMaterialPreset: nextSide.materialPreset, + material: undefined, + materialPreset: undefined, + } +} + +export function resolveActivePaintMaterialFromSelection(params: { + nodes: Record + selectedId: string | null + selectedMaterialTarget: { + nodeId: string + role: WallSurfaceSide | StairSurfaceMaterialRole | RoofSurfaceMaterialRole + } | null +}): ActivePaintMaterial | null { + const { nodes, selectedId, selectedMaterialTarget } = params + if (!selectedId || !selectedMaterialTarget || selectedMaterialTarget.nodeId !== selectedId) + return null + + const selectedNode = nodes[selectedId] + if (!selectedNode) return null + + if ( + selectedNode.type === 'wall' && + (selectedMaterialTarget.role === 'interior' || selectedMaterialTarget.role === 'exterior') + ) { + const surface = getEffectiveWallSurfaceMaterial(selectedNode, selectedMaterialTarget.role) + return hasActivePaintMaterial({ + material: surface.material, + materialPreset: surface.materialPreset, + sourceTarget: 'wall', + }) + ? { + material: surface.material, + materialPreset: surface.materialPreset, + sourceTarget: 'wall', + } + : null + } + + if ( + selectedNode.type === 'roof' && + (selectedMaterialTarget.role === 'top' || + selectedMaterialTarget.role === 'edge' || + selectedMaterialTarget.role === 'wall') + ) { + const surface = getEffectiveRoofSurfaceMaterial(selectedNode, selectedMaterialTarget.role) + return hasActivePaintMaterial({ + material: surface.material, + materialPreset: surface.materialPreset, + sourceTarget: 'roof', + }) + ? { + material: surface.material, + materialPreset: surface.materialPreset, + sourceTarget: 'roof', + } + : null + } + + if ( + selectedNode.type === 'stair' && + (selectedMaterialTarget.role === 'railing' || + selectedMaterialTarget.role === 'tread' || + selectedMaterialTarget.role === 'side') + ) { + const surface = getEffectiveStairSurfaceMaterial(selectedNode, selectedMaterialTarget.role) + return hasActivePaintMaterial({ + material: surface.material, + materialPreset: surface.materialPreset, + sourceTarget: 'stair', + }) + ? { + material: surface.material, + materialPreset: surface.materialPreset, + sourceTarget: 'stair', + } + : null + } + + return null +} + +export function resolvePaintTargetFromSelection(params: { + nodes: Record + selectedId: string | null +}): PaintableMaterialTarget | null { + const { nodes, selectedId } = params + if (!selectedId) return null + + const selectedNode = nodes[selectedId] + if (!selectedNode) return null + + if (selectedNode.type === 'wall') { + return 'wall' + } + + if (selectedNode.type === 'roof' || selectedNode.type === 'roof-segment') { + return 'roof' + } + + if (selectedNode.type === 'stair' || selectedNode.type === 'stair-segment') { + return 'stair' + } + + return null +} diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index 03d8a0e9..d323f12f 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -9,14 +9,14 @@ import { type FenceNode, type ItemNode, type LevelNode, - type RoofSurfaceMaterialRole, type RoofNode, type RoofSegmentNode, + type RoofSurfaceMaterialRole, type SlabNode, type Space, - type StairSurfaceMaterialRole, type StairNode, type StairSegmentNode, + type StairSurfaceMaterialRole, useScene, type WallNode, type WallSurfaceSide, @@ -26,6 +26,12 @@ import { useViewer } from '@pascal-app/viewer' import { create } from 'zustand' import { persist } from 'zustand/middleware' import { getDefaultCatalogItem } from '../components/ui/item-catalog/catalog-items' +import { + type ActivePaintMaterial, + type PaintableMaterialTarget, + resolveActivePaintMaterialFromSelection, + resolvePaintTargetFromSelection, +} from '../lib/material-paint' const DEFAULT_ACTIVE_SIDEBAR_PANEL = 'site' const DEFAULT_FLOORPLAN_PANE_RATIO = 0.5 @@ -37,7 +43,7 @@ export type SplitOrientation = 'horizontal' | 'vertical' export type Phase = 'site' | 'structure' | 'furnish' -export type Mode = 'select' | 'edit' | 'delete' | 'build' +export type Mode = 'select' | 'edit' | 'delete' | 'build' | 'material-paint' // Structure mode tools (building elements) export type StructureTool = @@ -84,13 +90,22 @@ export type MovingWallEndpoint = { endpoint: 'start' | 'end' } -export type MaterialTargetRole = WallSurfaceSide | StairSurfaceMaterialRole | RoofSurfaceMaterialRole +export type MaterialTargetRole = + | WallSurfaceSide + | StairSurfaceMaterialRole + | RoofSurfaceMaterialRole export type SelectedMaterialTarget = { nodeId: AnyNodeId role: MaterialTargetRole } +type MaterialPaintSelectionSnapshot = { + selectedId: string | null + activePaintTarget: PaintableMaterialTarget + activePaintMaterial: ActivePaintMaterial | null +} + type EditorState = { phase: Phase setPhase: (phase: Phase) => void @@ -140,6 +155,15 @@ type EditorState = { setCurvingWall: (wall: WallNode | null) => void selectedMaterialTarget: SelectedMaterialTarget | null setSelectedMaterialTarget: (target: SelectedMaterialTarget | null) => void + activePaintMaterial: ActivePaintMaterial | null + setActivePaintMaterial: (material: ActivePaintMaterial | null) => void + activePaintTarget: PaintableMaterialTarget + setActivePaintTarget: (target: PaintableMaterialTarget) => void + primeMaterialPaintFromSelection: () => MaterialPaintSelectionSnapshot + paintDisabledFeedbackTick: number + triggerPaintDisabledFeedback: () => void + hoveredPaintTarget: PaintableMaterialTarget | null + setHoveredPaintTarget: (target: PaintableMaterialTarget | null) => void selectedReferenceId: string | null setSelectedReferenceId: (id: string | null) => void // Space detection for cutaway mode @@ -219,7 +243,7 @@ function normalizeModeForPhase(phase: Phase, mode: Mode | undefined): Mode { return 'select' } - return mode === 'build' || mode === 'delete' ? mode : 'select' + return mode === 'build' || mode === 'delete' || mode === 'material-paint' ? mode : 'select' } function normalizeFloorplanPaneRatio(value: unknown): number { @@ -457,6 +481,8 @@ const useEditor = create()( const category = get().catalogCategory ?? 'furniture' set({ selectedItem: getDefaultSelectedItemForCategory(category) }) } + } else if (mode === 'material-paint') { + get().primeMaterialPaintFromSelection() } // When leaving build mode, clear tool else if (tool) { @@ -517,6 +543,48 @@ const useEditor = create()( setCurvingWall: (wall) => set({ curvingWall: wall }), selectedMaterialTarget: null, setSelectedMaterialTarget: (target) => set({ selectedMaterialTarget: target }), + activePaintMaterial: null, + setActivePaintMaterial: (material) => set({ activePaintMaterial: material }), + activePaintTarget: 'wall', + setActivePaintTarget: (target) => + set((state) => + state.activePaintTarget === target ? state : { activePaintTarget: target }, + ), + primeMaterialPaintFromSelection: () => { + const selectedId = + useViewer.getState().selection.selectedIds.length === 1 + ? (useViewer.getState().selection.selectedIds[0] ?? null) + : null + const activePaintTarget = + resolvePaintTargetFromSelection({ + nodes: useScene.getState().nodes, + selectedId, + }) ?? get().activePaintTarget + const activePaintMaterial = resolveActivePaintMaterialFromSelection({ + nodes: useScene.getState().nodes, + selectedId, + selectedMaterialTarget: get().selectedMaterialTarget, + }) + + set({ + activePaintTarget, + ...(activePaintMaterial ? { activePaintMaterial } : {}), + }) + + return { + selectedId, + activePaintTarget, + activePaintMaterial: activePaintMaterial ?? get().activePaintMaterial, + } + }, + paintDisabledFeedbackTick: 0, + triggerPaintDisabledFeedback: () => + set((state) => ({ paintDisabledFeedbackTick: state.paintDisabledFeedbackTick + 1 })), + hoveredPaintTarget: null, + setHoveredPaintTarget: (target) => + set((state) => + state.hoveredPaintTarget === target ? state : { hoveredPaintTarget: target }, + ), selectedReferenceId: null, setSelectedReferenceId: (id) => set({ selectedReferenceId: id }), spaces: {}, diff --git a/packages/viewer/src/components/viewer/post-processing.tsx b/packages/viewer/src/components/viewer/post-processing.tsx index a0b3022e..63217975 100644 --- a/packages/viewer/src/components/viewer/post-processing.tsx +++ b/packages/viewer/src/components/viewer/post-processing.tsx @@ -249,15 +249,33 @@ const PostProcessingPasses = () => { // Hovered: blue visible, yellow hidden, pulsing const hoverVisibleColor = uniform( - new Color(hoverHighlightMode === 'delete' ? 0xef_44_44 : 0x00_aa_ff), + new Color( + hoverHighlightMode === 'delete' + ? 0xef_44_44 + : hoverHighlightMode === 'paint-ready' + ? 0xf59e_0b + : hoverHighlightMode === 'paint-disabled' + ? 0x94_a3_b8 + : 0x00_aa_ff, + ), ) const hoverHiddenColor = uniform( - new Color(hoverHighlightMode === 'delete' ? 0x99_1b_1b : 0xf3_ff_47), + new Color( + hoverHighlightMode === 'delete' + ? 0x99_1b_1b + : hoverHighlightMode === 'paint-ready' + ? 0xfde0_68 + : hoverHighlightMode === 'paint-disabled' + ? 0x47_55_69 + : 0xf3_ff_47, + ), + ) + const hoverStrength = uniform( + hoverHighlightMode === 'delete' ? 6 : hoverHighlightMode === 'paint-disabled' ? 4 : 5, ) - const hoverStrength = uniform(hoverHighlightMode === 'delete' ? 6 : 5) const pulsePeriod = uniform(3) const osc = - hoverHighlightMode === 'delete' + hoverHighlightMode === 'delete' || hoverHighlightMode === 'paint-disabled' ? float(1) : oscSine(time.div(pulsePeriod).mul(2)).mul(0.5).add(0.5) // [ 0.5, 1.0 ] const hoverOutline = outlineNode.secondaryVisibleEdge diff --git a/packages/viewer/src/store/use-viewer.ts b/packages/viewer/src/store/use-viewer.ts index 070ec71d..f390edd6 100644 --- a/packages/viewer/src/store/use-viewer.ts +++ b/packages/viewer/src/store/use-viewer.ts @@ -22,8 +22,8 @@ type ViewerState = { selection: SelectionPath previewSelectedIds: BaseNode['id'][] setPreviewSelectedIds: (ids: BaseNode['id'][]) => void - hoverHighlightMode: 'default' | 'delete' - setHoverHighlightMode: (mode: 'default' | 'delete') => void + hoverHighlightMode: 'default' | 'delete' | 'paint-ready' | 'paint-disabled' + setHoverHighlightMode: (mode: 'default' | 'delete' | 'paint-ready' | 'paint-disabled') => void hoveredId: AnyNode['id'] | ZoneNode['id'] | null setHoveredId: (id: AnyNode['id'] | ZoneNode['id'] | null) => void @@ -85,9 +85,10 @@ const useViewer = create()( previewSelectedIds: [], setPreviewSelectedIds: (ids) => set({ previewSelectedIds: ids }), hoverHighlightMode: 'default', - setHoverHighlightMode: (mode) => set({ hoverHighlightMode: mode }), + setHoverHighlightMode: (mode) => + set((state) => (state.hoverHighlightMode === mode ? state : { hoverHighlightMode: mode })), hoveredId: null, - setHoveredId: (id) => set({ hoveredId: id }), + setHoveredId: (id) => set((state) => (state.hoveredId === id ? state : { hoveredId: id })), cameraMode: 'perspective', setCameraMode: (mode) => set({ cameraMode: mode }),