From 5d75e29bc5e5ef8443eb1f4f71f4d2aaeeb93e29 Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 21 Apr 2026 15:00:09 +0530 Subject: [PATCH 01/14] Add material paint mode with scrollable palette --- .../editor/src/components/editor/index.tsx | 239 +++++++++++++++++- .../components/editor/selection-manager.tsx | 202 ++++++++++++++- .../ui/action-menu/control-modes.tsx | 29 ++- .../src/components/ui/action-menu/index.tsx | 105 ++++++++ .../ui/command-palette/editor-commands.tsx | 19 +- .../ui/controls/material-picker.tsx | 115 +++++---- .../src/components/ui/panels/roof-panel.tsx | 38 +-- .../src/components/ui/panels/stair-panel.tsx | 38 +-- .../src/components/ui/panels/wall-panel.tsx | 38 +-- packages/editor/src/hooks/use-keyboard.ts | 7 + packages/editor/src/lib/material-paint.ts | 237 +++++++++++++++++ packages/editor/src/store/use-editor.tsx | 78 +++++- .../src/components/viewer/post-processing.tsx | 26 +- packages/viewer/src/store/use-viewer.ts | 9 +- 14 files changed, 1017 insertions(+), 163 deletions(-) create mode 100644 packages/editor/src/lib/material-paint.ts 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 }), From b925cf1e7a47b16531454b20f0483492012e0a82 Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 21 Apr 2026 15:11:46 +0530 Subject: [PATCH 02/14] Show paint cursor on incompatible clicks --- packages/editor/src/components/editor/index.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index 20e5669e..08ee4eaf 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -686,6 +686,7 @@ function PaintCursorLayer({ const activePaintTarget = useEditor((s) => s.activePaintTarget) const paintDisabledFeedbackTick = useEditor((s) => s.paintDisabledFeedbackTick) const badgeRef = useRef(null) + const lastPointerPositionRef = useRef<{ x: number; y: number } | null>(null) const [showDisabledFeedback, setShowDisabledFeedback] = useState(false) const active = mode === 'material-paint' && !isVersionPreviewMode @@ -714,6 +715,7 @@ 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) @@ -755,10 +757,16 @@ function PaintCursorLayer({ 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) { From 65399731f72ae05561e33a53f3a8b1a505fc5b93 Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 21 Apr 2026 15:19:46 +0530 Subject: [PATCH 03/14] Enable material paint for fence slab and ceiling nodes --- .../components/editor/selection-manager.tsx | 40 ++++++++++++- packages/editor/src/lib/material-paint.ts | 58 ++++++++++++++++++- packages/editor/src/store/use-editor.tsx | 2 + 3 files changed, 97 insertions(+), 3 deletions(-) diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index 92b1d2f1..a1dd3157 100755 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -2,13 +2,16 @@ import { type AnyNode, type AnyNodeId, type BuildingNode, + type CeilingNode, emitter, + type FenceNode, type ItemNode, type NodeEvent, type RoofEvent, type RoofNode, type RoofSegmentEvent, resolveLevelId, + type SlabNode, type StairEvent, type StairNode, type StairSegmentEvent, @@ -25,6 +28,7 @@ import { useCallback, useEffect, useRef } from 'react' import { type BufferGeometry, Color, type Material, type Mesh, type Object3D } from 'three' import { buildRoofSurfaceMaterialPatch, + buildSingleSurfaceMaterialPatch, buildStairSurfaceMaterialPatch, buildWallSurfaceMaterialPatch, hasActivePaintMaterial, @@ -609,7 +613,30 @@ export const SelectionManager = () => { } } - const disabledNodeTypes = ['fence', 'item', 'slab', 'ceiling', 'window', 'door', 'zone'] + if (node.type === 'fence' || node.type === 'slab' || node.type === 'ceiling') { + const target = node.type + const compatible = + isActivePaintMaterialCompatible(activePaintMaterial, target) && + hasActivePaintMaterial(activePaintMaterial) + + return { + apply: compatible + ? () => { + useScene + .getState() + .updateNode( + node.id as AnyNodeId, + buildSingleSurfaceMaterialPatch( + activePaintMaterial.material, + activePaintMaterial.materialPreset, + ), + ) + } + : null, + } + } + + const disabledNodeTypes = ['item', 'window', 'door', 'zone'] if (disabledNodeTypes.includes(node.type)) { return { apply: null, @@ -776,6 +803,14 @@ export const SelectionManager = () => { nextMaterialTargetHandled = true } + if ( + (node.type === 'fence' || node.type === 'slab' || node.type === 'ceiling') && + nodeToSelect.type === node.type + ) { + setSelectedMaterialTargetForNode(nodeToSelect, 'surface') + nextMaterialTargetHandled = true + } + if (!nextMaterialTargetHandled && useEditor.getState().selectedMaterialTarget) { useEditor.getState().setSelectedMaterialTarget(null) } @@ -1092,6 +1127,9 @@ const SelectionStateSync = () => { if ( !selectedNode || (selectedNode.type !== 'wall' && + selectedNode.type !== 'fence' && + selectedNode.type !== 'slab' && + selectedNode.type !== 'ceiling' && selectedNode.type !== 'stair' && selectedNode.type !== 'roof') ) { diff --git a/packages/editor/src/lib/material-paint.ts b/packages/editor/src/lib/material-paint.ts index 7c7fed68..88bce034 100644 --- a/packages/editor/src/lib/material-paint.ts +++ b/packages/editor/src/lib/material-paint.ts @@ -1,6 +1,8 @@ 'use client' import { + type CeilingNode, + type FenceNode, getCatalogMaterialById, getEffectiveRoofSurfaceMaterial, getEffectiveStairSurfaceMaterial, @@ -10,13 +12,19 @@ import { type MaterialTarget, type RoofNode, type RoofSurfaceMaterialRole, + type SlabNode, type StairNode, type StairSurfaceMaterialRole, type WallNode, type WallSurfaceSide, } from '@pascal-app/core' -export type PaintableMaterialTarget = Extract +export type PaintableMaterialTarget = Extract< + MaterialTarget, + 'wall' | 'roof' | 'stair' | 'fence' | 'slab' | 'ceiling' +> + +export type SingleSurfaceMaterialRole = 'surface' export type ActivePaintMaterial = { material?: MaterialSchema @@ -135,12 +143,26 @@ export function buildStairSurfaceMaterialPatch( } } +export function buildSingleSurfaceMaterialPatch( + material: MaterialSchema | undefined, + materialPreset: string | undefined, +): Partial { + return { + material, + materialPreset, + } as Partial +} + export function resolveActivePaintMaterialFromSelection(params: { nodes: Record selectedId: string | null selectedMaterialTarget: { nodeId: string - role: WallSurfaceSide | StairSurfaceMaterialRole | RoofSurfaceMaterialRole + role: + | WallSurfaceSide + | StairSurfaceMaterialRole + | RoofSurfaceMaterialRole + | SingleSurfaceMaterialRole } | null }): ActivePaintMaterial | null { const { nodes, selectedId, selectedMaterialTarget } = params @@ -208,6 +230,26 @@ export function resolveActivePaintMaterialFromSelection(params: { : null } + if ( + (selectedNode.type === 'fence' || + selectedNode.type === 'slab' || + selectedNode.type === 'ceiling') && + selectedMaterialTarget.role === 'surface' + ) { + const target = selectedNode.type + return hasActivePaintMaterial({ + material: selectedNode.material, + materialPreset: selectedNode.materialPreset, + sourceTarget: target, + }) + ? { + material: selectedNode.material, + materialPreset: selectedNode.materialPreset, + sourceTarget: target, + } + : null + } + return null } @@ -233,5 +275,17 @@ export function resolvePaintTargetFromSelection(params: { return 'stair' } + if (selectedNode.type === 'fence') { + return 'fence' + } + + if (selectedNode.type === 'slab') { + return 'slab' + } + + if (selectedNode.type === 'ceiling') { + return 'ceiling' + } + return null } diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index d323f12f..da6a952a 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -31,6 +31,7 @@ import { type PaintableMaterialTarget, resolveActivePaintMaterialFromSelection, resolvePaintTargetFromSelection, + type SingleSurfaceMaterialRole, } from '../lib/material-paint' const DEFAULT_ACTIVE_SIDEBAR_PANEL = 'site' @@ -94,6 +95,7 @@ export type MaterialTargetRole = | WallSurfaceSide | StairSurfaceMaterialRole | RoofSurfaceMaterialRole + | SingleSurfaceMaterialRole export type SelectedMaterialTarget = { nodeId: AnyNodeId From 87dfdf1b381e37dc8f6c2b28c1a9457c20a813ae Mon Sep 17 00:00:00 2001 From: sudhir Date: Wed, 22 Apr 2026 10:52:35 +0530 Subject: [PATCH 04/14] Add paint hover previews to viewer --- packages/core/src/store/use-scene.ts | 6 +- .../editor/src/components/editor/index.tsx | 24 ++- .../components/editor/selection-manager.tsx | 139 +++++++++++++++++- .../renderers/ceiling/ceiling-renderer.tsx | 45 +++++- .../renderers/fence/fence-renderer.tsx | 26 +++- .../roof-segment/roof-segment-renderer.tsx | 66 ++++++--- .../renderers/roof/roof-renderer.tsx | 48 ++++-- .../renderers/slab/slab-renderer.tsx | 90 ++++++++---- .../stair-segment/stair-segment-renderer.tsx | 67 ++++++--- .../renderers/stair/stair-renderer.tsx | 86 ++++++----- .../renderers/wall/wall-renderer.tsx | 26 +++- packages/viewer/src/store/use-viewer.d.ts | 44 +++++- packages/viewer/src/store/use-viewer.ts | 70 ++++++++- .../viewer/src/systems/roof/roof-materials.ts | 25 +++- .../src/systems/stair/stair-materials.ts | 32 +++- 15 files changed, 638 insertions(+), 156 deletions(-) diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index ca310349..853f783a 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -157,14 +157,16 @@ function migrateStairSurfaceMaterials(node: Record) { if (node.treadMaterial !== undefined || typeof node.treadMaterialPreset === 'string') { return { material: node.treadMaterial, - materialPreset: typeof node.treadMaterialPreset === 'string' ? node.treadMaterialPreset : undefined, + materialPreset: + typeof node.treadMaterialPreset === 'string' ? node.treadMaterialPreset : undefined, } } if (node.sideMaterial !== undefined || typeof node.sideMaterialPreset === 'string') { return { material: node.sideMaterial, - materialPreset: typeof node.sideMaterialPreset === 'string' ? node.sideMaterialPreset : undefined, + materialPreset: + typeof node.sideMaterialPreset === 'string' ? node.sideMaterialPreset : undefined, } } diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index 08ee4eaf..648e9cb8 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -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(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 }} > ['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]) diff --git a/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx b/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx index 51e17f4a..398abf54 100644 --- a/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx +++ b/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx @@ -1,8 +1,14 @@ -import { type CeilingNode, getMaterialPresetByRef, resolveMaterial, useRegistry } from '@pascal-app/core' +import { + type CeilingNode, + getMaterialPresetByRef, + resolveMaterial, + useRegistry, +} from '@pascal-app/core' import { useMemo, useRef } from 'react' import { float, mix, positionWorld, smoothstep } from 'three/tsl' import { BackSide, FrontSide, type Mesh, MeshBasicNodeMaterial } from 'three/webgpu' import { useNodeEvents } from '../../../hooks/use-node-events' +import useViewer from '../../../store/use-viewer' import { NodeRenderer } from '../node-renderer' const gridScale = 5 @@ -32,18 +38,47 @@ function createCeilingMaterials(color = '#999999') { return { topMaterial, bottomMaterial } } +const ceilingMaterialCache = new Map>() + +function getCeilingMaterials(color = '#999999') { + const cacheKey = color + const cached = ceilingMaterialCache.get(cacheKey) + if (cached) return cached + + const materials = createCeilingMaterials(color) + ceilingMaterialCache.set(cacheKey, materials) + return materials +} + export const CeilingRenderer = ({ node }: { node: CeilingNode }) => { const ref = useRef(null!) useRegistry(node.id, 'ceiling', ref) const handlers = useNodeEvents(node, 'ceiling') + const materialPreview = useViewer((state) => + state.materialPreview?.target === 'ceiling' && state.materialPreview.nodeId === node.id + ? state.materialPreview + : null, + ) const materials = useMemo(() => { - const preset = getMaterialPresetByRef(node.materialPreset) - const props = preset?.mapProperties ?? resolveMaterial(node.material) + const preset = getMaterialPresetByRef(materialPreview?.materialPreset ?? node.materialPreset) + const props = + preset?.mapProperties ?? resolveMaterial(materialPreview?.material ?? node.material) const color = props.color || '#999999' - return createCeilingMaterials(color) - }, [node.materialPreset, node.material, node.material?.preset, node.material?.properties, node.material?.texture]) + return getCeilingMaterials(color) + }, [ + node.materialPreset, + node.material, + node.material?.preset, + node.material?.properties, + node.material?.texture, + materialPreview?.materialPreset, + materialPreview?.material, + materialPreview?.material?.preset, + materialPreview?.material?.properties, + materialPreview?.material?.texture, + ]) return ( diff --git a/packages/viewer/src/components/renderers/fence/fence-renderer.tsx b/packages/viewer/src/components/renderers/fence/fence-renderer.tsx index 7f22d5e8..c5448ada 100644 --- a/packages/viewer/src/components/renderers/fence/fence-renderer.tsx +++ b/packages/viewer/src/components/renderers/fence/fence-renderer.tsx @@ -7,14 +7,22 @@ import { createMaterialFromPresetRef, DEFAULT_STAIR_MATERIAL, } from '../../../lib/materials' +import useViewer from '../../../store/use-viewer' export const FenceRenderer = ({ node }: { node: FenceNode }) => { const ref = useRef(null!) const handlers = useNodeEvents(node, 'fence') + const materialPreview = useViewer((state) => + state.materialPreview?.target === 'fence' && state.materialPreview.nodeId === node.id + ? state.materialPreview + : null, + ) const material = useMemo(() => { - const presetMaterial = createMaterialFromPresetRef(node.materialPreset) + const presetMaterial = createMaterialFromPresetRef( + materialPreview?.materialPreset ?? node.materialPreset, + ) if (presetMaterial) return presetMaterial - const mat = node.material + const mat = materialPreview?.material ?? node.material if (!mat) return DEFAULT_STAIR_MATERIAL return createMaterial(mat) }, [ @@ -23,6 +31,11 @@ export const FenceRenderer = ({ node }: { node: FenceNode }) => { node.material?.preset, node.material?.properties, node.material?.texture, + materialPreview?.materialPreset, + materialPreview?.material, + materialPreview?.material?.preset, + materialPreview?.material?.properties, + materialPreview?.material?.texture, ]) useRegistry(node.id, 'fence', ref) @@ -31,7 +44,14 @@ export const FenceRenderer = ({ node }: { node: FenceNode }) => { }, [node.id]) return ( - + ) diff --git a/packages/viewer/src/components/renderers/roof-segment/roof-segment-renderer.tsx b/packages/viewer/src/components/renderers/roof-segment/roof-segment-renderer.tsx index cd0fbd09..daebf9fb 100644 --- a/packages/viewer/src/components/renderers/roof-segment/roof-segment-renderer.tsx +++ b/packages/viewer/src/components/renderers/roof-segment/roof-segment-renderer.tsx @@ -1,4 +1,10 @@ -import { type AnyNodeId, type RoofNode, type RoofSegmentNode, useRegistry, useScene } from '@pascal-app/core' +import { + type AnyNodeId, + type RoofNode, + type RoofSegmentNode, + useRegistry, + useScene, +} from '@pascal-app/core' import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' import { useNodeEvents } from '../../../hooks/use-node-events' @@ -14,8 +20,14 @@ export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => { const handlers = useNodeEvents(node, 'roof-segment') const debugColors = useViewer((s) => s.debugColors) - const parentNode = - node.parentId ? (nodes[node.parentId as AnyNodeId] as RoofNode | undefined) : undefined + const parentNode = node.parentId + ? (nodes[node.parentId as AnyNodeId] as RoofNode | undefined) + : undefined + const materialPreview = useViewer((state) => + state.materialPreview?.target === 'roof' && state.materialPreview.nodeId === parentNode?.id + ? state.materialPreview + : null, + ) const placeholderGeometry = useMemo(() => { const geometry = new THREE.BufferGeometry() geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3)) @@ -26,30 +38,40 @@ export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => { return geometry }, []) + const previewParentNode = !parentNode + ? undefined + : materialPreview?.role === 'top' + ? { + ...parentNode, + topMaterial: materialPreview.material, + topMaterialPreset: materialPreview.materialPreset, + material: undefined, + materialPreset: undefined, + } + : materialPreview?.role === 'edge' + ? { + ...parentNode, + edgeMaterial: materialPreview.material, + edgeMaterialPreset: materialPreview.materialPreset, + material: undefined, + materialPreset: undefined, + } + : materialPreview?.role === 'wall' + ? { + ...parentNode, + wallMaterial: materialPreview.material, + wallMaterialPreset: materialPreview.materialPreset, + material: undefined, + materialPreset: undefined, + } + : parentNode const customMaterial = useMemo(() => { if (node.material !== undefined || typeof node.materialPreset === 'string') { return null } - return parentNode ? getRoofMaterialArray(parentNode) : null - }, [ - node.materialPreset, - node.material, - node.material?.preset, - node.material?.properties, - node.material?.texture, - parentNode?.materialPreset, - parentNode?.material, - parentNode?.material?.preset, - parentNode?.material?.properties, - parentNode?.material?.texture, - parentNode?.topMaterial, - parentNode?.topMaterialPreset, - parentNode?.edgeMaterial, - parentNode?.edgeMaterialPreset, - parentNode?.wallMaterial, - parentNode?.wallMaterialPreset, - ]) + return previewParentNode ? getRoofMaterialArray(previewParentNode) : null + }, [node, previewParentNode]) const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials diff --git a/packages/viewer/src/components/renderers/roof/roof-renderer.tsx b/packages/viewer/src/components/renderers/roof/roof-renderer.tsx index a5fd4ee6..4a4eb866 100644 --- a/packages/viewer/src/components/renderers/roof/roof-renderer.tsx +++ b/packages/viewer/src/components/renderers/roof/roof-renderer.tsx @@ -14,6 +14,11 @@ export const RoofRenderer = ({ node }: { node: RoofNode }) => { const handlers = useNodeEvents(node, 'roof') const debugColors = useViewer((s) => s.debugColors) + const materialPreview = useViewer((state) => + state.materialPreview?.target === 'roof' && state.materialPreview.nodeId === node.id + ? state.materialPreview + : null, + ) const placeholderGeometry = useMemo(() => { const geometry = new THREE.BufferGeometry() geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3)) @@ -24,22 +29,33 @@ export const RoofRenderer = ({ node }: { node: RoofNode }) => { return geometry }, []) - const customMaterial = useMemo( - () => getRoofMaterialArray(node), - [ - node.materialPreset, - node.material, - node.material?.preset, - node.material?.properties, - node.material?.texture, - node.topMaterial, - node.topMaterialPreset, - node.edgeMaterial, - node.edgeMaterialPreset, - node.wallMaterial, - node.wallMaterialPreset, - ], - ) + const previewNode = + materialPreview?.role === 'top' + ? { + ...node, + topMaterial: materialPreview.material, + topMaterialPreset: materialPreview.materialPreset, + material: undefined, + materialPreset: undefined, + } + : materialPreview?.role === 'edge' + ? { + ...node, + edgeMaterial: materialPreview.material, + edgeMaterialPreset: materialPreview.materialPreset, + material: undefined, + materialPreset: undefined, + } + : materialPreview?.role === 'wall' + ? { + ...node, + wallMaterial: materialPreview.material, + wallMaterialPreset: materialPreview.materialPreset, + material: undefined, + materialPreset: undefined, + } + : node + const customMaterial = useMemo(() => getRoofMaterialArray(previewNode), [previewNode]) const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials diff --git a/packages/viewer/src/components/renderers/slab/slab-renderer.tsx b/packages/viewer/src/components/renderers/slab/slab-renderer.tsx index f491cef2..0e97f23d 100644 --- a/packages/viewer/src/components/renderers/slab/slab-renderer.tsx +++ b/packages/viewer/src/components/renderers/slab/slab-renderer.tsx @@ -1,13 +1,50 @@ import { getMaterialPresetByRef, type SlabNode, useRegistry } from '@pascal-app/core' -import { useEffect, useMemo, useRef } from 'react' -import * as THREE from 'three' +import { useMemo, useRef } from 'react' import type { Mesh } from 'three' +import * as THREE from 'three' import { useNodeEvents } from '../../../hooks/use-node-events' import { applyMaterialPresetToMaterials, createMaterial, DEFAULT_SLAB_MATERIAL, } from '../../../lib/materials' +import useViewer from '../../../store/use-viewer' + +const slabMaterialCache = new Map() + +function getSlabMaterial( + cacheKey: string, + params: { material?: SlabNode['material']; materialPreset?: string }, +) { + const cached = slabMaterialCache.get(cacheKey) + if (cached) return cached + + const preset = getMaterialPresetByRef(params.materialPreset) + const slabMaterial = preset + ? new THREE.MeshStandardMaterial() + : params.material + ? createMaterial(params.material).clone() + : DEFAULT_SLAB_MATERIAL.clone() + + if (preset) { + // Apply the preset to the slab-owned material so async texture loads update + // the instance we actually render after refresh as well. + applyMaterialPresetToMaterials(slabMaterial, preset) + } + + // Slabs participate in the WebGPU MRT scene pass. Keeping them opaque avoids + // pipeline variants that can fail when geometry is regenerated while a + // transparent/custom material is attached. + slabMaterial.transparent = false + slabMaterial.opacity = 1 + slabMaterial.alphaMap = null + slabMaterial.side = THREE.DoubleSide + slabMaterial.depthWrite = true + slabMaterial.needsUpdate = true + + slabMaterialCache.set(cacheKey, slabMaterial) + return slabMaterial +} export const SlabRenderer = ({ node }: { node: SlabNode }) => { const ref = useRef(null!) @@ -15,46 +52,37 @@ export const SlabRenderer = ({ node }: { node: SlabNode }) => { useRegistry(node.id, 'slab', ref) const handlers = useNodeEvents(node, 'slab') + const materialPreview = useViewer((state) => + state.materialPreview?.target === 'slab' && state.materialPreview.nodeId === node.id + ? state.materialPreview + : null, + ) const material = useMemo(() => { - const preset = getMaterialPresetByRef(node.materialPreset) - const slabMaterial = preset - ? new THREE.MeshStandardMaterial() - : node.material - ? createMaterial(node.material).clone() - : DEFAULT_SLAB_MATERIAL.clone() + const resolvedMaterial = materialPreview?.material ?? node.material + const resolvedMaterialPreset = materialPreview?.materialPreset ?? node.materialPreset + const cacheKey = JSON.stringify({ + material: resolvedMaterial ?? null, + materialPreset: resolvedMaterialPreset ?? null, + }) - if (preset) { - // Apply the preset to the slab-owned material so async texture loads update - // the instance we actually render after refresh as well. - applyMaterialPresetToMaterials(slabMaterial, preset) - } - - // Slabs participate in the WebGPU MRT scene pass. Keeping them opaque avoids - // pipeline variants that can fail when geometry is regenerated while a - // transparent/custom material is attached. - slabMaterial.transparent = false - slabMaterial.opacity = 1 - slabMaterial.alphaMap = null - slabMaterial.side = THREE.DoubleSide - slabMaterial.depthWrite = true - slabMaterial.needsUpdate = true - - return slabMaterial + return getSlabMaterial(cacheKey, { + material: resolvedMaterial, + materialPreset: resolvedMaterialPreset, + }) }, [ node.material, node.material?.preset, node.material?.properties, node.material?.texture, node.materialPreset, + materialPreview?.material, + materialPreview?.material?.preset, + materialPreview?.material?.properties, + materialPreview?.material?.texture, + materialPreview?.materialPreset, ]) - useEffect(() => { - return () => { - material.dispose() - } - }, [material]) - return ( { @@ -15,29 +22,45 @@ export const StairSegmentRenderer = ({ node }: { node: StairSegmentNode }) => { }, [node.id]) const handlers = useNodeEvents(node, 'stair-segment') - const parentNode = - node.parentId ? (nodes[node.parentId as AnyNodeId] as StairNode | undefined) : undefined + const parentNode = node.parentId + ? (nodes[node.parentId as AnyNodeId] as StairNode | undefined) + : undefined + const materialPreview = useViewer((state) => + state.materialPreview?.target === 'stair' && state.materialPreview.nodeId === parentNode?.id + ? state.materialPreview + : null, + ) + const previewParentNode = !parentNode + ? undefined + : materialPreview?.role === 'railing' + ? { + ...parentNode, + railingMaterial: materialPreview.material, + railingMaterialPreset: materialPreview.materialPreset, + material: undefined, + materialPreset: undefined, + } + : materialPreview?.role === 'tread' + ? { + ...parentNode, + treadMaterial: materialPreview.material, + treadMaterialPreset: materialPreview.materialPreset, + material: undefined, + materialPreset: undefined, + } + : materialPreview?.role === 'side' + ? { + ...parentNode, + sideMaterial: materialPreview.material, + sideMaterialPreset: materialPreview.materialPreset, + material: undefined, + materialPreset: undefined, + } + : parentNode const material = useMemo(() => { - return getStraightStairSegmentBodyMaterials(node, parentNode) - }, [ - node.materialPreset, - node.material, - node.material?.preset, - node.material?.properties, - node.material?.texture, - parentNode?.materialPreset, - parentNode?.material, - parentNode?.material?.preset, - parentNode?.material?.properties, - parentNode?.material?.texture, - parentNode?.railingMaterialPreset, - parentNode?.railingMaterial, - parentNode?.sideMaterialPreset, - parentNode?.sideMaterial, - parentNode?.treadMaterialPreset, - parentNode?.treadMaterial, - ]) + return getStraightStairSegmentBodyMaterials(node, previewParentNode) + }, [node, previewParentNode]) const placeholderGeometry = useMemo(() => { const geometry = new THREE.BufferGeometry() diff --git a/packages/viewer/src/components/renderers/stair/stair-renderer.tsx b/packages/viewer/src/components/renderers/stair/stair-renderer.tsx index b79ecec2..4bc8821a 100644 --- a/packages/viewer/src/components/renderers/stair/stair-renderer.tsx +++ b/packages/viewer/src/components/renderers/stair/stair-renderer.tsx @@ -8,10 +8,15 @@ import { import { useEffect, useLayoutEffect, useMemo, useRef } from 'react' import * as THREE from 'three' import { useNodeEvents } from '../../../hooks/use-node-events' -import { createMaterial, createMaterialFromPresetRef, DEFAULT_STAIR_MATERIAL } from '../../../lib/materials' import { - getStairRailingMaterial, + createMaterial, + createMaterialFromPresetRef, + DEFAULT_STAIR_MATERIAL, +} from '../../../lib/materials' +import useViewer from '../../../store/use-viewer' +import { getStairBodyMaterials, + getStairRailingMaterial, type StairBodyMaterials, } from '../../../systems/stair/stair-materials' import { NodeRenderer } from '../node-renderer' @@ -57,48 +62,55 @@ export const StairRenderer = ({ node }: { node: StairNode }) => { }, [node.id]) const handlers = useNodeEvents(node, 'stair') + const materialPreview = useViewer((state) => + state.materialPreview?.target === 'stair' && state.materialPreview.nodeId === node.id + ? state.materialPreview + : null, + ) + const previewNode = + materialPreview?.role === 'railing' + ? { + ...node, + railingMaterial: materialPreview.material, + railingMaterialPreset: materialPreview.materialPreset, + material: undefined, + materialPreset: undefined, + } + : materialPreview?.role === 'tread' + ? { + ...node, + treadMaterial: materialPreview.material, + treadMaterialPreset: materialPreview.materialPreset, + material: undefined, + materialPreset: undefined, + } + : materialPreview?.role === 'side' + ? { + ...node, + sideMaterial: materialPreview.material, + sideMaterialPreset: materialPreview.materialPreset, + material: undefined, + materialPreset: undefined, + } + : node const material = useMemo(() => { - const presetMaterial = createMaterialFromPresetRef(node.materialPreset) + const presetMaterial = createMaterialFromPresetRef(previewNode.materialPreset) if (presetMaterial) return presetMaterial - const mat = node.material + const mat = previewNode.material if (!mat) return DEFAULT_STAIR_MATERIAL return createMaterial(mat) }, [ - node.materialPreset, - node.material, - node.material?.preset, - node.material?.properties, - node.material?.texture, + previewNode.materialPreset, + previewNode.material, + previewNode.material?.preset, + previewNode.material?.properties, + previewNode.material?.texture, ]) - const straightBodyMaterials = useMemo( - () => getStairBodyMaterials(node), - [ - node.material, - node.materialPreset, - node.railingMaterial, - node.railingMaterialPreset, - node.sideMaterial, - node.sideMaterialPreset, - node.treadMaterial, - node.treadMaterialPreset, - ], - ) + const straightBodyMaterials = useMemo(() => getStairBodyMaterials(previewNode), [previewNode]) - const railingMaterial = useMemo( - () => getStairRailingMaterial(node), - [ - node.material, - node.materialPreset, - node.railingMaterial, - node.railingMaterialPreset, - node.sideMaterial, - node.sideMaterialPreset, - node.treadMaterial, - node.treadMaterialPreset, - ], - ) + const railingMaterial = useMemo(() => getStairRailingMaterial(previewNode), [previewNode]) const straightPlaceholderGeometry = useMemo(() => { const geometry = new THREE.BufferGeometry() @@ -132,7 +144,9 @@ export const StairRenderer = ({ node }: { node: StairNode }) => { receiveShadow /> ) : null} - {!isSegmentBasedStair ? : null} + {!isSegmentBasedStair ? ( + + ) : null} {isSegmentBasedStair ? ( diff --git a/packages/viewer/src/components/renderers/wall/wall-renderer.tsx b/packages/viewer/src/components/renderers/wall/wall-renderer.tsx index 9ff38006..a61fbb82 100644 --- a/packages/viewer/src/components/renderers/wall/wall-renderer.tsx +++ b/packages/viewer/src/components/renderers/wall/wall-renderer.tsx @@ -2,6 +2,7 @@ import { useRegistry, useScene, type WallNode } from '@pascal-app/core' import { useLayoutEffect, useRef } from 'react' import type { Mesh } from 'three' import { useNodeEvents } from '../../../hooks/use-node-events' +import useViewer from '../../../store/use-viewer' import { getVisibleWallMaterials } from '../../../systems/wall/wall-materials' import { NodeRenderer } from '../node-renderer' @@ -15,7 +16,30 @@ export const WallRenderer = ({ node }: { node: WallNode }) => { }, [node.id]) const handlers = useNodeEvents(node, 'wall') - const material = getVisibleWallMaterials(node) + const materialPreview = useViewer((state) => + state.materialPreview?.target === 'wall' && state.materialPreview.nodeId === node.id + ? state.materialPreview + : null, + ) + const previewNode = + materialPreview && materialPreview.role === 'interior' + ? { + ...node, + interiorMaterial: materialPreview.material, + interiorMaterialPreset: materialPreview.materialPreset, + material: undefined, + materialPreset: undefined, + } + : materialPreview && materialPreview.role === 'exterior' + ? { + ...node, + exteriorMaterial: materialPreview.material, + exteriorMaterialPreset: materialPreview.materialPreset, + material: undefined, + materialPreset: undefined, + } + : node + const material = getVisibleWallMaterials(previewNode) return ( diff --git a/packages/viewer/src/store/use-viewer.d.ts b/packages/viewer/src/store/use-viewer.d.ts index 4dde28a6..64586624 100644 --- a/packages/viewer/src/store/use-viewer.d.ts +++ b/packages/viewer/src/store/use-viewer.d.ts @@ -1,4 +1,14 @@ -import type { AnyNode, BaseNode, BuildingNode, LevelNode, ZoneNode } from '@pascal-app/core' +import type { + AnyNode, + BaseNode, + BuildingNode, + LevelNode, + MaterialSchema, + RoofSurfaceMaterialRole, + StairSurfaceMaterialRole, + WallSurfaceSide, + ZoneNode, +} from '@pascal-app/core' import type { Object3D } from 'three' type SelectionPath = { buildingId: BuildingNode['id'] | null @@ -10,12 +20,40 @@ type Outliner = { selectedObjects: Object3D[] hoveredObjects: Object3D[] } +type MaterialPreview = { + nodeId: string + target: 'wall' + role: WallSurfaceSide + material?: MaterialSchema + materialPreset?: string +} | { + nodeId: string + target: 'roof' + role: RoofSurfaceMaterialRole + material?: MaterialSchema + materialPreset?: string +} | { + nodeId: string + target: 'stair' + role: StairSurfaceMaterialRole + material?: MaterialSchema + materialPreset?: string +} | { + nodeId: string + target: 'fence' | 'slab' | 'ceiling' + role: 'surface' + material?: MaterialSchema + materialPreset?: string +} | null type ViewerState = { selection: SelectionPath previewSelectedIds: BaseNode['id'][] setPreviewSelectedIds: (ids: BaseNode['id'][]) => void - hoverHighlightMode: 'default' | 'delete' - setHoverHighlightMode: (mode: 'default' | 'delete') => void + materialPreview: MaterialPreview + setMaterialPreview: (preview: MaterialPreview) => void + clearMaterialPreview: () => 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 cameraMode: 'perspective' | 'orthographic' diff --git a/packages/viewer/src/store/use-viewer.ts b/packages/viewer/src/store/use-viewer.ts index f390edd6..c431ab71 100644 --- a/packages/viewer/src/store/use-viewer.ts +++ b/packages/viewer/src/store/use-viewer.ts @@ -1,6 +1,17 @@ 'use client' -import type { AnyNode, BaseNode, BuildingNode, LevelNode, ZoneNode } from '@pascal-app/core' +import type { + AnyNode, + AnyNodeId, + BaseNode, + BuildingNode, + LevelNode, + MaterialSchema, + RoofSurfaceMaterialRole, + StairSurfaceMaterialRole, + WallSurfaceSide, + ZoneNode, +} from '@pascal-app/core' import type { Object3D } from 'three' import { create } from 'zustand' @@ -18,10 +29,57 @@ type Outliner = { hoveredObjects: Object3D[] } +type MaterialPreview = + | { + nodeId: AnyNodeId + target: 'wall' + role: WallSurfaceSide + material?: MaterialSchema + materialPreset?: string + } + | { + nodeId: AnyNodeId + target: 'roof' + role: RoofSurfaceMaterialRole + material?: MaterialSchema + materialPreset?: string + } + | { + nodeId: AnyNodeId + target: 'stair' + role: StairSurfaceMaterialRole + material?: MaterialSchema + materialPreset?: string + } + | { + nodeId: AnyNodeId + target: 'fence' | 'slab' | 'ceiling' + role: 'surface' + material?: MaterialSchema + materialPreset?: string + } + | null + +function isSameMaterialPreview(left: MaterialPreview, right: MaterialPreview) { + if (left === right) return true + if (!left || !right) return false + + return ( + left.nodeId === right.nodeId && + left.target === right.target && + left.role === right.role && + left.material === right.material && + left.materialPreset === right.materialPreset + ) +} + type ViewerState = { selection: SelectionPath previewSelectedIds: BaseNode['id'][] setPreviewSelectedIds: (ids: BaseNode['id'][]) => void + materialPreview: MaterialPreview + setMaterialPreview: (preview: MaterialPreview) => void + clearMaterialPreview: () => void hoverHighlightMode: 'default' | 'delete' | 'paint-ready' | 'paint-disabled' setHoverHighlightMode: (mode: 'default' | 'delete' | 'paint-ready' | 'paint-disabled') => void hoveredId: AnyNode['id'] | ZoneNode['id'] | null @@ -84,6 +142,15 @@ const useViewer = create()( selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [] }, previewSelectedIds: [], setPreviewSelectedIds: (ids) => set({ previewSelectedIds: ids }), + materialPreview: null, + setMaterialPreview: (materialPreview) => + set((state) => + isSameMaterialPreview(state.materialPreview, materialPreview) + ? state + : { materialPreview }, + ), + clearMaterialPreview: () => + set((state) => (state.materialPreview === null ? state : { materialPreview: null })), hoverHighlightMode: 'default', setHoverHighlightMode: (mode) => set((state) => (state.hoverHighlightMode === mode ? state : { hoverHighlightMode: mode })), @@ -188,6 +255,7 @@ const useViewer = create()( selectedIds: [], }, previewSelectedIds: [], + materialPreview: null, }), outliner: { selectedObjects: [], hoveredObjects: [] }, diff --git a/packages/viewer/src/systems/roof/roof-materials.ts b/packages/viewer/src/systems/roof/roof-materials.ts index 3991050a..8510cfa6 100644 --- a/packages/viewer/src/systems/roof/roof-materials.ts +++ b/packages/viewer/src/systems/roof/roof-materials.ts @@ -8,6 +8,17 @@ import { createMaterial, createMaterialFromPresetRef } from '../../lib/materials export type RoofMaterialArray = [THREE.Material, THREE.Material, THREE.Material, THREE.Material] +const roofMaterialArrayCache = new Map() + +function getSurfaceMaterialSignature( + spec: ReturnType, +): string { + return JSON.stringify({ + material: spec.material ?? null, + materialPreset: spec.materialPreset ?? null, + }) +} + function createResolvedMaterial( material: RoofNode['material'] | RoofSegmentNode['material'] | undefined, materialPreset: string | undefined, @@ -27,6 +38,14 @@ export function getRoofMaterialArray(node: RoofNode): RoofMaterialArray | null { const top = getEffectiveRoofSurfaceMaterial(node, 'top') const edge = getEffectiveRoofSurfaceMaterial(node, 'edge') const wall = getEffectiveRoofSurfaceMaterial(node, 'wall') + const cacheKey = JSON.stringify({ + top: getSurfaceMaterialSignature(top), + edge: getSurfaceMaterialSignature(edge), + wall: getSurfaceMaterialSignature(wall), + }) + + const cached = roofMaterialArrayCache.get(cacheKey) + if (cached) return cached const topMaterial = createResolvedMaterial(top.material, top.materialPreset) const edgeMaterial = createResolvedMaterial(edge.material, edge.materialPreset) @@ -36,11 +55,13 @@ export function getRoofMaterialArray(node: RoofNode): RoofMaterialArray | null { return null } - return [ + const materialArray: RoofMaterialArray = [ edgeMaterial ?? wallMaterial ?? topMaterial ?? new THREE.MeshStandardMaterial(), wallMaterial ?? edgeMaterial ?? topMaterial ?? new THREE.MeshStandardMaterial(), wallMaterial ?? edgeMaterial ?? topMaterial ?? new THREE.MeshStandardMaterial(), topMaterial ?? wallMaterial ?? edgeMaterial ?? new THREE.MeshStandardMaterial(), ] -} + roofMaterialArrayCache.set(cacheKey, materialArray) + return materialArray +} diff --git a/packages/viewer/src/systems/stair/stair-materials.ts b/packages/viewer/src/systems/stair/stair-materials.ts index 81983479..fb63a628 100644 --- a/packages/viewer/src/systems/stair/stair-materials.ts +++ b/packages/viewer/src/systems/stair/stair-materials.ts @@ -12,6 +12,18 @@ import { export type StairBodyMaterials = [THREE.Material, THREE.Material] +const stairBodyMaterialCache = new Map() +const stairRailingMaterialCache = new Map() + +function getSurfaceMaterialSignature( + spec: ReturnType, +): string { + return JSON.stringify({ + material: spec.material ?? null, + materialPreset: spec.materialPreset ?? null, + }) +} + function createResolvedMaterial( material: StairNode['material'] | StairSegmentNode['material'] | undefined, materialPreset: string | undefined, @@ -30,16 +42,32 @@ function createResolvedMaterial( export function getStairBodyMaterials(stair: StairNode): StairBodyMaterials { const tread = getEffectiveStairSurfaceMaterial(stair, 'tread') const side = getEffectiveStairSurfaceMaterial(stair, 'side') + const cacheKey = JSON.stringify({ + tread: getSurfaceMaterialSignature(tread), + side: getSurfaceMaterialSignature(side), + }) - return [ + const cached = stairBodyMaterialCache.get(cacheKey) + if (cached) return cached + + const materials: StairBodyMaterials = [ createResolvedMaterial(tread.material, tread.materialPreset), createResolvedMaterial(side.material, side.materialPreset), ] + + stairBodyMaterialCache.set(cacheKey, materials) + return materials } export function getStairRailingMaterial(stair: StairNode): THREE.Material { const railing = getEffectiveStairSurfaceMaterial(stair, 'railing') - return createResolvedMaterial(railing.material, railing.materialPreset) + const cacheKey = getSurfaceMaterialSignature(railing) + const cached = stairRailingMaterialCache.get(cacheKey) + if (cached) return cached + + const material = createResolvedMaterial(railing.material, railing.materialPreset) + stairRailingMaterialCache.set(cacheKey, material) + return material } export function getStraightStairSegmentBodyMaterials( From fb3de8debc062408242f8df253ddfe413da3ccf1 Mon Sep 17 00:00:00 2001 From: sudhir Date: Wed, 22 Apr 2026 18:16:20 +0530 Subject: [PATCH 05/14] 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) => From 4483957a0de02f0a29529e3b042159862f1934f5 Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 23 Apr 2026 10:21:33 +0530 Subject: [PATCH 06/14] Remove material pickers from side panels --- .../components/ui/panels/ceiling-panel.tsx | 26 +------ .../src/components/ui/panels/door-panel.tsx | 12 --- .../src/components/ui/panels/fence-panel.tsx | 26 +------ .../src/components/ui/panels/roof-panel.tsx | 54 ------------- .../ui/panels/roof-segment-panel.tsx | 25 ------ .../src/components/ui/panels/slab-panel.tsx | 26 +------ .../src/components/ui/panels/stair-panel.tsx | 54 ------------- .../ui/panels/stair-segment-panel.tsx | 25 ------ .../src/components/ui/panels/wall-panel.tsx | 76 +------------------ .../src/components/ui/panels/window-panel.tsx | 12 --- 10 files changed, 4 insertions(+), 332 deletions(-) diff --git a/packages/editor/src/components/ui/panels/ceiling-panel.tsx b/packages/editor/src/components/ui/panels/ceiling-panel.tsx index 43365ec1..ee9991eb 100755 --- a/packages/editor/src/components/ui/panels/ceiling-panel.tsx +++ b/packages/editor/src/components/ui/panels/ceiling-panel.tsx @@ -1,13 +1,12 @@ 'use client' -import { type AnyNode, type CeilingNode, type MaterialSchema, useScene } from '@pascal-app/core' +import { type AnyNode, type CeilingNode, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { Edit, Move, Plus, Trash2 } from 'lucide-react' import { useCallback, useEffect } from 'react' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { ActionButton, ActionGroup } from '../controls/action-button' -import { MaterialPicker } from '../controls/material-picker' import { PanelSection } from '../controls/panel-section' import { SliderControl } from '../controls/slider-control' import { PanelWrapper } from './panel-wrapper' @@ -32,20 +31,6 @@ export function CeilingPanel() { [selectedId, updateNode], ) - const handleMaterialChange = useCallback( - (material: MaterialSchema) => { - handleUpdate({ material, materialPreset: undefined }) - }, - [handleUpdate], - ) - - const handleMaterialPresetChange = useCallback( - (materialPreset: string) => { - handleUpdate({ materialPreset, material: undefined }) - }, - [handleUpdate], - ) - const handleClose = useCallback(() => { setSelection({ selectedIds: [] }) setEditingHole(null) @@ -257,15 +242,6 @@ export function CeilingPanel() {
- - - } label="Move" onClick={handleMove} /> diff --git a/packages/editor/src/components/ui/panels/door-panel.tsx b/packages/editor/src/components/ui/panels/door-panel.tsx index 4c098d72..e02f13c7 100755 --- a/packages/editor/src/components/ui/panels/door-panel.tsx +++ b/packages/editor/src/components/ui/panels/door-panel.tsx @@ -5,7 +5,6 @@ import { type AnyNodeId, DoorNode, emitter, - type MaterialSchema, useScene, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' @@ -15,7 +14,6 @@ import { usePresetsAdapter } from '../../../contexts/presets-context' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { ActionButton, ActionGroup } from '../controls/action-button' -import { MaterialPicker } from '../controls/material-picker' import { MetricControl } from '../controls/metric-control' import { PanelSection } from '../controls/panel-section' import { SegmentedControl } from '../controls/segmented-control' @@ -46,13 +44,6 @@ export function DoorPanel() { [selectedId, updateNode], ) - const handleMaterialChange = useCallback( - (material: MaterialSchema) => { - handleUpdate({ material }) - }, - [handleUpdate], - ) - const handleClose = useCallback(() => { setSelection({ selectedIds: [] }) }, [setSelection]) @@ -592,9 +583,6 @@ export function DoorPanel() { /> - - - ) } diff --git a/packages/editor/src/components/ui/panels/fence-panel.tsx b/packages/editor/src/components/ui/panels/fence-panel.tsx index 4e2785da..856e5592 100644 --- a/packages/editor/src/components/ui/panels/fence-panel.tsx +++ b/packages/editor/src/components/ui/panels/fence-panel.tsx @@ -1,9 +1,8 @@ 'use client' -import { type AnyNode, type AnyNodeId, type FenceNode, type MaterialSchema, useScene } from '@pascal-app/core' +import { type AnyNode, type AnyNodeId, type FenceNode, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { useCallback } from 'react' -import { MaterialPicker } from '../controls/material-picker' import { PanelSection } from '../controls/panel-section' import { SegmentedControl } from '../controls/segmented-control' import { SliderControl } from '../controls/slider-control' @@ -67,20 +66,6 @@ export function FencePanel() { setSelection({ selectedIds: [] }) }, [setSelection]) - const handleMaterialPresetChange = useCallback( - (materialPreset: string) => { - handleUpdate({ materialPreset, material: undefined }) - }, - [handleUpdate], - ) - - const handleCustomMaterialChange = useCallback( - (material: MaterialSchema) => { - handleUpdate({ material, materialPreset: undefined }) - }, - [handleUpdate], - ) - if (!(node && node.type === 'fence' && selectedId && selectedCount === 1)) return null const dx = node.end[0] - node.start[0] @@ -204,15 +189,6 @@ export function FencePanel() { /> - - - ) } diff --git a/packages/editor/src/components/ui/panels/roof-panel.tsx b/packages/editor/src/components/ui/panels/roof-panel.tsx index f546a8f3..adab45b3 100755 --- a/packages/editor/src/components/ui/panels/roof-panel.tsx +++ b/packages/editor/src/components/ui/panels/roof-panel.tsx @@ -3,8 +3,6 @@ import { type AnyNode, type AnyNodeId, - getEffectiveRoofSurfaceMaterial, - type MaterialSchema, type RoofNode, RoofNode as RoofNodeSchema, type RoofSegmentNode, @@ -15,11 +13,9 @@ 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' -import { MaterialPicker } from '../controls/material-picker' import { PanelSection } from '../controls/panel-section' import { SliderControl } from '../controls/slider-control' import { PanelWrapper } from './panel-wrapper' @@ -30,9 +26,6 @@ export function RoofPanel() { const updateNode = useScene((s) => s.updateNode) 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, @@ -55,37 +48,6 @@ export function RoofPanel() { [selectedId, updateNode], ) - const materialTargetRole = - selectedMaterialTarget && - selectedMaterialTarget.nodeId === node?.id && - (selectedMaterialTarget.role === 'top' || - selectedMaterialTarget.role === 'edge' || - selectedMaterialTarget.role === 'wall') - ? selectedMaterialTarget.role - : null - const materialPickerValue = - node && materialTargetRole ? getEffectiveRoofSurfaceMaterial(node, materialTargetRole) : {} - - const handleTargetedMaterialChange = useCallback( - (material: MaterialSchema) => { - if (!node || !materialTargetRole) return - setActivePaintTarget('roof') - setActivePaintMaterial({ material, sourceTarget: 'roof' }) - handleUpdate(buildRoofSurfaceMaterialPatch(node, materialTargetRole, material, undefined)) - }, - [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, setActivePaintMaterial, setActivePaintTarget], - ) - const handleClose = useCallback(() => { setSelection({ selectedIds: [] }) }, [setSelection]) @@ -290,22 +252,6 @@ export function RoofPanel() { /> - - {!materialTargetRole ? ( -
- Click the roof surface you want to edit. Materials apply to one target at a time. -
- ) : null} - -
) } diff --git a/packages/editor/src/components/ui/panels/roof-segment-panel.tsx b/packages/editor/src/components/ui/panels/roof-segment-panel.tsx index de2a1440..88c1887e 100755 --- a/packages/editor/src/components/ui/panels/roof-segment-panel.tsx +++ b/packages/editor/src/components/ui/panels/roof-segment-panel.tsx @@ -3,7 +3,6 @@ import { type AnyNode, type AnyNodeId, - type MaterialSchema, type RoofSegmentNode, RoofSegmentNode as RoofSegmentNodeSchema, type RoofType, @@ -15,7 +14,6 @@ import { useCallback } from 'react' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { ActionButton, ActionGroup } from '../controls/action-button' -import { MaterialPicker } from '../controls/material-picker' import { PanelSection } from '../controls/panel-section' import { SegmentedControl } from '../controls/segmented-control' import { SliderControl } from '../controls/slider-control' @@ -52,20 +50,6 @@ export function RoofSegmentPanel() { [selectedId, updateNode], ) - const handleMaterialChange = useCallback( - (material: MaterialSchema) => { - handleUpdate({ material, materialPreset: undefined }) - }, - [handleUpdate], - ) - - const handleMaterialPresetChange = useCallback( - (materialPreset: string) => { - handleUpdate({ materialPreset, material: undefined }) - }, - [handleUpdate], - ) - const handleClose = useCallback(() => { setSelection({ selectedIds: [] }) }, [setSelection]) @@ -322,15 +306,6 @@ export function RoofSegmentPanel() { /> - - - ) } diff --git a/packages/editor/src/components/ui/panels/slab-panel.tsx b/packages/editor/src/components/ui/panels/slab-panel.tsx index bdd4071f..b44b1a64 100755 --- a/packages/editor/src/components/ui/panels/slab-panel.tsx +++ b/packages/editor/src/components/ui/panels/slab-panel.tsx @@ -1,13 +1,12 @@ 'use client' -import { type AnyNode, type MaterialSchema, type SlabNode, useScene } from '@pascal-app/core' +import { type AnyNode, type SlabNode, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { Edit, Move, Plus, Trash2 } from 'lucide-react' import { useCallback, useEffect } from 'react' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { ActionButton, ActionGroup } from '../controls/action-button' -import { MaterialPicker } from '../controls/material-picker' import { PanelSection } from '../controls/panel-section' import { SliderControl } from '../controls/slider-control' import { PanelWrapper } from './panel-wrapper' @@ -32,20 +31,6 @@ export function SlabPanel() { [selectedId, updateNode], ) - const handleMaterialPresetChange = useCallback( - (materialPreset: string) => { - handleUpdate({ materialPreset, material: undefined }) - }, - [handleUpdate], - ) - - const handleCustomMaterialChange = useCallback( - (material: MaterialSchema) => { - handleUpdate({ material, materialPreset: undefined }) - }, - [handleUpdate], - ) - const handleClose = useCallback(() => { setSelection({ selectedIds: [] }) setEditingHole(null) @@ -257,15 +242,6 @@ export function SlabPanel() { />
- - - } label="Move" onClick={handleMove} /> diff --git a/packages/editor/src/components/ui/panels/stair-panel.tsx b/packages/editor/src/components/ui/panels/stair-panel.tsx index ea3f1d26..362c42aa 100644 --- a/packages/editor/src/components/ui/panels/stair-panel.tsx +++ b/packages/editor/src/components/ui/panels/stair-panel.tsx @@ -3,9 +3,7 @@ import { type AnyNode, type AnyNodeId, - getEffectiveStairSurfaceMaterial, type LevelNode, - type MaterialSchema, type StairNode, type StairRailingMode, type StairSlabOpeningMode, @@ -20,12 +18,10 @@ 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' import { ActionButton, ActionGroup } from '../controls/action-button' -import { MaterialPicker } from '../controls/material-picker' import { MetricControl } from '../controls/metric-control' import { PanelSection } from '../controls/panel-section' import { SegmentedControl } from '../controls/segmented-control' @@ -64,9 +60,6 @@ export function StairPanel() { const createNode = useScene((s) => s.createNode) 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, @@ -97,37 +90,6 @@ export function StairPanel() { [selectedId, updateNode], ) - const materialTargetRole = - selectedMaterialTarget && - selectedMaterialTarget.nodeId === node?.id && - (selectedMaterialTarget.role === 'railing' || - selectedMaterialTarget.role === 'tread' || - selectedMaterialTarget.role === 'side') - ? selectedMaterialTarget.role - : null - const materialPickerValue = - node && materialTargetRole ? getEffectiveStairSurfaceMaterial(node, materialTargetRole) : {} - - const handleTargetedMaterialChange = useCallback( - (material: MaterialSchema) => { - if (!node || !materialTargetRole) return - setActivePaintTarget('stair') - setActivePaintMaterial({ material, sourceTarget: 'stair' }) - handleUpdate(buildStairSurfaceMaterialPatch(node, materialTargetRole, material, undefined)) - }, - [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, setActivePaintMaterial, setActivePaintTarget], - ) - const handleClose = useCallback(() => { setSelection({ selectedIds: [] }) }, [setSelection]) @@ -590,22 +552,6 @@ export function StairPanel() { /> - - {!materialTargetRole ? ( -
- Click the stair surface you want to edit. Materials apply to one target at a time. -
- ) : null} - -
) } diff --git a/packages/editor/src/components/ui/panels/stair-segment-panel.tsx b/packages/editor/src/components/ui/panels/stair-segment-panel.tsx index 17c84650..c783bd98 100644 --- a/packages/editor/src/components/ui/panels/stair-segment-panel.tsx +++ b/packages/editor/src/components/ui/panels/stair-segment-panel.tsx @@ -4,7 +4,6 @@ import { type AnyNode, type AnyNodeId, type AttachmentSide, - type MaterialSchema, type StairSegmentNode, StairSegmentNode as StairSegmentNodeSchema, type StairSegmentType, @@ -16,7 +15,6 @@ import { useCallback } from 'react' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { ActionButton, ActionGroup } from '../controls/action-button' -import { MaterialPicker } from '../controls/material-picker' import { PanelSection } from '../controls/panel-section' import { SegmentedControl } from '../controls/segmented-control' import { SliderControl } from '../controls/slider-control' @@ -61,20 +59,6 @@ export function StairSegmentPanel() { [selectedId, updateNode], ) - const handleMaterialChange = useCallback( - (material: MaterialSchema) => { - handleUpdate({ material, materialPreset: undefined }) - }, - [handleUpdate], - ) - - const handleMaterialPresetChange = useCallback( - (materialPreset: string) => { - handleUpdate({ materialPreset, material: undefined }) - }, - [handleUpdate], - ) - const handleClose = useCallback(() => { setSelection({ selectedIds: [] }) }, [setSelection]) @@ -336,15 +320,6 @@ export function StairSegmentPanel() { /> - - - ) } diff --git a/packages/editor/src/components/ui/panels/wall-panel.tsx b/packages/editor/src/components/ui/panels/wall-panel.tsx index 899c92fc..c1fae2e7 100755 --- a/packages/editor/src/components/ui/panels/wall-panel.tsx +++ b/packages/editor/src/components/ui/panels/wall-panel.tsx @@ -3,24 +3,19 @@ import { type AnyNode, type AnyNodeId, - getEffectiveWallSurfaceMaterial, getClampedWallCurveOffset, getMaxWallCurveOffset, getWallCurveLength, - getWallSurfaceMaterialSignature, normalizeWallCurveOffset, - type MaterialSchema, useScene, type WallNode, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { Move, Spline } from 'lucide-react' -import { useCallback, useMemo } from 'react' +import { useCallback } 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' import { PanelSection } from '../controls/panel-section' import { SliderControl } from '../controls/slider-control' import { PanelWrapper } from './panel-wrapper' @@ -31,9 +26,6 @@ export function WallPanel() { const updateNode = useScene((s) => s.updateNode) 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, @@ -64,35 +56,6 @@ export function WallPanel() { [selectedId, updateNode], ) - const effectiveInteriorMaterial = useMemo( - () => (node ? getEffectiveWallSurfaceMaterial(node, 'interior') : {}), - [node], - ) - const effectiveExteriorMaterial = useMemo( - () => (node ? getEffectiveWallSurfaceMaterial(node, 'exterior') : {}), - [node], - ) - const surfaceMaterialsMatch = useMemo( - () => - getWallSurfaceMaterialSignature(effectiveInteriorMaterial) === - getWallSurfaceMaterialSignature(effectiveExteriorMaterial), - [effectiveExteriorMaterial, effectiveInteriorMaterial], - ) - const materialTargetSide = - selectedMaterialTarget && - selectedMaterialTarget.nodeId === node?.id && - (selectedMaterialTarget.role === 'interior' || selectedMaterialTarget.role === 'exterior') - ? selectedMaterialTarget.role - : null - const materialPickerValue = - materialTargetSide === 'interior' - ? effectiveInteriorMaterial - : materialTargetSide === 'exterior' - ? effectiveExteriorMaterial - : surfaceMaterialsMatch - ? effectiveInteriorMaterial - : {} - const handleUpdateLength = useCallback( (newLength: number) => { if (!node || newLength <= 0) return @@ -116,26 +79,6 @@ export function WallPanel() { [node, handleUpdate], ) - const handleMaterialPresetChange = useCallback( - (materialPreset: string) => { - if (!node || !materialTargetSide) return - setActivePaintTarget('wall') - setActivePaintMaterial({ materialPreset, sourceTarget: 'wall' }) - handleUpdate(buildWallSurfaceMaterialPatch(node, materialTargetSide, undefined, materialPreset)) - }, - [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, setActivePaintMaterial, setActivePaintTarget], - ) - const handleClose = useCallback(() => { setSelection({ selectedIds: [] }) }, [setSelection]) @@ -217,23 +160,6 @@ export function WallPanel() { )} - - {!materialTargetSide ? ( -
- Click the wall face you want to edit. Materials now apply to one side at a time. -
- ) : null} - -
- } label="Move" onClick={handleMove} /> diff --git a/packages/editor/src/components/ui/panels/window-panel.tsx b/packages/editor/src/components/ui/panels/window-panel.tsx index cc346cf0..a41891b0 100755 --- a/packages/editor/src/components/ui/panels/window-panel.tsx +++ b/packages/editor/src/components/ui/panels/window-panel.tsx @@ -4,7 +4,6 @@ import { type AnyNode, type AnyNodeId, emitter, - type MaterialSchema, useScene, WindowNode, } from '@pascal-app/core' @@ -15,7 +14,6 @@ import { usePresetsAdapter } from '../../../contexts/presets-context' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { ActionButton, ActionGroup } from '../controls/action-button' -import { MaterialPicker } from '../controls/material-picker' import { MetricControl } from '../controls/metric-control' import { PanelSection } from '../controls/panel-section' import { SliderControl } from '../controls/slider-control' @@ -45,13 +43,6 @@ export function WindowPanel() { [selectedId, updateNode], ) - const handleMaterialChange = useCallback( - (material: MaterialSchema) => { - handleUpdate({ material }) - }, - [handleUpdate], - ) - const handleClose = useCallback(() => { setSelection({ selectedIds: [] }) }, [setSelection]) @@ -431,9 +422,6 @@ export function WindowPanel() { /> - - - ) } From 2876f39876abbdc77e04cb0cc9c83d2fb790ff9f Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 23 Apr 2026 12:48:50 +0530 Subject: [PATCH 07/14] Refactor paint materials to use category-based compatibility --- packages/core/src/index.ts | 5 +- packages/core/src/material-library.ts | 120 +++++----- .../src/components/ui/action-menu/index.tsx | 47 +--- .../ui/controls/material-picker.tsx | 212 ++++++------------ .../src/components/ui/panels/paint-panel.tsx | 164 ++++++++++++++ .../components/ui/panels/panel-manager.tsx | 13 ++ packages/editor/src/lib/material-paint.ts | 32 ++- packages/editor/src/store/use-editor.tsx | 4 + 8 files changed, 358 insertions(+), 239 deletions(-) create mode 100644 packages/editor/src/components/ui/panels/paint-panel.tsx diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8c7778ba..d8d80978 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -40,11 +40,14 @@ export { export { baseMaterial, glassMaterial } from './materials' export { getCatalogMaterialById, + getMaterialCategoryTargets, getLibraryMaterialIdFromRef, getMaterialPresetByRef, - getMaterialsForTarget, + getMaterialsForCategory, LIBRARY_MATERIAL_REF_PREFIX, MATERIAL_CATALOG, + MATERIAL_CATEGORIES, + type MaterialCategory, type MaterialCatalogItem, toLibraryMaterialRef, } from './material-library' diff --git a/packages/core/src/material-library.ts b/packages/core/src/material-library.ts index f5455c96..a52ac07f 100644 --- a/packages/core/src/material-library.ts +++ b/packages/core/src/material-library.ts @@ -7,51 +7,63 @@ import { export type MaterialCatalogItem = { id: string label: string + category: MaterialCategory description?: string - targets: MaterialTarget[] previewThumbnailUrl?: string previewColor?: string preset: MaterialPresetPayload } -const WALL_TARGETS: MaterialTarget[] = [ - MaterialTargetSchema.enum.wall, -] +export const MATERIAL_CATEGORIES = [ + 'wood', + 'wallpaper', + 'parquet', + 'granite', + 'marble', + 'other', +] as const +export type MaterialCategory = (typeof MATERIAL_CATEGORIES)[number] -const SLAB_TARGETS: MaterialTarget[] = [ - MaterialTargetSchema.enum.slab, -] - -const WALL_AND_SLAB_TARGETS: MaterialTarget[] = [ - MaterialTargetSchema.enum.wall, - MaterialTargetSchema.enum.slab, -] - -const STAIR_TARGETS: MaterialTarget[] = [ - MaterialTargetSchema.enum.stair, - MaterialTargetSchema.enum['stair-segment'], -] - -const STAIR_AND_FENCE_TARGETS: MaterialTarget[] = [ - ...STAIR_TARGETS, - MaterialTargetSchema.enum.fence, -] - -const ROOF_TARGETS: MaterialTarget[] = [ - MaterialTargetSchema.enum.roof, - MaterialTargetSchema.enum['roof-segment'], -] - -const CEILING_TARGETS: MaterialTarget[] = [ - MaterialTargetSchema.enum.ceiling, -] +export const MATERIAL_CATEGORY_TARGETS: Record = { + wood: [ + MaterialTargetSchema.enum.wall, + MaterialTargetSchema.enum.slab, + MaterialTargetSchema.enum.stair, + MaterialTargetSchema.enum['stair-segment'], + MaterialTargetSchema.enum.fence, + MaterialTargetSchema.enum.roof, + MaterialTargetSchema.enum['roof-segment'], + ], + wallpaper: [MaterialTargetSchema.enum.wall], + parquet: [ + MaterialTargetSchema.enum.slab, + MaterialTargetSchema.enum.stair, + MaterialTargetSchema.enum['stair-segment'], + ], + granite: [MaterialTargetSchema.enum.slab], + marble: [ + MaterialTargetSchema.enum.slab, + MaterialTargetSchema.enum.stair, + MaterialTargetSchema.enum['stair-segment'], + ], + other: [ + MaterialTargetSchema.enum.wall, + MaterialTargetSchema.enum.roof, + MaterialTargetSchema.enum['roof-segment'], + MaterialTargetSchema.enum.stair, + MaterialTargetSchema.enum['stair-segment'], + MaterialTargetSchema.enum.fence, + MaterialTargetSchema.enum.slab, + MaterialTargetSchema.enum.ceiling, + ], +} export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'wall-wood1', label: 'Wood', + category: 'wood', description: 'Warm wood finish', - targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS, ...ROOF_TARGETS], previewThumbnailUrl: '/material/wood1/wood1_thumbnail.webp', preset: { maps: { @@ -85,8 +97,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'wall-wood2', label: 'Wood', + category: 'wood', description: 'Textured wood finish', - targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS, ...ROOF_TARGETS], previewThumbnailUrl: '/material/wood2/wood2_thumbnail.webp', preset: { maps: { @@ -121,8 +133,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'wall-wood3', label: 'Wood', + category: 'wood', description: 'Knotted timber finish', - targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS, ...ROOF_TARGETS], previewThumbnailUrl: '/material/wood3/wood3_thumbnail.webp', preset: { maps: { @@ -155,8 +167,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'wall-wood4', label: 'Wood', + category: 'wood', description: 'Oak stretcher finish', - targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS], previewThumbnailUrl: '/material/wood4/wood4_thumbnail.webp', preset: { maps: { @@ -189,8 +201,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'wall-wood5', label: 'Wood', + category: 'wood', description: 'Rich grain wood finish', - targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS], previewThumbnailUrl: '/material/wood5/wood5_thumnail.webp', preset: { maps: { @@ -225,8 +237,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'wall-granite1', label: 'Granite', + category: 'granite', description: 'Polished granite finish', - targets: SLAB_TARGETS, previewThumbnailUrl: '/material/granite1/granite_thumbnail.webp', preset: { maps: { @@ -259,8 +271,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'wall-marble1', label: 'Marble', + category: 'marble', description: 'Smooth marble finish', - targets: [...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS], previewThumbnailUrl: '/material/marble1/marble1_thumbnail.webp', preset: { maps: { @@ -293,8 +305,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'wall-marble2', label: 'Marble', + category: 'marble', description: 'Soft marble finish', - targets: [...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS], previewThumbnailUrl: '/material/marble2/marble2_thumbnail.webp', preset: { maps: { @@ -327,8 +339,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'wall-parquet1', label: 'Parquet', + category: 'parquet', description: 'Parquet wood finish', - targets: SLAB_TARGETS, previewThumbnailUrl: '/material/parquet1/parquet_thumnail.webp', preset: { maps: { @@ -361,8 +373,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'wall-parquet2', label: 'Parquet', + category: 'parquet', description: 'Soft parquet finish', - targets: SLAB_TARGETS, previewThumbnailUrl: '/material/parquet2/parquet2_thumbnail.webp', preset: { maps: { @@ -395,8 +407,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'wall-wallpaper1', label: 'Wallpaper', + category: 'wallpaper', description: 'Soft wallpaper finish', - targets: WALL_TARGETS, previewThumbnailUrl: '/material/wallpaper1/wallpaper1_thumbnail.webp', preset: { maps: { @@ -430,8 +442,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'wall-wallpaper2', label: 'Wallpaper', + category: 'wallpaper', description: 'Decorative wallpaper finish', - targets: WALL_TARGETS, previewThumbnailUrl: '/material/wallpaper2/wallpaper2_thumnail.webp', preset: { maps: { @@ -464,8 +476,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'wall-wallpaper3', label: 'Wallpaper', + category: 'wallpaper', description: 'Patterned wallpaper finish', - targets: WALL_TARGETS, previewThumbnailUrl: '/material/wallpaper3/wallpaper3_thumbnail.webp', preset: { maps: { @@ -498,14 +510,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'preset-white', label: 'White', + category: 'other', description: 'Clean painted finish', - targets: [ - ...WALL_TARGETS, - ...SLAB_TARGETS, - ...ROOF_TARGETS, - ...STAIR_AND_FENCE_TARGETS, - ...CEILING_TARGETS, - ], previewColor: '#ffffff', preset: { maps: {}, @@ -536,8 +542,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'preset-metal', label: 'Metal', + category: 'other', description: 'Brushed metal finish', - targets: [...WALL_TARGETS, ...SLAB_TARGETS], previewColor: '#c0c0c0', preset: { maps: {}, @@ -568,8 +574,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'preset-glass', label: 'Glass', + category: 'other', description: 'Light glass finish', - targets: [...WALL_TARGETS, ...SLAB_TARGETS], previewColor: '#87ceeb', preset: { maps: {}, @@ -599,8 +605,12 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, ] -export function getMaterialsForTarget(target: MaterialTarget): MaterialCatalogItem[] { - return MATERIAL_CATALOG.filter((item) => item.targets.includes(target)) +export function getMaterialsForCategory(category: MaterialCategory): MaterialCatalogItem[] { + return MATERIAL_CATALOG.filter((item) => item.category === category) +} + +export function getMaterialCategoryTargets(category: MaterialCategory): MaterialTarget[] { + return MATERIAL_CATEGORY_TARGETS[category] } export function getCatalogMaterialById(id?: string): MaterialCatalogItem | undefined { diff --git a/packages/editor/src/components/ui/action-menu/index.tsx b/packages/editor/src/components/ui/action-menu/index.tsx index 5eb7bf49..e1c59cb4 100644 --- a/packages/editor/src/components/ui/action-menu/index.tsx +++ b/packages/editor/src/components/ui/action-menu/index.tsx @@ -1,16 +1,13 @@ 'use client' -import { getMaterialsForTarget, toLibraryMaterialRef, useScene } from '@pascal-app/core' +import { 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 { resolvePaintTargetFromSelection } from './../../../lib/material-paint' import { cn } from './../../../lib/utils' import useEditor from './../../../store/use-editor' import { ItemCatalog } from '../item-catalog/item-catalog' @@ -40,45 +37,17 @@ function PaintMaterialTray() { } }, [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 }) + onChange={(material, category) => { + setActivePaintMaterial({ material, category, sourceTarget: activePaintTarget }) }} - onSelectMaterialPreset={(materialPreset) => { - setActivePaintMaterial({ materialPreset, sourceTarget: activePaintTarget }) + onSelectMaterialPreset={(materialPreset, category) => { + setActivePaintMaterial({ materialPreset, category, sourceTarget: activePaintTarget }) }} - selectedMaterialPreset={ - activePaintMaterial?.sourceTarget === activePaintTarget - ? activePaintMaterial.materialPreset - : undefined - } - value={ - activePaintMaterial?.sourceTarget === activePaintTarget - ? activePaintMaterial.material - : undefined - } + selectedMaterialPreset={activePaintMaterial?.materialPreset} + value={activePaintMaterial?.material} />
) diff --git a/packages/editor/src/components/ui/controls/material-picker.tsx b/packages/editor/src/components/ui/controls/material-picker.tsx index e189276e..dbe0a6e3 100755 --- a/packages/editor/src/components/ui/controls/material-picker.tsx +++ b/packages/editor/src/components/ui/controls/material-picker.tsx @@ -1,55 +1,69 @@ 'use client' import { - getMaterialsForTarget, + getCatalogMaterialById, + getLibraryMaterialIdFromRef, + getMaterialsForCategory, + MATERIAL_CATEGORIES, toLibraryMaterialRef, + type MaterialCategory, type MaterialSchema, - type MaterialTarget, } from '@pascal-app/core' import { useEffect, useRef, useState } from 'react' +import useEditor from '../../../store/use-editor' type MaterialPickerProps = { - nodeType?: MaterialTarget value?: MaterialSchema selectedMaterialPreset?: string - onChange?: (material: MaterialSchema) => void - onSelectMaterialPreset?: (materialPreset: string) => void - hideSideControl?: boolean + onChange?: (material: MaterialSchema, category: MaterialCategory) => void + onSelectMaterialPreset?: (materialPreset: string, category: MaterialCategory) => void disabled?: boolean } export function MaterialPicker({ - nodeType, value, selectedMaterialPreset, onChange, onSelectMaterialPreset, - hideSideControl = false, disabled = false, }: MaterialPickerProps) { + const setPaintPanelOpen = useEditor((state) => state.setPaintPanelOpen) const [showCustom, setShowCustom] = useState(!!value?.properties) + const [selectedCategory, setSelectedCategory] = useState(MATERIAL_CATEGORIES[0]) const catalogScrollRef = useRef(null) - const catalogItems = nodeType ? getMaterialsForTarget(nodeType) : [] + const catalogItems = + selectedCategory === 'other' + ? getMaterialsForCategory('other') + : getMaterialsForCategory(selectedCategory) useEffect(() => { setShowCustom(!!value?.properties && !selectedMaterialPreset) }, [selectedMaterialPreset, value?.properties]) - const currentProps = value?.properties || { - color: '#ffffff', - roughness: 0.5, - metalness: 0, - opacity: 1, - transparent: false, - side: 'front' as const, - } + useEffect(() => { + if (!selectedMaterialPreset && value?.properties) { + setSelectedCategory('other') + return + } + + const catalogId = + getLibraryMaterialIdFromRef(selectedMaterialPreset) ?? value?.id ?? undefined + const selectedCatalogEntry = getCatalogMaterialById(catalogId) + if (selectedCatalogEntry?.category) { + setSelectedCategory(selectedCatalogEntry.category) + } + }, [selectedMaterialPreset, value?.id]) + const selectedCatalogId = selectedMaterialPreset ?? (value?.id ? toLibraryMaterialRef(value.id) : undefined) const handleCatalogSelect = (materialId: string) => { if (disabled) return setShowCustom(false) - onSelectMaterialPreset?.(toLibraryMaterialRef(materialId)) + setPaintPanelOpen(false) + const category = getCatalogMaterialById(materialId)?.category + if (!category) return + onSelectMaterialPreset?.(toLibraryMaterialRef(materialId), category) } useEffect(() => { @@ -76,40 +90,51 @@ export function MaterialPicker({ const handleCustomOpen = () => { if (disabled) return setShowCustom(true) - onChange?.({ - preset: 'custom', - properties: { - color: value?.properties?.color || '#ffffff', - roughness: value?.properties?.roughness ?? 0.5, - metalness: value?.properties?.metalness ?? 0, - opacity: value?.properties?.opacity ?? 1, - transparent: value?.properties?.transparent ?? false, - side: value?.properties?.side ?? 'front', + setPaintPanelOpen(true) + onChange?.( + { + preset: 'custom', + properties: { + color: value?.properties?.color || '#ffffff', + roughness: value?.properties?.roughness ?? 0.5, + metalness: value?.properties?.metalness ?? 0, + opacity: value?.properties?.opacity ?? 1, + transparent: value?.properties?.transparent ?? false, + side: value?.properties?.side ?? 'front', + }, }, - }) - } - - const handlePropertyChange = ( - prop: keyof typeof currentProps, - val: (typeof currentProps)[keyof typeof currentProps], - ) => { - if (disabled) return - onChange?.({ - preset: 'custom', - properties: { - ...currentProps, - [prop]: val, - }, - }) + 'other', + ) } return (
{(catalogItems.length > 0 || onChange) && (
- {catalogItems.length > 0 ? ( -
Library
- ) : null} +
+ {MATERIAL_CATEGORIES.map((category) => ( + + ))} +
))} - {onChange ? ( + {selectedCategory === 'other' && onChange ? (
)} - - {showCustom && onChange && ( -
-
- - handlePropertyChange('color', e.target.value)} - type="color" - value={currentProps.color} - /> - handlePropertyChange('color', e.target.value)} - type="text" - value={currentProps.color} - /> -
- -
- - handlePropertyChange('roughness', Number.parseFloat(e.target.value))} - step={0.01} - type="range" - value={currentProps.roughness} - /> - - {currentProps.roughness.toFixed(2)} - -
- -
- - handlePropertyChange('metalness', Number.parseFloat(e.target.value))} - step={0.01} - type="range" - value={currentProps.metalness} - /> - - {currentProps.metalness.toFixed(2)} - -
- -
- - { - const opacity = Number.parseFloat(e.target.value) - handlePropertyChange('opacity', opacity) - if (opacity < 1 && !currentProps.transparent) { - handlePropertyChange('transparent', true) - } - }} - step={0.01} - type="range" - value={currentProps.opacity} - /> - - {currentProps.opacity.toFixed(2)} - -
- - {!hideSideControl && ( -
- - -
- )} -
- )}
) } diff --git a/packages/editor/src/components/ui/panels/paint-panel.tsx b/packages/editor/src/components/ui/panels/paint-panel.tsx new file mode 100644 index 00000000..0df8c677 --- /dev/null +++ b/packages/editor/src/components/ui/panels/paint-panel.tsx @@ -0,0 +1,164 @@ +'use client' + +import useEditor from '../../../store/use-editor' +import { Input } from '../primitives/input' +import { PanelSection } from '../controls/panel-section' +import { PanelWrapper } from './panel-wrapper' + +function buildDefaultCustomMaterial() { + return { + preset: 'custom' as const, + properties: { + color: '#ffffff', + roughness: 0.5, + metalness: 0, + opacity: 1, + transparent: false, + side: 'front' as const, + }, + } +} + +export function PaintPanel() { + const activePaintMaterial = useEditor((state) => state.activePaintMaterial) + const activePaintTarget = useEditor((state) => state.activePaintTarget) + const setActivePaintMaterial = useEditor((state) => state.setActivePaintMaterial) + const setPaintPanelOpen = useEditor((state) => state.setPaintPanelOpen) + + const customMaterial = + activePaintMaterial?.material?.properties && !activePaintMaterial.materialPreset + ? activePaintMaterial.material + : null + + if (!customMaterial) return null + + const currentProps = customMaterial.properties ?? buildDefaultCustomMaterial().properties + + const updateCustomMaterial = ( + updates: Partial, + nextTransparent = currentProps.transparent, + ) => { + setActivePaintMaterial({ + material: { + preset: 'custom', + properties: { + ...currentProps, + ...updates, + transparent: nextTransparent, + }, + }, + category: 'other', + sourceTarget: activePaintMaterial?.sourceTarget ?? activePaintTarget, + }) + } + + return ( + setPaintPanelOpen(false)} + title="Material" + width={320} + > + +
+
+ +
+ updateCustomMaterial({ color: e.target.value })} + type="color" + value={currentProps.color} + /> + updateCustomMaterial({ color: e.target.value })} + value={currentProps.color} + /> +
+
+ +
+
+ + + {currentProps.roughness.toFixed(2)} + +
+ updateCustomMaterial({ roughness: Number.parseFloat(e.target.value) })} + step={0.01} + type="range" + value={currentProps.roughness} + /> +
+ +
+
+ + + {currentProps.metalness.toFixed(2)} + +
+ updateCustomMaterial({ metalness: Number.parseFloat(e.target.value) })} + step={0.01} + type="range" + value={currentProps.metalness} + /> +
+ +
+
+ + + {currentProps.opacity.toFixed(2)} + +
+ { + const opacity = Number.parseFloat(e.target.value) + updateCustomMaterial({ opacity }, opacity < 1 || currentProps.transparent) + }} + step={0.01} + type="range" + value={currentProps.opacity} + /> +
+ +
+ + +
+
+
+
+ ) +} diff --git a/packages/editor/src/components/ui/panels/panel-manager.tsx b/packages/editor/src/components/ui/panels/panel-manager.tsx index 1750c6fc..cc20f364 100755 --- a/packages/editor/src/components/ui/panels/panel-manager.tsx +++ b/packages/editor/src/components/ui/panels/panel-manager.tsx @@ -7,6 +7,7 @@ import { CeilingPanel } from './ceiling-panel' import { DoorPanel } from './door-panel' import { FencePanel } from './fence-panel' import { ItemPanel } from './item-panel' +import { PaintPanel } from './paint-panel' import { ReferencePanel } from './reference-panel' import { RoofPanel } from './roof-panel' import { RoofSegmentPanel } from './roof-segment-panel' @@ -19,6 +20,9 @@ import { WindowPanel } from './window-panel' export function PanelManager() { const selectedIds = useViewer((s) => s.selection.selectedIds) const selectedReferenceId = useEditor((s) => s.selectedReferenceId) + const isPaintPanelOpen = useEditor((s) => s.isPaintPanelOpen) + const mode = useEditor((s) => s.mode) + const activePaintMaterial = useEditor((s) => s.activePaintMaterial) // Only subscribe to the *type* of the single-selected node — string primitive // so we don't re-render on unrelated scene mutations. const selectedNodeType = useScene((s) => { @@ -32,6 +36,15 @@ export function PanelManager() { return } + if ( + isPaintPanelOpen && + mode === 'material-paint' && + activePaintMaterial?.material?.properties && + !activePaintMaterial.materialPreset + ) { + return + } + // Show appropriate panel based on selected node type if (selectedNodeType) { switch (selectedNodeType) { diff --git a/packages/editor/src/lib/material-paint.ts b/packages/editor/src/lib/material-paint.ts index 88bce034..e1f52156 100644 --- a/packages/editor/src/lib/material-paint.ts +++ b/packages/editor/src/lib/material-paint.ts @@ -8,6 +8,8 @@ import { getEffectiveStairSurfaceMaterial, getEffectiveWallSurfaceMaterial, getLibraryMaterialIdFromRef, + getMaterialCategoryTargets, + type MaterialCategory, type MaterialSchema, type MaterialTarget, type RoofNode, @@ -29,6 +31,7 @@ export type SingleSurfaceMaterialRole = 'surface' export type ActivePaintMaterial = { material?: MaterialSchema materialPreset?: string + category?: MaterialCategory sourceTarget: PaintableMaterialTarget } @@ -47,6 +50,17 @@ function getCatalogEntryForActivePaintMaterial(material: ActivePaintMaterial | n return getCatalogMaterialById(catalogId) } +function resolvePaintMaterialCategory( + material: MaterialSchema | undefined, + materialPreset: string | undefined, +): MaterialCategory | undefined { + const catalogId = getLibraryMaterialIdFromRef(materialPreset) ?? material?.id ?? undefined + const catalogEntry = getCatalogMaterialById(catalogId) + if (catalogEntry?.category) return catalogEntry.category + if (material || materialPreset === undefined) return 'other' + return undefined +} + export function getActivePaintMaterialLabel(material: ActivePaintMaterial | null | undefined) { return getCatalogEntryForActivePaintMaterial(material)?.label ?? 'Custom' } @@ -58,9 +72,10 @@ export function isActivePaintMaterialCompatible( if (!hasActivePaintMaterial(material)) return false const catalogEntry = getCatalogEntryForActivePaintMaterial(material) - if (!catalogEntry) return true + const category = material?.category ?? catalogEntry?.category + if (!category) return false - return catalogEntry.targets.includes(target) + return getMaterialCategoryTargets(category).includes(target) } export function buildWallSurfaceMaterialPatch( @@ -182,9 +197,10 @@ export function resolveActivePaintMaterialFromSelection(params: { materialPreset: surface.materialPreset, sourceTarget: 'wall', }) - ? { + ? { material: surface.material, materialPreset: surface.materialPreset, + category: resolvePaintMaterialCategory(surface.material, surface.materialPreset), sourceTarget: 'wall', } : null @@ -202,9 +218,10 @@ export function resolveActivePaintMaterialFromSelection(params: { materialPreset: surface.materialPreset, sourceTarget: 'roof', }) - ? { + ? { material: surface.material, materialPreset: surface.materialPreset, + category: resolvePaintMaterialCategory(surface.material, surface.materialPreset), sourceTarget: 'roof', } : null @@ -222,9 +239,10 @@ export function resolveActivePaintMaterialFromSelection(params: { materialPreset: surface.materialPreset, sourceTarget: 'stair', }) - ? { + ? { material: surface.material, materialPreset: surface.materialPreset, + category: resolvePaintMaterialCategory(surface.material, surface.materialPreset), sourceTarget: 'stair', } : null @@ -245,6 +263,10 @@ export function resolveActivePaintMaterialFromSelection(params: { ? { material: selectedNode.material, materialPreset: selectedNode.materialPreset, + category: resolvePaintMaterialCategory( + selectedNode.material, + selectedNode.materialPreset, + ), sourceTarget: target, } : null diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index 96043be3..f6d0488b 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -169,6 +169,8 @@ type EditorState = { primeMaterialPaintFromSelection: () => MaterialPaintSelectionSnapshot hoveredPaintTarget: PaintableMaterialTarget | null setHoveredPaintTarget: (target: PaintableMaterialTarget | null) => void + isPaintPanelOpen: boolean + setPaintPanelOpen: (open: boolean) => void selectedReferenceId: string | null setSelectedReferenceId: (id: string | null) => void // Space detection for cutaway mode @@ -591,6 +593,8 @@ const useEditor = create()( set((state) => state.hoveredPaintTarget === target ? state : { hoveredPaintTarget: target }, ), + isPaintPanelOpen: false, + setPaintPanelOpen: (open) => set({ isPaintPanelOpen: open }), selectedReferenceId: null, setSelectedReferenceId: (id) => set({ selectedReferenceId: id }), spaces: {}, From a5448a00530794475ea39a1b2974bb743a7a874b Mon Sep 17 00:00:00 2001 From: sudhir Date: Fri, 24 Apr 2026 10:43:38 +0530 Subject: [PATCH 08/14] Make material categories horizontally scrollable --- .../ui/controls/material-picker.tsx | 76 +++++++++++++------ 1 file changed, 52 insertions(+), 24 deletions(-) diff --git a/packages/editor/src/components/ui/controls/material-picker.tsx b/packages/editor/src/components/ui/controls/material-picker.tsx index dbe0a6e3..ce743482 100755 --- a/packages/editor/src/components/ui/controls/material-picker.tsx +++ b/packages/editor/src/components/ui/controls/material-picker.tsx @@ -31,6 +31,7 @@ export function MaterialPicker({ const [showCustom, setShowCustom] = useState(!!value?.properties) const [selectedCategory, setSelectedCategory] = useState(MATERIAL_CATEGORIES[0]) const catalogScrollRef = useRef(null) + const categoryScrollRef = useRef(null) const catalogItems = selectedCategory === 'other' ? getMaterialsForCategory('other') @@ -87,6 +88,27 @@ export function MaterialPicker({ } }, [catalogItems.length, onChange, showCustom]) + useEffect(() => { + const container = categoryScrollRef.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) + } + }, []) + const handleCustomOpen = () => { if (disabled) return setShowCustom(true) @@ -110,30 +132,36 @@ export function MaterialPicker({ return (
{(catalogItems.length > 0 || onChange) && ( -
-
- {MATERIAL_CATEGORIES.map((category) => ( - - ))} +
+
+
+ {MATERIAL_CATEGORIES.map((category) => ( + + ))} +
Date: Fri, 24 Apr 2026 12:50:05 +0530 Subject: [PATCH 09/14] Use paint icon for material mode --- apps/editor/public/icons/paint.png | Bin 0 -> 99049 bytes .../components/ui/action-menu/control-modes.tsx | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 apps/editor/public/icons/paint.png diff --git a/apps/editor/public/icons/paint.png b/apps/editor/public/icons/paint.png new file mode 100644 index 0000000000000000000000000000000000000000..d66167bb0e797e64002123951f37d393d85ae334 GIT binary patch literal 99049 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%0Aw?rD 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⁣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

)8tv)wuzvUMopVoV%@N%Zv?qx+ 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 CcrP;m literal 0 HcmV?d00001 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 f85c5e34..f258ef20 100755 --- a/packages/editor/src/components/ui/action-menu/control-modes.tsx +++ b/packages/editor/src/components/ui/action-menu/control-modes.tsx @@ -64,7 +64,7 @@ const controls: ControlConfig[] = [ }, { id: 'material-paint', - iconifyIcon: 'mdi:format-color-fill', + imageSrc: '/icons/paint.png', label: 'Material Paint', shortcut: 'P', color: 'hover:bg-amber-500/20 hover:text-amber-400', From 325f2e460351fc31d38bca0fb25864ceac317ad0 Mon Sep 17 00:00:00 2001 From: sudhir Date: Fri, 24 Apr 2026 22:49:04 +0530 Subject: [PATCH 10/14] Refactor viewer hover styles and remove preview state --- .../editor/src/components/editor/index.tsx | 20 +++- .../components/editor/selection-manager.tsx | 14 +-- .../renderers/ceiling/ceiling-renderer.tsx | 16 +-- .../renderers/fence/fence-renderer.tsx | 17 +-- .../roof-segment/roof-segment-renderer.tsx | 36 +----- .../renderers/roof/roof-renderer.tsx | 33 +----- .../renderers/slab/slab-renderer.tsx | 15 +-- .../stair-segment/stair-segment-renderer.tsx | 39 +------ .../renderers/stair/stair-renderer.tsx | 50 ++------ .../renderers/wall/wall-renderer.tsx | 26 +---- .../viewer/src/components/viewer/index.tsx | 6 +- .../src/components/viewer/post-processing.tsx | 109 +++++++++++++----- packages/viewer/src/index.ts | 11 +- packages/viewer/src/store/use-viewer.d.ts | 36 +----- packages/viewer/src/store/use-viewer.ts | 74 +----------- 15 files changed, 142 insertions(+), 360 deletions(-) diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index b61644af..a7591d90 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -7,7 +7,7 @@ import { spatialGridManager, useScene, } from '@pascal-app/core' -import { InteractiveSystem, useViewer, Viewer } from '@pascal-app/viewer' +import { type HoverStyles, InteractiveSystem, useViewer, Viewer } from '@pascal-app/viewer' import { memo, type ReactNode, @@ -75,6 +75,17 @@ 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 +const EDITOR_HOVER_STYLES: HoverStyles = { + default: { visibleColor: 0x00_aaff, hiddenColor: 0xf3_ff47, strength: 5, pulse: true }, + delete: { visibleColor: 0xef_4444, hiddenColor: 0x99_1b1b, strength: 6, pulse: false }, + 'paint-ready': { visibleColor: 0xf5_9e0b, hiddenColor: 0xfd_e068, strength: 5, pulse: true }, + 'paint-disabled': { + visibleColor: 0x94_a3b8, + hiddenColor: 0x47_5569, + strength: 4, + pulse: false, + }, +} /** * Wire up module-level singletons (spatial grid, space detection, SFX) for @@ -885,7 +896,10 @@ const ViewerCanvas = memo(function ViewerCanvas({ /> ) : null} - + + diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index a53c870d..bf1fd870 100755 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -25,20 +25,18 @@ import { type WallSurfaceSide, } from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' -import { useCallback, useEffect, useRef } from 'react' -import { type BufferGeometry, Color, type Material, type Mesh, type Object3D } from 'three' import { applyMaterialPresetToMaterials, createMaterial, createMaterialFromPresetRef, -} from '../../../../viewer/src/lib/materials' -import { getRoofMaterialArray } from '../../../../viewer/src/systems/roof/roof-materials' -import { + getRoofMaterialArray, getStairBodyMaterials, getStairRailingMaterial, -} from '../../../../viewer/src/systems/stair/stair-materials' -import { getVisibleWallMaterials } from '../../../../viewer/src/systems/wall/wall-materials' + getVisibleWallMaterials, + useViewer, +} from '@pascal-app/viewer' +import { useCallback, useEffect, useRef } from 'react' +import { type BufferGeometry, Color, type Material, type Mesh, type Object3D } from 'three' import { type ActivePaintMaterial, buildRoofSurfaceMaterialPatch, diff --git a/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx b/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx index 398abf54..91e7487c 100644 --- a/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx +++ b/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx @@ -8,7 +8,6 @@ import { useMemo, useRef } from 'react' import { float, mix, positionWorld, smoothstep } from 'three/tsl' import { BackSide, FrontSide, type Mesh, MeshBasicNodeMaterial } from 'three/webgpu' import { useNodeEvents } from '../../../hooks/use-node-events' -import useViewer from '../../../store/use-viewer' import { NodeRenderer } from '../node-renderer' const gridScale = 5 @@ -55,16 +54,10 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => { useRegistry(node.id, 'ceiling', ref) const handlers = useNodeEvents(node, 'ceiling') - const materialPreview = useViewer((state) => - state.materialPreview?.target === 'ceiling' && state.materialPreview.nodeId === node.id - ? state.materialPreview - : null, - ) const materials = useMemo(() => { - const preset = getMaterialPresetByRef(materialPreview?.materialPreset ?? node.materialPreset) - const props = - preset?.mapProperties ?? resolveMaterial(materialPreview?.material ?? node.material) + const preset = getMaterialPresetByRef(node.materialPreset) + const props = preset?.mapProperties ?? resolveMaterial(node.material) const color = props.color || '#999999' return getCeilingMaterials(color) }, [ @@ -73,11 +66,6 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => { node.material?.preset, node.material?.properties, node.material?.texture, - materialPreview?.materialPreset, - materialPreview?.material, - materialPreview?.material?.preset, - materialPreview?.material?.properties, - materialPreview?.material?.texture, ]) return ( diff --git a/packages/viewer/src/components/renderers/fence/fence-renderer.tsx b/packages/viewer/src/components/renderers/fence/fence-renderer.tsx index c5448ada..4e563572 100644 --- a/packages/viewer/src/components/renderers/fence/fence-renderer.tsx +++ b/packages/viewer/src/components/renderers/fence/fence-renderer.tsx @@ -7,22 +7,14 @@ import { createMaterialFromPresetRef, DEFAULT_STAIR_MATERIAL, } from '../../../lib/materials' -import useViewer from '../../../store/use-viewer' export const FenceRenderer = ({ node }: { node: FenceNode }) => { const ref = useRef(null!) const handlers = useNodeEvents(node, 'fence') - const materialPreview = useViewer((state) => - state.materialPreview?.target === 'fence' && state.materialPreview.nodeId === node.id - ? state.materialPreview - : null, - ) const material = useMemo(() => { - const presetMaterial = createMaterialFromPresetRef( - materialPreview?.materialPreset ?? node.materialPreset, - ) + const presetMaterial = createMaterialFromPresetRef(node.materialPreset) if (presetMaterial) return presetMaterial - const mat = materialPreview?.material ?? node.material + const mat = node.material if (!mat) return DEFAULT_STAIR_MATERIAL return createMaterial(mat) }, [ @@ -31,11 +23,6 @@ export const FenceRenderer = ({ node }: { node: FenceNode }) => { node.material?.preset, node.material?.properties, node.material?.texture, - materialPreview?.materialPreset, - materialPreview?.material, - materialPreview?.material?.preset, - materialPreview?.material?.properties, - materialPreview?.material?.texture, ]) useRegistry(node.id, 'fence', ref) diff --git a/packages/viewer/src/components/renderers/roof-segment/roof-segment-renderer.tsx b/packages/viewer/src/components/renderers/roof-segment/roof-segment-renderer.tsx index daebf9fb..8bd052fa 100644 --- a/packages/viewer/src/components/renderers/roof-segment/roof-segment-renderer.tsx +++ b/packages/viewer/src/components/renderers/roof-segment/roof-segment-renderer.tsx @@ -23,11 +23,6 @@ export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => { const parentNode = node.parentId ? (nodes[node.parentId as AnyNodeId] as RoofNode | undefined) : undefined - const materialPreview = useViewer((state) => - state.materialPreview?.target === 'roof' && state.materialPreview.nodeId === parentNode?.id - ? state.materialPreview - : null, - ) const placeholderGeometry = useMemo(() => { const geometry = new THREE.BufferGeometry() geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3)) @@ -38,40 +33,13 @@ export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => { return geometry }, []) - const previewParentNode = !parentNode - ? undefined - : materialPreview?.role === 'top' - ? { - ...parentNode, - topMaterial: materialPreview.material, - topMaterialPreset: materialPreview.materialPreset, - material: undefined, - materialPreset: undefined, - } - : materialPreview?.role === 'edge' - ? { - ...parentNode, - edgeMaterial: materialPreview.material, - edgeMaterialPreset: materialPreview.materialPreset, - material: undefined, - materialPreset: undefined, - } - : materialPreview?.role === 'wall' - ? { - ...parentNode, - wallMaterial: materialPreview.material, - wallMaterialPreset: materialPreview.materialPreset, - material: undefined, - materialPreset: undefined, - } - : parentNode const customMaterial = useMemo(() => { if (node.material !== undefined || typeof node.materialPreset === 'string') { return null } - return previewParentNode ? getRoofMaterialArray(previewParentNode) : null - }, [node, previewParentNode]) + return parentNode ? getRoofMaterialArray(parentNode) : null + }, [node, parentNode]) const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials diff --git a/packages/viewer/src/components/renderers/roof/roof-renderer.tsx b/packages/viewer/src/components/renderers/roof/roof-renderer.tsx index 4a4eb866..1dee2a98 100644 --- a/packages/viewer/src/components/renderers/roof/roof-renderer.tsx +++ b/packages/viewer/src/components/renderers/roof/roof-renderer.tsx @@ -14,11 +14,6 @@ export const RoofRenderer = ({ node }: { node: RoofNode }) => { const handlers = useNodeEvents(node, 'roof') const debugColors = useViewer((s) => s.debugColors) - const materialPreview = useViewer((state) => - state.materialPreview?.target === 'roof' && state.materialPreview.nodeId === node.id - ? state.materialPreview - : null, - ) const placeholderGeometry = useMemo(() => { const geometry = new THREE.BufferGeometry() geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3)) @@ -29,33 +24,7 @@ export const RoofRenderer = ({ node }: { node: RoofNode }) => { return geometry }, []) - const previewNode = - materialPreview?.role === 'top' - ? { - ...node, - topMaterial: materialPreview.material, - topMaterialPreset: materialPreview.materialPreset, - material: undefined, - materialPreset: undefined, - } - : materialPreview?.role === 'edge' - ? { - ...node, - edgeMaterial: materialPreview.material, - edgeMaterialPreset: materialPreview.materialPreset, - material: undefined, - materialPreset: undefined, - } - : materialPreview?.role === 'wall' - ? { - ...node, - wallMaterial: materialPreview.material, - wallMaterialPreset: materialPreview.materialPreset, - material: undefined, - materialPreset: undefined, - } - : node - const customMaterial = useMemo(() => getRoofMaterialArray(previewNode), [previewNode]) + const customMaterial = useMemo(() => getRoofMaterialArray(node), [node]) const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials diff --git a/packages/viewer/src/components/renderers/slab/slab-renderer.tsx b/packages/viewer/src/components/renderers/slab/slab-renderer.tsx index 0e97f23d..e2b935f6 100644 --- a/packages/viewer/src/components/renderers/slab/slab-renderer.tsx +++ b/packages/viewer/src/components/renderers/slab/slab-renderer.tsx @@ -8,7 +8,6 @@ import { createMaterial, DEFAULT_SLAB_MATERIAL, } from '../../../lib/materials' -import useViewer from '../../../store/use-viewer' const slabMaterialCache = new Map() @@ -52,15 +51,10 @@ export const SlabRenderer = ({ node }: { node: SlabNode }) => { useRegistry(node.id, 'slab', ref) const handlers = useNodeEvents(node, 'slab') - const materialPreview = useViewer((state) => - state.materialPreview?.target === 'slab' && state.materialPreview.nodeId === node.id - ? state.materialPreview - : null, - ) const material = useMemo(() => { - const resolvedMaterial = materialPreview?.material ?? node.material - const resolvedMaterialPreset = materialPreview?.materialPreset ?? node.materialPreset + const resolvedMaterial = node.material + const resolvedMaterialPreset = node.materialPreset const cacheKey = JSON.stringify({ material: resolvedMaterial ?? null, materialPreset: resolvedMaterialPreset ?? null, @@ -76,11 +70,6 @@ export const SlabRenderer = ({ node }: { node: SlabNode }) => { node.material?.properties, node.material?.texture, node.materialPreset, - materialPreview?.material, - materialPreview?.material?.preset, - materialPreview?.material?.properties, - materialPreview?.material?.texture, - materialPreview?.materialPreset, ]) return ( diff --git a/packages/viewer/src/components/renderers/stair-segment/stair-segment-renderer.tsx b/packages/viewer/src/components/renderers/stair-segment/stair-segment-renderer.tsx index 1c27a364..73c834ea 100644 --- a/packages/viewer/src/components/renderers/stair-segment/stair-segment-renderer.tsx +++ b/packages/viewer/src/components/renderers/stair-segment/stair-segment-renderer.tsx @@ -8,7 +8,6 @@ import { import { useEffect, useLayoutEffect, useMemo, useRef } from 'react' import * as THREE from 'three' import { useNodeEvents } from '../../../hooks/use-node-events' -import useViewer from '../../../store/use-viewer' import { getStraightStairSegmentBodyMaterials } from '../../../systems/stair/stair-materials' export const StairSegmentRenderer = ({ node }: { node: StairSegmentNode }) => { @@ -25,42 +24,10 @@ export const StairSegmentRenderer = ({ node }: { node: StairSegmentNode }) => { const parentNode = node.parentId ? (nodes[node.parentId as AnyNodeId] as StairNode | undefined) : undefined - const materialPreview = useViewer((state) => - state.materialPreview?.target === 'stair' && state.materialPreview.nodeId === parentNode?.id - ? state.materialPreview - : null, + const material = useMemo( + () => getStraightStairSegmentBodyMaterials(node, parentNode), + [node, parentNode], ) - const previewParentNode = !parentNode - ? undefined - : materialPreview?.role === 'railing' - ? { - ...parentNode, - railingMaterial: materialPreview.material, - railingMaterialPreset: materialPreview.materialPreset, - material: undefined, - materialPreset: undefined, - } - : materialPreview?.role === 'tread' - ? { - ...parentNode, - treadMaterial: materialPreview.material, - treadMaterialPreset: materialPreview.materialPreset, - material: undefined, - materialPreset: undefined, - } - : materialPreview?.role === 'side' - ? { - ...parentNode, - sideMaterial: materialPreview.material, - sideMaterialPreset: materialPreview.materialPreset, - material: undefined, - materialPreset: undefined, - } - : parentNode - - const material = useMemo(() => { - return getStraightStairSegmentBodyMaterials(node, previewParentNode) - }, [node, previewParentNode]) const placeholderGeometry = useMemo(() => { const geometry = new THREE.BufferGeometry() diff --git a/packages/viewer/src/components/renderers/stair/stair-renderer.tsx b/packages/viewer/src/components/renderers/stair/stair-renderer.tsx index 4bc8821a..d05ed49c 100644 --- a/packages/viewer/src/components/renderers/stair/stair-renderer.tsx +++ b/packages/viewer/src/components/renderers/stair/stair-renderer.tsx @@ -13,7 +13,6 @@ import { createMaterialFromPresetRef, DEFAULT_STAIR_MATERIAL, } from '../../../lib/materials' -import useViewer from '../../../store/use-viewer' import { getStairBodyMaterials, getStairRailingMaterial, @@ -62,55 +61,24 @@ export const StairRenderer = ({ node }: { node: StairNode }) => { }, [node.id]) const handlers = useNodeEvents(node, 'stair') - const materialPreview = useViewer((state) => - state.materialPreview?.target === 'stair' && state.materialPreview.nodeId === node.id - ? state.materialPreview - : null, - ) - const previewNode = - materialPreview?.role === 'railing' - ? { - ...node, - railingMaterial: materialPreview.material, - railingMaterialPreset: materialPreview.materialPreset, - material: undefined, - materialPreset: undefined, - } - : materialPreview?.role === 'tread' - ? { - ...node, - treadMaterial: materialPreview.material, - treadMaterialPreset: materialPreview.materialPreset, - material: undefined, - materialPreset: undefined, - } - : materialPreview?.role === 'side' - ? { - ...node, - sideMaterial: materialPreview.material, - sideMaterialPreset: materialPreview.materialPreset, - material: undefined, - materialPreset: undefined, - } - : node const material = useMemo(() => { - const presetMaterial = createMaterialFromPresetRef(previewNode.materialPreset) + const presetMaterial = createMaterialFromPresetRef(node.materialPreset) if (presetMaterial) return presetMaterial - const mat = previewNode.material + const mat = node.material if (!mat) return DEFAULT_STAIR_MATERIAL return createMaterial(mat) }, [ - previewNode.materialPreset, - previewNode.material, - previewNode.material?.preset, - previewNode.material?.properties, - previewNode.material?.texture, + node.materialPreset, + node.material, + node.material?.preset, + node.material?.properties, + node.material?.texture, ]) - const straightBodyMaterials = useMemo(() => getStairBodyMaterials(previewNode), [previewNode]) + const straightBodyMaterials = useMemo(() => getStairBodyMaterials(node), [node]) - const railingMaterial = useMemo(() => getStairRailingMaterial(previewNode), [previewNode]) + const railingMaterial = useMemo(() => getStairRailingMaterial(node), [node]) const straightPlaceholderGeometry = useMemo(() => { const geometry = new THREE.BufferGeometry() diff --git a/packages/viewer/src/components/renderers/wall/wall-renderer.tsx b/packages/viewer/src/components/renderers/wall/wall-renderer.tsx index a61fbb82..9ff38006 100644 --- a/packages/viewer/src/components/renderers/wall/wall-renderer.tsx +++ b/packages/viewer/src/components/renderers/wall/wall-renderer.tsx @@ -2,7 +2,6 @@ import { useRegistry, useScene, type WallNode } from '@pascal-app/core' import { useLayoutEffect, useRef } from 'react' import type { Mesh } from 'three' import { useNodeEvents } from '../../../hooks/use-node-events' -import useViewer from '../../../store/use-viewer' import { getVisibleWallMaterials } from '../../../systems/wall/wall-materials' import { NodeRenderer } from '../node-renderer' @@ -16,30 +15,7 @@ export const WallRenderer = ({ node }: { node: WallNode }) => { }, [node.id]) const handlers = useNodeEvents(node, 'wall') - const materialPreview = useViewer((state) => - state.materialPreview?.target === 'wall' && state.materialPreview.nodeId === node.id - ? state.materialPreview - : null, - ) - const previewNode = - materialPreview && materialPreview.role === 'interior' - ? { - ...node, - interiorMaterial: materialPreview.material, - interiorMaterialPreset: materialPreview.materialPreset, - material: undefined, - materialPreset: undefined, - } - : materialPreview && materialPreview.role === 'exterior' - ? { - ...node, - exteriorMaterial: materialPreview.material, - exteriorMaterialPreset: materialPreview.materialPreset, - material: undefined, - materialPreset: undefined, - } - : node - const material = getVisibleWallMaterials(previewNode) + const material = getVisibleWallMaterials(node) return ( diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index 42182d9a..6ee40f54 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -26,7 +26,7 @@ import { SceneRenderer } from '../renderers/scene-renderer' import FrameLimiter from './frame-limiter' import { Lights } from './lights' import { PerfMonitor } from './perf-monitor' -import PostProcessing from './post-processing' +import PostProcessing, { DEFAULT_HOVER_STYLES, type HoverStyles } from './post-processing' import { SelectionManager } from './selection-manager' import { ViewerCamera } from './viewer-camera' @@ -101,12 +101,14 @@ function GPUDeviceWatcher() { interface ViewerProps { children?: React.ReactNode + hoverStyles?: HoverStyles selectionManager?: 'default' | 'custom' perf?: boolean } const Viewer: React.FC = ({ children, + hoverStyles = DEFAULT_HOVER_STYLES, selectionManager = 'default', perf = false, }) => { @@ -165,7 +167,7 @@ const Viewer: React.FC = ({ - + {/* */} diff --git a/packages/viewer/src/components/viewer/post-processing.tsx b/packages/viewer/src/components/viewer/post-processing.tsx index 63217975..253fb927 100644 --- a/packages/viewer/src/components/viewer/post-processing.tsx +++ b/packages/viewer/src/components/viewer/post-processing.tsx @@ -47,6 +47,46 @@ const RETRY_DELAY_MS = 500 const DARK_BG = '#1f2433' const LIGHT_BG = '#ffffff' +export type HoverStyle = { + visibleColor: number + hiddenColor: number + strength: number + pulse: boolean +} + +export type HoverStyles = { + default: HoverStyle +} & Record + +const DEFAULT_HOVER_STYLE: HoverStyle = { + visibleColor: 0x00_aa_ff, + hiddenColor: 0xf3_ff_47, + strength: 5, + pulse: true, +} + +export const DEFAULT_HOVER_STYLES: HoverStyles = { + default: DEFAULT_HOVER_STYLE, + delete: { + visibleColor: 0xef_44_44, + hiddenColor: 0x99_1b_1b, + strength: 6, + pulse: false, + }, + 'paint-ready': { + visibleColor: 0xf5_9e_0b, + hiddenColor: 0xfd_e0_68, + strength: 5, + pulse: true, + }, + 'paint-disabled': { + visibleColor: 0x94_a3_b8, + hiddenColor: 0x47_55_69, + strength: 4, + pulse: false, + }, +} + function sanitizeOutlineObjects(objects: Object3D[]) { let nextIndex = 0 @@ -62,7 +102,11 @@ function sanitizeOutlineObjects(objects: Object3D[]) { objects.length = nextIndex } -const PostProcessingPasses = () => { +const PostProcessingPasses = ({ + hoverStyles = DEFAULT_HOVER_STYLES, +}: { + hoverStyles?: HoverStyles +}) => { const { gl: renderer, scene, camera } = useThree() const renderPipelineRef = useRef(null) const hasPipelineErrorRef = useRef(false) @@ -83,6 +127,10 @@ const PostProcessingPasses = () => { return l }, []) const hoverHighlightMode = useViewer((s) => s.hoverHighlightMode) + const hoverVisibleColor = useMemo(() => uniform(new Color(DEFAULT_HOVER_STYLE.visibleColor)), []) + const hoverHiddenColor = useMemo(() => uniform(new Color(DEFAULT_HOVER_STYLE.hiddenColor)), []) + const hoverStrength = useMemo(() => uniform(DEFAULT_HOVER_STYLE.strength), []) + const hoverPulseMix = useMemo(() => uniform(DEFAULT_HOVER_STYLE.pulse ? 0 : 1), []) // Subscribe to projectId so the pipeline rebuilds on project switch const projectId = useViewer((s) => s.projectId) @@ -119,6 +167,21 @@ const PostProcessingPasses = () => { } }, []) + useEffect(() => { + const style = hoverStyles[hoverHighlightMode] ?? hoverStyles.default + hoverVisibleColor.value.setHex(style.visibleColor) + hoverHiddenColor.value.setHex(style.hiddenColor) + hoverStrength.value = style.strength + hoverPulseMix.value = style.pulse ? 0 : 1 + }, [ + hoverHiddenColor, + hoverHighlightMode, + hoverPulseMix, + hoverStrength, + hoverStyles, + hoverVisibleColor, + ]) + // Build / rebuild the post-processing pipeline useEffect(() => { // Intentionally touch these so React/biome treat project switches and retry bumps @@ -248,36 +311,9 @@ const PostProcessingPasses = () => { .mul(selectedStrength) // Hovered: blue visible, yellow hidden, pulsing - const hoverVisibleColor = uniform( - 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 - : 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 pulsePeriod = uniform(3) - const osc = - hoverHighlightMode === 'delete' || hoverHighlightMode === 'paint-disabled' - ? float(1) - : oscSine(time.div(pulsePeriod).mul(2)).mul(0.5).add(0.5) // [ 0.5, 1.0 ] + const oscillating = oscSine(time.div(pulsePeriod).mul(2)).mul(0.5).add(0.5) + const osc = mix(oscillating, float(1), hoverPulseMix) const hoverOutline = outlineNode.secondaryVisibleEdge .mul(hoverVisibleColor) .add(outlineNode.secondaryHiddenEdge.mul(hoverHiddenColor)) @@ -316,7 +352,18 @@ const PostProcessingPasses = () => { } renderPipelineRef.current = null } - }, [renderer, scene, camera, hoverHighlightMode, zoneLayers, projectId, pipelineVersion]) + }, [ + camera, + hoverHiddenColor, + hoverPulseMix, + hoverStrength, + hoverVisibleColor, + pipelineVersion, + projectId, + renderer, + scene, + zoneLayers, + ]) useFrame((_, delta) => { // Animate background colour toward the current theme target (same lerp as AnimatedBackground) diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 799e1312..e867a444 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -1,12 +1,18 @@ export { default as Viewer } from './components/viewer' -export { SSGI_PARAMS } from './components/viewer/post-processing' +export type { HoverStyle, HoverStyles } from './components/viewer/post-processing' +export { + DEFAULT_HOVER_STYLES, + SSGI_PARAMS, +} from './components/viewer/post-processing' export { WalkthroughControls } from './components/viewer/walkthrough-controls' export { ASSETS_CDN_URL, resolveAssetUrl, resolveCdnUrl } from './lib/asset-url' export { SCENE_LAYER, ZONE_LAYER } from './lib/layers' export { + applyMaterialPresetToMaterials, clearMaterialCache, createDefaultMaterial, createMaterial, + createMaterialFromPresetRef, DEFAULT_CEILING_MATERIAL, DEFAULT_DOOR_MATERIAL, DEFAULT_ROOF_MATERIAL, @@ -19,3 +25,6 @@ export { mergedOutline } from './lib/merged-outline-node' export { default as useViewer } from './store/use-viewer' export { InteractiveSystem } from './systems/interactive/interactive-system' export { snapLevelsToTruePositions } from './systems/level/level-utils' +export { getRoofMaterialArray } from './systems/roof/roof-materials' +export { getStairBodyMaterials, getStairRailingMaterial } from './systems/stair/stair-materials' +export { getVisibleWallMaterials } from './systems/wall/wall-materials' diff --git a/packages/viewer/src/store/use-viewer.d.ts b/packages/viewer/src/store/use-viewer.d.ts index 64586624..1342032a 100644 --- a/packages/viewer/src/store/use-viewer.d.ts +++ b/packages/viewer/src/store/use-viewer.d.ts @@ -3,10 +3,6 @@ import type { BaseNode, BuildingNode, LevelNode, - MaterialSchema, - RoofSurfaceMaterialRole, - StairSurfaceMaterialRole, - WallSurfaceSide, ZoneNode, } from '@pascal-app/core' import type { Object3D } from 'three' @@ -20,40 +16,12 @@ type Outliner = { selectedObjects: Object3D[] hoveredObjects: Object3D[] } -type MaterialPreview = { - nodeId: string - target: 'wall' - role: WallSurfaceSide - material?: MaterialSchema - materialPreset?: string -} | { - nodeId: string - target: 'roof' - role: RoofSurfaceMaterialRole - material?: MaterialSchema - materialPreset?: string -} | { - nodeId: string - target: 'stair' - role: StairSurfaceMaterialRole - material?: MaterialSchema - materialPreset?: string -} | { - nodeId: string - target: 'fence' | 'slab' | 'ceiling' - role: 'surface' - material?: MaterialSchema - materialPreset?: string -} | null type ViewerState = { selection: SelectionPath previewSelectedIds: BaseNode['id'][] setPreviewSelectedIds: (ids: BaseNode['id'][]) => void - materialPreview: MaterialPreview - setMaterialPreview: (preview: MaterialPreview) => void - clearMaterialPreview: () => void - hoverHighlightMode: 'default' | 'delete' | 'paint-ready' | 'paint-disabled' - setHoverHighlightMode: (mode: 'default' | 'delete' | 'paint-ready' | 'paint-disabled') => void + hoverHighlightMode: string + setHoverHighlightMode: (mode: string) => void hoveredId: AnyNode['id'] | ZoneNode['id'] | null setHoveredId: (id: AnyNode['id'] | ZoneNode['id'] | null) => void cameraMode: 'perspective' | 'orthographic' diff --git a/packages/viewer/src/store/use-viewer.ts b/packages/viewer/src/store/use-viewer.ts index c431ab71..00649a63 100644 --- a/packages/viewer/src/store/use-viewer.ts +++ b/packages/viewer/src/store/use-viewer.ts @@ -1,17 +1,6 @@ 'use client' -import type { - AnyNode, - AnyNodeId, - BaseNode, - BuildingNode, - LevelNode, - MaterialSchema, - RoofSurfaceMaterialRole, - StairSurfaceMaterialRole, - WallSurfaceSide, - ZoneNode, -} from '@pascal-app/core' +import type { AnyNode, BaseNode, BuildingNode, LevelNode, ZoneNode } from '@pascal-app/core' import type { Object3D } from 'three' import { create } from 'zustand' @@ -29,59 +18,12 @@ type Outliner = { hoveredObjects: Object3D[] } -type MaterialPreview = - | { - nodeId: AnyNodeId - target: 'wall' - role: WallSurfaceSide - material?: MaterialSchema - materialPreset?: string - } - | { - nodeId: AnyNodeId - target: 'roof' - role: RoofSurfaceMaterialRole - material?: MaterialSchema - materialPreset?: string - } - | { - nodeId: AnyNodeId - target: 'stair' - role: StairSurfaceMaterialRole - material?: MaterialSchema - materialPreset?: string - } - | { - nodeId: AnyNodeId - target: 'fence' | 'slab' | 'ceiling' - role: 'surface' - material?: MaterialSchema - materialPreset?: string - } - | null - -function isSameMaterialPreview(left: MaterialPreview, right: MaterialPreview) { - if (left === right) return true - if (!left || !right) return false - - return ( - left.nodeId === right.nodeId && - left.target === right.target && - left.role === right.role && - left.material === right.material && - left.materialPreset === right.materialPreset - ) -} - type ViewerState = { selection: SelectionPath previewSelectedIds: BaseNode['id'][] setPreviewSelectedIds: (ids: BaseNode['id'][]) => void - materialPreview: MaterialPreview - setMaterialPreview: (preview: MaterialPreview) => void - clearMaterialPreview: () => void - hoverHighlightMode: 'default' | 'delete' | 'paint-ready' | 'paint-disabled' - setHoverHighlightMode: (mode: 'default' | 'delete' | 'paint-ready' | 'paint-disabled') => void + hoverHighlightMode: string + setHoverHighlightMode: (mode: string) => void hoveredId: AnyNode['id'] | ZoneNode['id'] | null setHoveredId: (id: AnyNode['id'] | ZoneNode['id'] | null) => void @@ -142,15 +84,6 @@ const useViewer = create()( selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [] }, previewSelectedIds: [], setPreviewSelectedIds: (ids) => set({ previewSelectedIds: ids }), - materialPreview: null, - setMaterialPreview: (materialPreview) => - set((state) => - isSameMaterialPreview(state.materialPreview, materialPreview) - ? state - : { materialPreview }, - ), - clearMaterialPreview: () => - set((state) => (state.materialPreview === null ? state : { materialPreview: null })), hoverHighlightMode: 'default', setHoverHighlightMode: (mode) => set((state) => (state.hoverHighlightMode === mode ? state : { hoverHighlightMode: mode })), @@ -255,7 +188,6 @@ const useViewer = create()( selectedIds: [], }, previewSelectedIds: [], - materialPreview: null, }), outliner: { selectedObjects: [], hoveredObjects: [] }, From 2b656bcf8ba980d5e63dbe47d377d425872e2884 Mon Sep 17 00:00:00 2001 From: sudhir Date: Fri, 24 Apr 2026 23:05:14 +0530 Subject: [PATCH 11/14] Remove editor hover styles from viewer defaults --- .../src/components/viewer/post-processing.tsx | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/packages/viewer/src/components/viewer/post-processing.tsx b/packages/viewer/src/components/viewer/post-processing.tsx index 253fb927..e06faa30 100644 --- a/packages/viewer/src/components/viewer/post-processing.tsx +++ b/packages/viewer/src/components/viewer/post-processing.tsx @@ -67,24 +67,6 @@ const DEFAULT_HOVER_STYLE: HoverStyle = { export const DEFAULT_HOVER_STYLES: HoverStyles = { default: DEFAULT_HOVER_STYLE, - delete: { - visibleColor: 0xef_44_44, - hiddenColor: 0x99_1b_1b, - strength: 6, - pulse: false, - }, - 'paint-ready': { - visibleColor: 0xf5_9e_0b, - hiddenColor: 0xfd_e0_68, - strength: 5, - pulse: true, - }, - 'paint-disabled': { - visibleColor: 0x94_a3_b8, - hiddenColor: 0x47_55_69, - strength: 4, - pulse: false, - }, } function sanitizeOutlineObjects(objects: Object3D[]) { From d36441fa47a13657114bda389fe6b216a45c6b4a Mon Sep 17 00:00:00 2001 From: sudhir Date: Fri, 24 Apr 2026 23:29:34 +0530 Subject: [PATCH 12/14] Add hover highlights to paint interactions --- .../components/editor/selection-manager.tsx | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index bf1fd870..1db09ba1 100755 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -87,6 +87,8 @@ type PaintPreviewCleanup = () => void type PaintInteraction = { key: string apply: (() => void) | null + hoverMode: HoverHighlightMode + hoveredId: AnyNodeId preview: (() => PaintPreviewCleanup | null) | null } @@ -743,6 +745,11 @@ export const SelectionManager = () => { role !== null && isActivePaintMaterialCompatible(activePaintMaterial, 'wall') return { key: `wall:${node.id}:${role ?? 'unsupported'}`, + hoveredId: node.id as AnyNodeId, + hoverMode: + compatible && hasActivePaintMaterial(activePaintMaterial) && role + ? 'paint-ready' + : 'paint-disabled', apply: compatible && hasActivePaintMaterial(activePaintMaterial) ? () => { @@ -780,6 +787,11 @@ export const SelectionManager = () => { role !== null && isActivePaintMaterialCompatible(activePaintMaterial, 'roof') return { key: `roof:${roofNode.id}:${role ?? 'unsupported'}`, + hoveredId: roofNode.id as AnyNodeId, + hoverMode: + compatible && hasActivePaintMaterial(activePaintMaterial) && role + ? 'paint-ready' + : 'paint-disabled', apply: compatible && hasActivePaintMaterial(activePaintMaterial) ? () => { @@ -817,6 +829,11 @@ export const SelectionManager = () => { role !== null && isActivePaintMaterialCompatible(activePaintMaterial, 'stair') return { key: `stair:${stairNode.id}:${role ?? 'unsupported'}`, + hoveredId: stairNode.id as AnyNodeId, + hoverMode: + compatible && hasActivePaintMaterial(activePaintMaterial) && role + ? 'paint-ready' + : 'paint-disabled', apply: compatible && hasActivePaintMaterial(activePaintMaterial) ? () => { @@ -848,6 +865,8 @@ export const SelectionManager = () => { return { key: `${target}:${node.id}:surface`, + hoveredId: node.id as AnyNodeId, + hoverMode: compatible ? 'paint-ready' : 'paint-disabled', apply: compatible ? () => { useScene @@ -875,6 +894,8 @@ export const SelectionManager = () => { if (disabledNodeTypes.includes(node.type)) { return { key: `${node.type}:${node.id}:unsupported`, + hoveredId: node.id as AnyNodeId, + hoverMode: 'paint-disabled', apply: null, preview: () => previewCursor('not-allowed'), } @@ -896,6 +917,8 @@ export const SelectionManager = () => { } clearActivePreview() + useViewer.setState({ hoveredId: interaction.hoveredId }) + setHoverHighlightMode(interaction.hoverMode) const restore = interaction.preview?.() if (restore) { @@ -912,6 +935,10 @@ export const SelectionManager = () => { } clearActivePreview() + if (useViewer.getState().hoveredId === interaction.hoveredId) { + useViewer.setState({ hoveredId: null }) + } + setHoverHighlightMode('default') } const onClick = (event: NodeEvent) => { @@ -932,6 +959,7 @@ export const SelectionManager = () => { } else { clearActivePreview() } + setHoverHighlightMode(interaction.hoverMode) } const allTypes = [ @@ -962,8 +990,10 @@ export const SelectionManager = () => { emitter.off(`${type}:click` as any, onClick as any) } clearActivePreview() + useViewer.setState({ hoveredId: null }) + setHoverHighlightMode('default') } - }, [curvingWall, mode, movingNode]) + }, [curvingWall, mode, movingNode, setHoverHighlightMode]) useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { From cb268509ea99d57c92cd7af973f1dbb69994e667 Mon Sep 17 00:00:00 2001 From: sudhir Date: Sat, 25 Apr 2026 00:06:31 +0530 Subject: [PATCH 13/14] Invalidate viewer on hover style updates --- packages/viewer/src/components/viewer/post-processing.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/viewer/src/components/viewer/post-processing.tsx b/packages/viewer/src/components/viewer/post-processing.tsx index e06faa30..96c8c690 100644 --- a/packages/viewer/src/components/viewer/post-processing.tsx +++ b/packages/viewer/src/components/viewer/post-processing.tsx @@ -89,7 +89,7 @@ const PostProcessingPasses = ({ }: { hoverStyles?: HoverStyles }) => { - const { gl: renderer, scene, camera } = useThree() + const { gl: renderer, invalidate, scene, camera } = useThree() const renderPipelineRef = useRef(null) const hasPipelineErrorRef = useRef(false) const retryCountRef = useRef(0) @@ -155,6 +155,7 @@ const PostProcessingPasses = ({ hoverHiddenColor.value.setHex(style.hiddenColor) hoverStrength.value = style.strength hoverPulseMix.value = style.pulse ? 0 : 1 + invalidate() }, [ hoverHiddenColor, hoverHighlightMode, @@ -162,6 +163,7 @@ const PostProcessingPasses = ({ hoverStrength, hoverStyles, hoverVisibleColor, + invalidate, ]) // Build / rebuild the post-processing pipeline From 275a42cc7f55a5b41c7c95bd57b516c8cf0fdf48 Mon Sep 17 00:00:00 2001 From: sudhir Date: Sat, 25 Apr 2026 00:15:29 +0530 Subject: [PATCH 14/14] Simplify paint material compatibility logic --- packages/core/src/index.ts | 1 - packages/core/src/material-library.ts | 40 ------------------- .../components/editor/selection-manager.tsx | 17 +++----- .../src/components/ui/action-menu/index.tsx | 8 ++-- .../ui/controls/material-picker.tsx | 36 ++++++++--------- .../src/components/ui/panels/paint-panel.tsx | 1 - packages/editor/src/lib/material-paint.ts | 34 ---------------- 7 files changed, 25 insertions(+), 112 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d8d80978..7ef4e545 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -40,7 +40,6 @@ export { export { baseMaterial, glassMaterial } from './materials' export { getCatalogMaterialById, - getMaterialCategoryTargets, getLibraryMaterialIdFromRef, getMaterialPresetByRef, getMaterialsForCategory, diff --git a/packages/core/src/material-library.ts b/packages/core/src/material-library.ts index a52ac07f..5b08bec1 100644 --- a/packages/core/src/material-library.ts +++ b/packages/core/src/material-library.ts @@ -1,7 +1,5 @@ import { type MaterialPresetPayload, - type MaterialTarget, - MaterialTarget as MaterialTargetSchema, } from './schema/material' export type MaterialCatalogItem = { @@ -24,40 +22,6 @@ export const MATERIAL_CATEGORIES = [ ] as const export type MaterialCategory = (typeof MATERIAL_CATEGORIES)[number] -export const MATERIAL_CATEGORY_TARGETS: Record = { - wood: [ - MaterialTargetSchema.enum.wall, - MaterialTargetSchema.enum.slab, - MaterialTargetSchema.enum.stair, - MaterialTargetSchema.enum['stair-segment'], - MaterialTargetSchema.enum.fence, - MaterialTargetSchema.enum.roof, - MaterialTargetSchema.enum['roof-segment'], - ], - wallpaper: [MaterialTargetSchema.enum.wall], - parquet: [ - MaterialTargetSchema.enum.slab, - MaterialTargetSchema.enum.stair, - MaterialTargetSchema.enum['stair-segment'], - ], - granite: [MaterialTargetSchema.enum.slab], - marble: [ - MaterialTargetSchema.enum.slab, - MaterialTargetSchema.enum.stair, - MaterialTargetSchema.enum['stair-segment'], - ], - other: [ - MaterialTargetSchema.enum.wall, - MaterialTargetSchema.enum.roof, - MaterialTargetSchema.enum['roof-segment'], - MaterialTargetSchema.enum.stair, - MaterialTargetSchema.enum['stair-segment'], - MaterialTargetSchema.enum.fence, - MaterialTargetSchema.enum.slab, - MaterialTargetSchema.enum.ceiling, - ], -} - export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ { id: 'wall-wood1', @@ -609,10 +573,6 @@ export function getMaterialsForCategory(category: MaterialCategory): MaterialCat return MATERIAL_CATALOG.filter((item) => item.category === category) } -export function getMaterialCategoryTargets(category: MaterialCategory): MaterialTarget[] { - return MATERIAL_CATEGORY_TARGETS[category] -} - export function getCatalogMaterialById(id?: string): MaterialCatalogItem | undefined { if (!id) return undefined return MATERIAL_CATALOG.find((item) => item.id === id) diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index 1db09ba1..884fc80d 100755 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -44,7 +44,6 @@ import { buildStairSurfaceMaterialPatch, buildWallSurfaceMaterialPatch, hasActivePaintMaterial, - isActivePaintMaterialCompatible, resolveActivePaintMaterialFromSelection, } from '../../lib/material-paint' import { sfxEmitter } from '../../lib/sfx-bus' @@ -741,8 +740,7 @@ export const SelectionManager = () => { if (node.type === 'wall') { const role = resolveWallMaterialTarget(event as WallEvent) - const compatible = - role !== null && isActivePaintMaterialCompatible(activePaintMaterial, 'wall') + const compatible = role !== null && hasActivePaintMaterial(activePaintMaterial) return { key: `wall:${node.id}:${role ?? 'unsupported'}`, hoveredId: node.id as AnyNodeId, @@ -783,8 +781,7 @@ export const SelectionManager = () => { if (!roofNode || roofNode.type !== 'roof') return null const role = resolveRoofMaterialTarget(event as RoofEvent | RoofSegmentEvent) - const compatible = - role !== null && isActivePaintMaterialCompatible(activePaintMaterial, 'roof') + const compatible = role !== null && hasActivePaintMaterial(activePaintMaterial) return { key: `roof:${roofNode.id}:${role ?? 'unsupported'}`, hoveredId: roofNode.id as AnyNodeId, @@ -825,8 +822,7 @@ export const SelectionManager = () => { if (!stairNode || stairNode.type !== 'stair') return null const role = resolveStairMaterialTarget(event as StairEvent | StairSegmentEvent) - const compatible = - role !== null && isActivePaintMaterialCompatible(activePaintMaterial, 'stair') + const compatible = role !== null && hasActivePaintMaterial(activePaintMaterial) return { key: `stair:${stairNode.id}:${role ?? 'unsupported'}`, hoveredId: stairNode.id as AnyNodeId, @@ -858,13 +854,10 @@ export const SelectionManager = () => { } if (node.type === 'fence' || node.type === 'slab' || node.type === 'ceiling') { - const target = node.type - const compatible = - isActivePaintMaterialCompatible(activePaintMaterial, target) && - hasActivePaintMaterial(activePaintMaterial) + const compatible = hasActivePaintMaterial(activePaintMaterial) return { - key: `${target}:${node.id}:surface`, + key: `${node.type}:${node.id}:surface`, hoveredId: node.id as AnyNodeId, hoverMode: compatible ? 'paint-ready' : 'paint-disabled', apply: compatible diff --git a/packages/editor/src/components/ui/action-menu/index.tsx b/packages/editor/src/components/ui/action-menu/index.tsx index e1c59cb4..f74b6180 100644 --- a/packages/editor/src/components/ui/action-menu/index.tsx +++ b/packages/editor/src/components/ui/action-menu/index.tsx @@ -40,11 +40,11 @@ function PaintMaterialTray() { return (

{ - setActivePaintMaterial({ material, category, sourceTarget: activePaintTarget }) + onChange={(material) => { + setActivePaintMaterial({ material, sourceTarget: activePaintTarget }) }} - onSelectMaterialPreset={(materialPreset, category) => { - setActivePaintMaterial({ materialPreset, category, sourceTarget: activePaintTarget }) + onSelectMaterialPreset={(materialPreset) => { + setActivePaintMaterial({ materialPreset, sourceTarget: activePaintTarget }) }} selectedMaterialPreset={activePaintMaterial?.materialPreset} value={activePaintMaterial?.material} diff --git a/packages/editor/src/components/ui/controls/material-picker.tsx b/packages/editor/src/components/ui/controls/material-picker.tsx index ce743482..b328ee5e 100755 --- a/packages/editor/src/components/ui/controls/material-picker.tsx +++ b/packages/editor/src/components/ui/controls/material-picker.tsx @@ -6,7 +6,6 @@ import { getMaterialsForCategory, MATERIAL_CATEGORIES, toLibraryMaterialRef, - type MaterialCategory, type MaterialSchema, } from '@pascal-app/core' import { useEffect, useRef, useState } from 'react' @@ -15,8 +14,8 @@ import useEditor from '../../../store/use-editor' type MaterialPickerProps = { value?: MaterialSchema selectedMaterialPreset?: string - onChange?: (material: MaterialSchema, category: MaterialCategory) => void - onSelectMaterialPreset?: (materialPreset: string, category: MaterialCategory) => void + onChange?: (material: MaterialSchema) => void + onSelectMaterialPreset?: (materialPreset: string) => void disabled?: boolean } @@ -29,7 +28,9 @@ export function MaterialPicker({ }: MaterialPickerProps) { const setPaintPanelOpen = useEditor((state) => state.setPaintPanelOpen) const [showCustom, setShowCustom] = useState(!!value?.properties) - const [selectedCategory, setSelectedCategory] = useState(MATERIAL_CATEGORIES[0]) + const [selectedCategory, setSelectedCategory] = useState<(typeof MATERIAL_CATEGORIES)[number]>( + MATERIAL_CATEGORIES[0], + ) const catalogScrollRef = useRef(null) const categoryScrollRef = useRef(null) const catalogItems = @@ -62,9 +63,7 @@ export function MaterialPicker({ if (disabled) return setShowCustom(false) setPaintPanelOpen(false) - const category = getCatalogMaterialById(materialId)?.category - if (!category) return - onSelectMaterialPreset?.(toLibraryMaterialRef(materialId), category) + onSelectMaterialPreset?.(toLibraryMaterialRef(materialId)) } useEffect(() => { @@ -113,20 +112,17 @@ export function MaterialPicker({ if (disabled) return setShowCustom(true) setPaintPanelOpen(true) - onChange?.( - { - preset: 'custom', - properties: { - color: value?.properties?.color || '#ffffff', - roughness: value?.properties?.roughness ?? 0.5, - metalness: value?.properties?.metalness ?? 0, - opacity: value?.properties?.opacity ?? 1, - transparent: value?.properties?.transparent ?? false, - side: value?.properties?.side ?? 'front', - }, + onChange?.({ + preset: 'custom', + properties: { + color: value?.properties?.color || '#ffffff', + roughness: value?.properties?.roughness ?? 0.5, + metalness: value?.properties?.metalness ?? 0, + opacity: value?.properties?.opacity ?? 1, + transparent: value?.properties?.transparent ?? false, + side: value?.properties?.side ?? 'front', }, - 'other', - ) + }) } return ( diff --git a/packages/editor/src/components/ui/panels/paint-panel.tsx b/packages/editor/src/components/ui/panels/paint-panel.tsx index 0df8c677..ffa8ec9d 100644 --- a/packages/editor/src/components/ui/panels/paint-panel.tsx +++ b/packages/editor/src/components/ui/panels/paint-panel.tsx @@ -47,7 +47,6 @@ export function PaintPanel() { transparent: nextTransparent, }, }, - category: 'other', sourceTarget: activePaintMaterial?.sourceTarget ?? activePaintTarget, }) } diff --git a/packages/editor/src/lib/material-paint.ts b/packages/editor/src/lib/material-paint.ts index e1f52156..ffdde786 100644 --- a/packages/editor/src/lib/material-paint.ts +++ b/packages/editor/src/lib/material-paint.ts @@ -8,8 +8,6 @@ import { getEffectiveStairSurfaceMaterial, getEffectiveWallSurfaceMaterial, getLibraryMaterialIdFromRef, - getMaterialCategoryTargets, - type MaterialCategory, type MaterialSchema, type MaterialTarget, type RoofNode, @@ -31,7 +29,6 @@ export type SingleSurfaceMaterialRole = 'surface' export type ActivePaintMaterial = { material?: MaterialSchema materialPreset?: string - category?: MaterialCategory sourceTarget: PaintableMaterialTarget } @@ -50,34 +47,10 @@ function getCatalogEntryForActivePaintMaterial(material: ActivePaintMaterial | n return getCatalogMaterialById(catalogId) } -function resolvePaintMaterialCategory( - material: MaterialSchema | undefined, - materialPreset: string | undefined, -): MaterialCategory | undefined { - const catalogId = getLibraryMaterialIdFromRef(materialPreset) ?? material?.id ?? undefined - const catalogEntry = getCatalogMaterialById(catalogId) - if (catalogEntry?.category) return catalogEntry.category - if (material || materialPreset === undefined) return 'other' - return undefined -} - 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) - const category = material?.category ?? catalogEntry?.category - if (!category) return false - - return getMaterialCategoryTargets(category).includes(target) -} - export function buildWallSurfaceMaterialPatch( node: WallNode, targetSide: WallSurfaceSide, @@ -200,7 +173,6 @@ export function resolveActivePaintMaterialFromSelection(params: { ? { material: surface.material, materialPreset: surface.materialPreset, - category: resolvePaintMaterialCategory(surface.material, surface.materialPreset), sourceTarget: 'wall', } : null @@ -221,7 +193,6 @@ export function resolveActivePaintMaterialFromSelection(params: { ? { material: surface.material, materialPreset: surface.materialPreset, - category: resolvePaintMaterialCategory(surface.material, surface.materialPreset), sourceTarget: 'roof', } : null @@ -242,7 +213,6 @@ export function resolveActivePaintMaterialFromSelection(params: { ? { material: surface.material, materialPreset: surface.materialPreset, - category: resolvePaintMaterialCategory(surface.material, surface.materialPreset), sourceTarget: 'stair', } : null @@ -263,10 +233,6 @@ export function resolveActivePaintMaterialFromSelection(params: { ? { material: selectedNode.material, materialPreset: selectedNode.materialPreset, - category: resolvePaintMaterialCategory( - selectedNode.material, - selectedNode.materialPreset, - ), sourceTarget: target, } : null