Add material paint mode with scrollable palette

This commit is contained in:
sudhir
2026-04-21 15:00:09 +05:30
parent 86a4a82a92
commit 5d75e29bc5
14 changed files with 1017 additions and 163 deletions
+231 -8
View File
@@ -8,7 +8,15 @@ import {
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { InteractiveSystem, useViewer, Viewer } from '@pascal-app/viewer' 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 { ViewerOverlay } from '../../components/viewer-overlay'
import { ViewerZoneSystem } from '../../components/viewer-zone-system' import { ViewerZoneSystem } from '../../components/viewer-zone-system'
import { type PresetsAdapter, PresetsProvider } from '../../contexts/presets-context' import { type PresetsAdapter, PresetsProvider } from '../../contexts/presets-context'
@@ -22,8 +30,8 @@ import {
} from '../../lib/scene' } from '../../lib/scene'
import { initSFXBus } from '../../lib/sfx-bus' import { initSFXBus } from '../../lib/sfx-bus'
import useEditor from '../../store/use-editor' import useEditor from '../../store/use-editor'
import { CeilingSystem } from '../systems/ceiling/ceiling-system'
import { CeilingSelectionAffordanceSystem } from '../systems/ceiling/ceiling-selection-affordance-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 { RoofEditSystem } from '../systems/roof/roof-edit-system'
import { StairEditSystem } from '../systems/stair/stair-edit-system' import { StairEditSystem } from '../systems/stair/stair-edit-system'
import { ZoneLabelEditorSystem } from '../systems/zone/zone-label-editor-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_COLOR = '#ef4444'
const DELETE_CURSOR_BADGE_OFFSET_X = 14 const DELETE_CURSOR_BADGE_OFFSET_X = 14
const DELETE_CURSOR_BADGE_OFFSET_Y = 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 * 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 (
<div
aria-hidden="true"
className="pointer-events-none absolute z-40"
style={{
left: position.x + PAINT_CURSOR_BADGE_OFFSET_X,
top: position.y + PAINT_CURSOR_BADGE_OFFSET_Y,
}}
>
<div
className="flex items-center gap-2 rounded-xl border border-white/5 bg-zinc-900/95 px-3 py-2 shadow-[0_8px_16px_-4px_rgba(0,0,0,0.3),0_4px_8px_-4px_rgba(0,0,0,0.2)]"
style={{
boxShadow: `0 8px 16px -4px rgba(0,0,0,0.3), 0 4px 8px -4px rgba(0,0,0,0.2), 0 0 18px ${accentColor}22`,
}}
>
<Icon
aria-hidden="true"
className="drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]"
color={accentColor}
height={16}
icon={icon}
width={16}
/>
<span className="font-medium text-[11px]" style={{ color: accentColor }}>
{label}
</span>
</div>
</div>
)
}
// ── Viewer scene content: memoized so <Viewer> doesn't re-render on mode/viewMode changes ── // ── Viewer scene content: memoized so <Viewer> doesn't re-render on mode/viewMode changes ──
const ViewerSceneContent = memo(function ViewerSceneContent({ const ViewerSceneContent = memo(function ViewerSceneContent({
@@ -552,31 +608,194 @@ function DeleteCursorLayer({
isVersionPreviewMode: boolean isVersionPreviewMode: boolean
}) { }) {
const mode = useEditor((s) => s.mode) const mode = useEditor((s) => s.mode)
const [position, setPosition] = useState<{ x: number; y: number } | null>(null) const badgeRef = useRef<HTMLDivElement>(null)
const active = mode === 'delete' && !isVersionPreviewMode const active = mode === 'delete' && !isVersionPreviewMode
useEffect(() => { useEffect(() => {
if (!active) { if (!active) {
setPosition(null) if (badgeRef.current) {
badgeRef.current.style.display = 'none'
}
return return
} }
const el = containerRef.current const el = containerRef.current
if (!el) return 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 onMove = (e: PointerEvent) => {
const rect = el.getBoundingClientRect() 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('pointermove', onMove)
el.addEventListener('pointerleave', onLeave) el.addEventListener('pointerleave', onLeave)
return () => { return () => {
if (frame !== 0) {
window.cancelAnimationFrame(frame)
}
el.removeEventListener('pointermove', onMove) el.removeEventListener('pointermove', onMove)
el.removeEventListener('pointerleave', onLeave) el.removeEventListener('pointerleave', onLeave)
} }
}, [active, containerRef]) }, [active, containerRef])
if (!(active && position)) return null if (!active) return null
return <DeleteCursorBadge position={position} />
return (
<div
className="pointer-events-none"
ref={badgeRef}
style={{ display: 'none', position: 'absolute', left: 0, top: 0 }}
>
<DeleteCursorBadge position={{ x: 0, y: 0 }} />
</div>
)
}
function PaintCursorLayer({
containerRef,
isVersionPreviewMode,
}: {
containerRef: React.RefObject<HTMLDivElement | null>
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<HTMLDivElement>(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 (
<div
className="pointer-events-none"
ref={badgeRef}
style={{ display: 'none', position: 'absolute', left: 0, top: 0 }}
>
<PaintCursorBadge
disabled={!hasMaterial || showDisabledFeedback}
icon={icon}
label={label}
position={{ x: 0, y: 0 }}
/>
</div>
)
} }
// ── Viewer canvas: memoized, subscribes to viewMode/floorplanPaneRatio internally ── // ── Viewer canvas: memoized, subscribes to viewMode/floorplanPaneRatio internally ──
@@ -684,6 +903,10 @@ const ViewerCanvas = memo(function ViewerCanvas({
containerRef={viewer3dRef} containerRef={viewer3dRef}
isVersionPreviewMode={isVersionPreviewMode} isVersionPreviewMode={isVersionPreviewMode}
/> />
<PaintCursorLayer
containerRef={viewer3dRef}
isVersionPreviewMode={isVersionPreviewMode}
/>
{!showLoader && isCameraControlsHintVisible && !isFirstPersonMode ? ( {!showLoader && isCameraControlsHintVisible && !isFirstPersonMode ? (
<ViewerCanvasControlsHint <ViewerCanvasControlsHint
isPreviewMode={isPreviewMode} isPreviewMode={isPreviewMode}
@@ -6,23 +6,37 @@ import {
type ItemNode, type ItemNode,
type NodeEvent, type NodeEvent,
type RoofEvent, type RoofEvent,
type RoofNode,
type RoofSegmentEvent, type RoofSegmentEvent,
resolveLevelId, resolveLevelId,
sceneRegistry,
type StairEvent, type StairEvent,
type StairNode, type StairNode,
type StairSurfaceMaterialRole,
type StairSegmentEvent, type StairSegmentEvent,
type StairSurfaceMaterialRole,
sceneRegistry,
useScene, useScene,
type WallEvent, type WallEvent,
type WallNode,
type WallSurfaceSide, type WallSurfaceSide,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef } from 'react' import { useCallback, useEffect, useRef } from 'react'
import { Color, type BufferGeometry, type Material, type Mesh, type Object3D } from 'three' import { type BufferGeometry, Color, type Material, type Mesh, type Object3D } from 'three'
import {
buildRoofSurfaceMaterialPatch,
buildStairSurfaceMaterialPatch,
buildWallSurfaceMaterialPatch,
hasActivePaintMaterial,
isActivePaintMaterialCompatible,
resolveActivePaintMaterialFromSelection,
} from '../../lib/material-paint'
import { sfxEmitter } from '../../lib/sfx-bus' import { sfxEmitter } from '../../lib/sfx-bus'
import useEditor, { type MaterialTargetRole, type Phase, type StructureLayer } from './../../store/use-editor' import useEditor, {
type MaterialTargetRole,
type Phase,
type StructureLayer,
} from './../../store/use-editor'
import { boxSelectHandled } from '../tools/select/box-select-tool' import { boxSelectHandled } from '../tools/select/box-select-tool'
const isNodeInCurrentLevel = (node: AnyNode): boolean => { const isNodeInCurrentLevel = (node: AnyNode): boolean => {
@@ -175,10 +189,7 @@ function getIntersectionMaterialIndex(
return group?.materialIndex return group?.materialIndex
} }
function setSelectedMaterialTargetForNode( function setSelectedMaterialTargetForNode(node: AnyNode, role: MaterialTargetRole | null) {
node: AnyNode,
role: MaterialTargetRole | null,
) {
if (!role) { if (!role) {
const currentTarget = useEditor.getState().selectedMaterialTarget const currentTarget = useEditor.getState().selectedMaterialTarget
if (currentTarget?.nodeId !== node.id) { if (currentTarget?.nodeId !== node.id) {
@@ -209,6 +220,7 @@ const HIGHLIGHT_PROFILES = {
} as const } as const
type HighlightKind = keyof typeof HIGHLIGHT_PROFILES type HighlightKind = keyof typeof HIGHLIGHT_PROFILES
type HoverHighlightMode = 'default' | 'delete' | 'paint-ready' | 'paint-disabled'
type HighlightableMaterial = Material & { type HighlightableMaterial = Material & {
color?: Color color?: Color
@@ -474,13 +486,181 @@ export const SelectionManager = () => {
const curvingWall = useEditor((s) => s.curvingWall) const curvingWall = useEditor((s) => s.curvingWall)
useEffect(() => { useEffect(() => {
setHoverHighlightMode(mode === 'delete' ? 'delete' : 'default') const nextHoverMode: HoverHighlightMode = mode === 'delete' ? 'delete' : 'default'
setHoverHighlightMode(nextHoverMode)
return () => { return () => {
setHoverHighlightMode('default') setHoverHighlightMode('default')
} }
}, [mode, setHoverHighlightMode]) }, [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(() => { useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Meta') modifierKeysRef.current.meta = true if (event.key === 'Meta') modifierKeysRef.current.meta = true
@@ -911,7 +1091,9 @@ const SelectionStateSync = () => {
const selectedNode = useScene.getState().nodes[singleSelectedId as AnyNodeId] const selectedNode = useScene.getState().nodes[singleSelectedId as AnyNodeId]
if ( if (
!selectedNode || !selectedNode ||
(selectedNode.type !== 'wall' && selectedNode.type !== 'stair' && selectedNode.type !== 'roof') (selectedNode.type !== 'wall' &&
selectedNode.type !== 'stair' &&
selectedNode.type !== 'roof')
) { ) {
setSelectedMaterialTarget(null) setSelectedMaterialTarget(null)
return return
@@ -9,7 +9,15 @@ import { cn } from './../../../lib/utils'
import useEditor from './../../../store/use-editor' import useEditor from './../../../store/use-editor'
import { ActionButton } from './action-button' 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 = { type ControlConfig = {
id: ControlId id: ControlId
@@ -54,6 +62,14 @@ const controls: ControlConfig[] = [
color: 'hover:bg-green-500/20 hover:text-green-400', color: 'hover:bg-green-500/20 hover:text-green-400',
activeColor: 'bg-green-500/20 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', id: 'furnish',
imageSrc: '/icons/couch.png', imageSrc: '/icons/couch.png',
@@ -88,6 +104,7 @@ export function ControlModes() {
const setPhase = useEditor((state) => state.setPhase) const setPhase = useEditor((state) => state.setPhase)
const setStructureLayer = useEditor((state) => state.setStructureLayer) const setStructureLayer = useEditor((state) => state.setStructureLayer)
const setSelectionTool = useEditor((state) => state.setFloorplanSelectionTool) const setSelectionTool = useEditor((state) => state.setFloorplanSelectionTool)
const primeMaterialPaintFromSelection = useEditor((state) => state.primeMaterialPaintFromSelection)
const levelId = useViewer((s) => s.selection.levelId) const levelId = useViewer((s) => s.selection.levelId)
// Only subscribe to the primitive `level` number — when walls are added to // 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 === 'site-edit') return false
if (id === 'build') if (id === 'build')
return mode === 'build' && phase === 'structure' && structureLayer === 'elements' 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 === 'furnish') return mode === 'build' && phase === 'furnish'
if (id === 'zone') if (id === 'zone')
return mode === 'build' && phase === 'structure' && structureLayer === 'zones' return mode === 'build' && phase === 'structure' && structureLayer === 'zones'
@@ -155,6 +173,15 @@ export function ControlModes() {
setStructureLayer('elements') setStructureLayer('elements')
setMode('build') 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') { } else if (id === 'furnish') {
if (getIsActive('furnish')) { if (getIsActive('furnish')) {
setMode('select') setMode('select')
@@ -1,8 +1,16 @@
'use client' 'use client'
import { getMaterialsForTarget, toLibraryMaterialRef, useScene } from '@pascal-app/core'
import { AnimatePresence, motion } from 'motion/react' 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 { TooltipProvider } from './../../../components/ui/primitives/tooltip'
import { MaterialPicker } from './../../../components/ui/controls/material-picker'
import { useReducedMotion } from './../../../hooks/use-reduced-motion' import { useReducedMotion } from './../../../hooks/use-reduced-motion'
import {
isActivePaintMaterialCompatible,
resolvePaintTargetFromSelection,
} from './../../../lib/material-paint'
import { cn } from './../../../lib/utils' import { cn } from './../../../lib/utils'
import useEditor from './../../../store/use-editor' import useEditor from './../../../store/use-editor'
import { ItemCatalog } from '../item-catalog/item-catalog' import { ItemCatalog } from '../item-catalog/item-catalog'
@@ -12,12 +20,77 @@ import { FurnishTools } from './furnish-tools'
import { StructureTools } from './structure-tools' import { StructureTools } from './structure-tools'
import { ViewToggles } from './view-toggles' 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 (
<div className="w-[42rem] max-w-[calc(100vw-2rem)]">
<MaterialPicker
hideSideControl
nodeType={activePaintTarget}
onChange={(material) => {
setActivePaintMaterial({ material, sourceTarget: activePaintTarget })
}}
onSelectMaterialPreset={(materialPreset) => {
setActivePaintMaterial({ materialPreset, sourceTarget: activePaintTarget })
}}
selectedMaterialPreset={
activePaintMaterial?.sourceTarget === activePaintTarget
? activePaintMaterial.materialPreset
: undefined
}
value={
activePaintMaterial?.sourceTarget === activePaintTarget
? activePaintMaterial.material
: undefined
}
/>
</div>
)
}
export function ActionMenu({ className }: { className?: string }) { export function ActionMenu({ className }: { className?: string }) {
const phase = useEditor((state) => state.phase) const phase = useEditor((state) => state.phase)
const mode = useEditor((state) => state.mode) const mode = useEditor((state) => state.mode)
const tool = useEditor((state) => state.tool) const tool = useEditor((state) => state.tool)
const catalogCategory = useEditor((state) => state.catalogCategory) const catalogCategory = useEditor((state) => state.catalogCategory)
const reducedMotion = useReducedMotion() const reducedMotion = useReducedMotion()
const showPaintTray = useMemo(() => mode === 'material-paint', [mode])
const transition = reducedMotion const transition = reducedMotion
? { duration: 0 } ? { duration: 0 }
: { type: 'spring' as const, bounce: 0.2, duration: 0.4 } : { type: 'spring' as const, bounce: 0.2, duration: 0.4 }
@@ -138,6 +211,38 @@ export function ActionMenu({ className }: { className?: string }) {
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
<AnimatePresence>
{showPaintTray && (
<motion.div
animate={{
opacity: 1,
maxHeight: 96,
paddingTop: 8,
paddingBottom: 8,
borderBottomWidth: 1,
}}
className={cn('overflow-hidden border-border border-b px-3')}
exit={{
opacity: 0,
maxHeight: 0,
paddingTop: 0,
paddingBottom: 0,
borderBottomWidth: 0,
}}
initial={{
opacity: 0,
maxHeight: 0,
paddingTop: 0,
paddingBottom: 0,
borderBottomWidth: 0,
}}
transition={transition}
>
<PaintMaterialTray />
</motion.div>
)}
</AnimatePresence>
{/* Control Mode Row - Always visible, centered */} {/* Control Mode Row - Always visible, centered */}
<div className="flex items-center justify-center gap-1 px-2 py-1.5"> <div className="flex items-center justify-center gap-1 px-2 py-1.5">
<ControlModes /> <ControlModes />
@@ -23,6 +23,7 @@ import {
Moon, Moon,
MousePointer2, MousePointer2,
Package, Package,
PaintBucket,
PencilLine, PencilLine,
Plus, Plus,
Redo2, Redo2,
@@ -48,6 +49,7 @@ export function EditorCommands() {
const setMode = useEditor((s) => s.setMode) const setMode = useEditor((s) => s.setMode)
const setTool = useEditor((s) => s.setTool) const setTool = useEditor((s) => s.setTool)
const setStructureLayer = useEditor((s) => s.setStructureLayer) const setStructureLayer = useEditor((s) => s.setStructureLayer)
const primeMaterialPaintFromSelection = useEditor((s) => s.primeMaterialPaintFromSelection)
const isPreviewMode = useEditor((s) => s.isPreviewMode) const isPreviewMode = useEditor((s) => s.isPreviewMode)
const setPreviewMode = useEditor((s) => s.setPreviewMode) const setPreviewMode = useEditor((s) => s.setPreviewMode)
@@ -149,6 +151,21 @@ export function EditorCommands() {
useScene.getState().deleteNodes(selectedIds as any[]) useScene.getState().deleteNodes(selectedIds as any[])
}), }),
}, },
{
id: 'editor.mode.material-paint',
label: 'Material Paint',
group: 'Scene',
icon: <PaintBucket className="h-4 w-4" />,
keywords: ['paint', 'material', 'texture', 'bucket', 'surface'],
shortcut: ['P'],
execute: () =>
run(() => {
primeMaterialPaintFromSelection()
setPhase('structure')
setStructureLayer('elements')
setMode('material-paint')
}),
},
// ── Levels ─────────────────────────────────────────────────────────── // ── Levels ───────────────────────────────────────────────────────────
{ {
@@ -354,7 +371,7 @@ export function EditorCommands() {
icon: <Box className="h-4 w-4" />, icon: <Box className="h-4 w-4" />,
keywords: ['export', 'glb', 'gltf', '3d', 'model', 'download'], keywords: ['export', 'glb', 'gltf', '3d', 'model', 'download'],
execute: () => run(() => exportScene()), execute: () => run(() => exportScene()),
} as const, },
] ]
: []), : []),
{ {
@@ -6,7 +6,7 @@ import {
type MaterialSchema, type MaterialSchema,
type MaterialTarget, type MaterialTarget,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useEffect, useState } from 'react' import { useEffect, useRef, useState } from 'react'
type MaterialPickerProps = { type MaterialPickerProps = {
nodeType?: MaterialTarget nodeType?: MaterialTarget
@@ -28,6 +28,7 @@ export function MaterialPicker({
disabled = false, disabled = false,
}: MaterialPickerProps) { }: MaterialPickerProps) {
const [showCustom, setShowCustom] = useState<boolean>(!!value?.properties) const [showCustom, setShowCustom] = useState<boolean>(!!value?.properties)
const catalogScrollRef = useRef<HTMLDivElement>(null)
const catalogItems = nodeType ? getMaterialsForTarget(nodeType) : [] const catalogItems = nodeType ? getMaterialsForTarget(nodeType) : []
useEffect(() => { useEffect(() => {
@@ -51,6 +52,27 @@ export function MaterialPicker({
onSelectMaterialPreset?.(toLibraryMaterialRef(materialId)) 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 = () => { const handleCustomOpen = () => {
if (disabled) return if (disabled) return
setShowCustom(true) setShowCustom(true)
@@ -82,16 +104,21 @@ export function MaterialPicker({
} }
return ( return (
<div className={`space-y-3 ${disabled ? 'pointer-events-none opacity-50' : ''}`}> <div className={`min-w-0 space-y-3 ${disabled ? 'pointer-events-none opacity-50' : ''}`}>
{(catalogItems.length > 0 || onChange) && ( {(catalogItems.length > 0 || onChange) && (
<div className="space-y-2"> <div className="min-w-0 space-y-2">
{catalogItems.length > 0 ? ( {catalogItems.length > 0 ? (
<div className="text-gray-500 text-xs uppercase tracking-[0.16em]">Library</div> <div className="text-gray-500 text-xs uppercase tracking-[0.16em]">Library</div>
) : null} ) : null}
<div className="flex flex-wrap gap-1.5"> <div
className="w-full max-w-full overflow-x-auto overflow-y-hidden"
ref={catalogScrollRef}
style={{ msOverflowStyle: 'none', scrollbarWidth: 'none' }}
>
<div className="flex min-w-max gap-1.5 pb-1">
{catalogItems.map((item) => ( {catalogItems.map((item) => (
<button <button
className={`h-14 w-14 shrink-0 overflow-hidden rounded-lg border transition-all ${ className={`relative h-14 w-14 shrink-0 overflow-hidden rounded-lg border transition-all ${
selectedCatalogId === toLibraryMaterialRef(item.id) selectedCatalogId === toLibraryMaterialRef(item.id)
? 'border-blue-500 ring-2 ring-blue-500/30' ? 'border-blue-500 ring-2 ring-blue-500/30'
: 'border-gray-300 hover:border-gray-400' : 'border-gray-300 hover:border-gray-400'
@@ -101,6 +128,7 @@ export function MaterialPicker({
title={item.label} title={item.label}
type="button" type="button"
> >
<div className="pointer-events-none absolute inset-0 rounded-[inherit] ring-1 ring-inset ring-white/12" />
{item.previewThumbnailUrl ? ( {item.previewThumbnailUrl ? (
<img <img
alt={item.label} alt={item.label}
@@ -130,6 +158,7 @@ export function MaterialPicker({
) : null} ) : null}
</div> </div>
</div> </div>
</div>
)} )}
{showCustom && onChange && ( {showCustom && onChange && (
@@ -6,7 +6,6 @@ import {
getEffectiveRoofSurfaceMaterial, getEffectiveRoofSurfaceMaterial,
type MaterialSchema, type MaterialSchema,
type RoofNode, type RoofNode,
type RoofSurfaceMaterialRole,
RoofNode as RoofNodeSchema, RoofNode as RoofNodeSchema,
type RoofSegmentNode, type RoofSegmentNode,
RoofSegmentNode as RoofSegmentNodeSchema, RoofSegmentNode as RoofSegmentNodeSchema,
@@ -16,6 +15,7 @@ import { useViewer } from '@pascal-app/viewer'
import { Copy, Move, Plus, Trash2 } from 'lucide-react' import { Copy, Move, Plus, Trash2 } from 'lucide-react'
import { useCallback } from 'react' import { useCallback } from 'react'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
import { buildRoofSurfaceMaterialPatch } from '../../../lib/material-paint'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button' import { ActionButton, ActionGroup } from '../controls/action-button'
@@ -24,32 +24,6 @@ import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control' import { SliderControl } from '../controls/slider-control'
import { PanelWrapper } from './panel-wrapper' import { PanelWrapper } from './panel-wrapper'
function buildRoofSurfaceMaterialPatch(
node: RoofNode,
targetRole: RoofSurfaceMaterialRole,
material: MaterialSchema | undefined,
materialPreset: string | undefined,
): Partial<RoofNode> {
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() { export function RoofPanel() {
const selectedId = useViewer((s) => s.selection.selectedIds[0]) const selectedId = useViewer((s) => s.selection.selectedIds[0])
const setSelection = useViewer((s) => s.setSelection) const setSelection = useViewer((s) => s.setSelection)
@@ -57,6 +31,8 @@ export function RoofPanel() {
const createNode = useScene((s) => s.createNode) const createNode = useScene((s) => s.createNode)
const setMovingNode = useEditor((s) => s.setMovingNode) const setMovingNode = useEditor((s) => s.setMovingNode)
const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget) const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
const setActivePaintMaterial = useEditor((s) => s.setActivePaintMaterial)
const setActivePaintTarget = useEditor((s) => s.setActivePaintTarget)
const node = useScene((s) => const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as RoofNode | undefined) : undefined, selectedId ? (s.nodes[selectedId as AnyNode['id']] as RoofNode | undefined) : undefined,
@@ -93,17 +69,21 @@ export function RoofPanel() {
const handleTargetedMaterialChange = useCallback( const handleTargetedMaterialChange = useCallback(
(material: MaterialSchema) => { (material: MaterialSchema) => {
if (!node || !materialTargetRole) return if (!node || !materialTargetRole) return
setActivePaintTarget('roof')
setActivePaintMaterial({ material, sourceTarget: 'roof' })
handleUpdate(buildRoofSurfaceMaterialPatch(node, materialTargetRole, material, undefined)) handleUpdate(buildRoofSurfaceMaterialPatch(node, materialTargetRole, material, undefined))
}, },
[handleUpdate, materialTargetRole, node], [handleUpdate, materialTargetRole, node, setActivePaintMaterial, setActivePaintTarget],
) )
const handleTargetedMaterialPresetChange = useCallback( const handleTargetedMaterialPresetChange = useCallback(
(materialPreset: string) => { (materialPreset: string) => {
if (!node || !materialTargetRole) return if (!node || !materialTargetRole) return
setActivePaintTarget('roof')
setActivePaintMaterial({ materialPreset, sourceTarget: 'roof' })
handleUpdate(buildRoofSurfaceMaterialPatch(node, materialTargetRole, undefined, materialPreset)) handleUpdate(buildRoofSurfaceMaterialPatch(node, materialTargetRole, undefined, materialPreset))
}, },
[handleUpdate, materialTargetRole, node], [handleUpdate, materialTargetRole, node, setActivePaintMaterial, setActivePaintTarget],
) )
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
@@ -8,7 +8,6 @@ import {
type MaterialSchema, type MaterialSchema,
type StairNode, type StairNode,
type StairRailingMode, type StairRailingMode,
type StairSurfaceMaterialRole,
type StairSlabOpeningMode, type StairSlabOpeningMode,
type StairTopLandingMode, type StairTopLandingMode,
type StairType, type StairType,
@@ -21,6 +20,7 @@ import { useViewer } from '@pascal-app/viewer'
import { Copy, Move, Plus, Trash2 } from 'lucide-react' import { Copy, Move, Plus, Trash2 } from 'lucide-react'
import { useCallback } from 'react' import { useCallback } from 'react'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
import { buildStairSurfaceMaterialPatch } from '../../../lib/material-paint'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE } from '../../tools/stair/stair-defaults' 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 { ToggleControl } from '../controls/toggle-control'
import { PanelWrapper } from './panel-wrapper' import { PanelWrapper } from './panel-wrapper'
function buildStairSurfaceMaterialPatch(
node: StairNode,
targetRole: StairSurfaceMaterialRole,
material: MaterialSchema | undefined,
materialPreset: string | undefined,
): Partial<StairNode> {
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 }[] = [ const RAILING_MODE_OPTIONS: { label: string; value: StairRailingMode }[] = [
{ label: 'None', value: 'none' }, { label: 'None', value: 'none' },
{ label: 'Left', value: 'left' }, { label: 'Left', value: 'left' },
@@ -91,6 +65,8 @@ export function StairPanel() {
const createNodes = useScene((s) => s.createNodes) const createNodes = useScene((s) => s.createNodes)
const setMovingNode = useEditor((s) => s.setMovingNode) const setMovingNode = useEditor((s) => s.setMovingNode)
const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget) const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
const setActivePaintMaterial = useEditor((s) => s.setActivePaintMaterial)
const setActivePaintTarget = useEditor((s) => s.setActivePaintTarget)
const node = useScene((s) => const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as StairNode | undefined) : undefined, selectedId ? (s.nodes[selectedId as AnyNode['id']] as StairNode | undefined) : undefined,
@@ -135,17 +111,21 @@ export function StairPanel() {
const handleTargetedMaterialChange = useCallback( const handleTargetedMaterialChange = useCallback(
(material: MaterialSchema) => { (material: MaterialSchema) => {
if (!node || !materialTargetRole) return if (!node || !materialTargetRole) return
setActivePaintTarget('stair')
setActivePaintMaterial({ material, sourceTarget: 'stair' })
handleUpdate(buildStairSurfaceMaterialPatch(node, materialTargetRole, material, undefined)) handleUpdate(buildStairSurfaceMaterialPatch(node, materialTargetRole, material, undefined))
}, },
[handleUpdate, materialTargetRole, node], [handleUpdate, materialTargetRole, node, setActivePaintMaterial, setActivePaintTarget],
) )
const handleTargetedMaterialPresetChange = useCallback( const handleTargetedMaterialPresetChange = useCallback(
(materialPreset: string) => { (materialPreset: string) => {
if (!node || !materialTargetRole) return if (!node || !materialTargetRole) return
setActivePaintTarget('stair')
setActivePaintMaterial({ materialPreset, sourceTarget: 'stair' })
handleUpdate(buildStairSurfaceMaterialPatch(node, materialTargetRole, undefined, materialPreset)) handleUpdate(buildStairSurfaceMaterialPatch(node, materialTargetRole, undefined, materialPreset))
}, },
[handleUpdate, materialTargetRole, node], [handleUpdate, materialTargetRole, node, setActivePaintMaterial, setActivePaintTarget],
) )
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
@@ -11,13 +11,13 @@ import {
normalizeWallCurveOffset, normalizeWallCurveOffset,
type MaterialSchema, type MaterialSchema,
useScene, useScene,
type WallSurfaceSide,
type WallNode, type WallNode,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { Move, Spline } from 'lucide-react' import { Move, Spline } from 'lucide-react'
import { useCallback, useMemo } from 'react' import { useCallback, useMemo } from 'react'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import { buildWallSurfaceMaterialPatch } from '../../../lib/material-paint'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button' import { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker' import { MaterialPicker } from '../controls/material-picker'
@@ -25,32 +25,6 @@ import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control' import { SliderControl } from '../controls/slider-control'
import { PanelWrapper } from './panel-wrapper' import { PanelWrapper } from './panel-wrapper'
function buildWallSurfaceMaterialPatch(
node: WallNode,
targetSide: WallSurfaceSide | null,
material: MaterialSchema | undefined,
materialPreset: string | undefined,
): Partial<WallNode> {
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() { export function WallPanel() {
const selectedId = useViewer((s) => s.selection.selectedIds[0]) const selectedId = useViewer((s) => s.selection.selectedIds[0])
const setSelection = useViewer((s) => s.setSelection) const setSelection = useViewer((s) => s.setSelection)
@@ -58,6 +32,8 @@ export function WallPanel() {
const setMovingNode = useEditor((s) => s.setMovingNode) const setMovingNode = useEditor((s) => s.setMovingNode)
const setCurvingWall = useEditor((s) => s.setCurvingWall) const setCurvingWall = useEditor((s) => s.setCurvingWall)
const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget) const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
const setActivePaintMaterial = useEditor((s) => s.setActivePaintMaterial)
const setActivePaintTarget = useEditor((s) => s.setActivePaintTarget)
const node = useScene((s) => const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as WallNode | undefined) : undefined, selectedId ? (s.nodes[selectedId as AnyNode['id']] as WallNode | undefined) : undefined,
@@ -143,17 +119,21 @@ export function WallPanel() {
const handleMaterialPresetChange = useCallback( const handleMaterialPresetChange = useCallback(
(materialPreset: string) => { (materialPreset: string) => {
if (!node || !materialTargetSide) return if (!node || !materialTargetSide) return
setActivePaintTarget('wall')
setActivePaintMaterial({ materialPreset, sourceTarget: 'wall' })
handleUpdate(buildWallSurfaceMaterialPatch(node, materialTargetSide, undefined, materialPreset)) handleUpdate(buildWallSurfaceMaterialPatch(node, materialTargetSide, undefined, materialPreset))
}, },
[handleUpdate, materialTargetSide, node], [handleUpdate, materialTargetSide, node, setActivePaintMaterial, setActivePaintTarget],
) )
const handleCustomMaterialChange = useCallback( const handleCustomMaterialChange = useCallback(
(material: MaterialSchema) => { (material: MaterialSchema) => {
if (!node || !materialTargetSide) return if (!node || !materialTargetSide) return
setActivePaintTarget('wall')
setActivePaintMaterial({ material, sourceTarget: 'wall' })
handleUpdate(buildWallSurfaceMaterialPatch(node, materialTargetSide, material, undefined)) handleUpdate(buildWallSurfaceMaterialPatch(node, materialTargetSide, material, undefined))
}, },
[handleUpdate, materialTargetSide, node], [handleUpdate, materialTargetSide, node, setActivePaintMaterial, setActivePaintTarget],
) )
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
@@ -88,6 +88,13 @@ export const useKeyboard = ({ isVersionPreviewMode = false } = {}) => {
if (isVersionPreviewMode) return if (isVersionPreviewMode) return
e.preventDefault() e.preventDefault()
useEditor.getState().setMode('delete') 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)) { } else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) {
if (isVersionPreviewMode) return if (isVersionPreviewMode) return
e.preventDefault() e.preventDefault()
+237
View File
@@ -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<MaterialTarget, 'wall' | 'roof' | 'stair'>
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<WallNode> {
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<RoofNode> {
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<StairNode> {
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<string, any>
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<string, any>
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
}
+73 -5
View File
@@ -9,14 +9,14 @@ import {
type FenceNode, type FenceNode,
type ItemNode, type ItemNode,
type LevelNode, type LevelNode,
type RoofSurfaceMaterialRole,
type RoofNode, type RoofNode,
type RoofSegmentNode, type RoofSegmentNode,
type RoofSurfaceMaterialRole,
type SlabNode, type SlabNode,
type Space, type Space,
type StairSurfaceMaterialRole,
type StairNode, type StairNode,
type StairSegmentNode, type StairSegmentNode,
type StairSurfaceMaterialRole,
useScene, useScene,
type WallNode, type WallNode,
type WallSurfaceSide, type WallSurfaceSide,
@@ -26,6 +26,12 @@ import { useViewer } from '@pascal-app/viewer'
import { create } from 'zustand' import { create } from 'zustand'
import { persist } from 'zustand/middleware' import { persist } from 'zustand/middleware'
import { getDefaultCatalogItem } from '../components/ui/item-catalog/catalog-items' 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_ACTIVE_SIDEBAR_PANEL = 'site'
const DEFAULT_FLOORPLAN_PANE_RATIO = 0.5 const DEFAULT_FLOORPLAN_PANE_RATIO = 0.5
@@ -37,7 +43,7 @@ export type SplitOrientation = 'horizontal' | 'vertical'
export type Phase = 'site' | 'structure' | 'furnish' 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) // Structure mode tools (building elements)
export type StructureTool = export type StructureTool =
@@ -84,13 +90,22 @@ export type MovingWallEndpoint = {
endpoint: 'start' | 'end' endpoint: 'start' | 'end'
} }
export type MaterialTargetRole = WallSurfaceSide | StairSurfaceMaterialRole | RoofSurfaceMaterialRole export type MaterialTargetRole =
| WallSurfaceSide
| StairSurfaceMaterialRole
| RoofSurfaceMaterialRole
export type SelectedMaterialTarget = { export type SelectedMaterialTarget = {
nodeId: AnyNodeId nodeId: AnyNodeId
role: MaterialTargetRole role: MaterialTargetRole
} }
type MaterialPaintSelectionSnapshot = {
selectedId: string | null
activePaintTarget: PaintableMaterialTarget
activePaintMaterial: ActivePaintMaterial | null
}
type EditorState = { type EditorState = {
phase: Phase phase: Phase
setPhase: (phase: Phase) => void setPhase: (phase: Phase) => void
@@ -140,6 +155,15 @@ type EditorState = {
setCurvingWall: (wall: WallNode | null) => void setCurvingWall: (wall: WallNode | null) => void
selectedMaterialTarget: SelectedMaterialTarget | null selectedMaterialTarget: SelectedMaterialTarget | null
setSelectedMaterialTarget: (target: SelectedMaterialTarget | null) => void 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 selectedReferenceId: string | null
setSelectedReferenceId: (id: string | null) => void setSelectedReferenceId: (id: string | null) => void
// Space detection for cutaway mode // Space detection for cutaway mode
@@ -219,7 +243,7 @@ function normalizeModeForPhase(phase: Phase, mode: Mode | undefined): Mode {
return 'select' return 'select'
} }
return mode === 'build' || mode === 'delete' ? mode : 'select' return mode === 'build' || mode === 'delete' || mode === 'material-paint' ? mode : 'select'
} }
function normalizeFloorplanPaneRatio(value: unknown): number { function normalizeFloorplanPaneRatio(value: unknown): number {
@@ -457,6 +481,8 @@ const useEditor = create<EditorState>()(
const category = get().catalogCategory ?? 'furniture' const category = get().catalogCategory ?? 'furniture'
set({ selectedItem: getDefaultSelectedItemForCategory(category) }) set({ selectedItem: getDefaultSelectedItemForCategory(category) })
} }
} else if (mode === 'material-paint') {
get().primeMaterialPaintFromSelection()
} }
// When leaving build mode, clear tool // When leaving build mode, clear tool
else if (tool) { else if (tool) {
@@ -517,6 +543,48 @@ const useEditor = create<EditorState>()(
setCurvingWall: (wall) => set({ curvingWall: wall }), setCurvingWall: (wall) => set({ curvingWall: wall }),
selectedMaterialTarget: null, selectedMaterialTarget: null,
setSelectedMaterialTarget: (target) => set({ selectedMaterialTarget: target }), 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, selectedReferenceId: null,
setSelectedReferenceId: (id) => set({ selectedReferenceId: id }), setSelectedReferenceId: (id) => set({ selectedReferenceId: id }),
spaces: {}, spaces: {},
@@ -249,15 +249,33 @@ const PostProcessingPasses = () => {
// Hovered: blue visible, yellow hidden, pulsing // Hovered: blue visible, yellow hidden, pulsing
const hoverVisibleColor = uniform( 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( 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 pulsePeriod = uniform(3)
const osc = const osc =
hoverHighlightMode === 'delete' hoverHighlightMode === 'delete' || hoverHighlightMode === 'paint-disabled'
? float(1) ? float(1)
: oscSine(time.div(pulsePeriod).mul(2)).mul(0.5).add(0.5) // [ 0.5, 1.0 ] : oscSine(time.div(pulsePeriod).mul(2)).mul(0.5).add(0.5) // [ 0.5, 1.0 ]
const hoverOutline = outlineNode.secondaryVisibleEdge const hoverOutline = outlineNode.secondaryVisibleEdge
+5 -4
View File
@@ -22,8 +22,8 @@ type ViewerState = {
selection: SelectionPath selection: SelectionPath
previewSelectedIds: BaseNode['id'][] previewSelectedIds: BaseNode['id'][]
setPreviewSelectedIds: (ids: BaseNode['id'][]) => void setPreviewSelectedIds: (ids: BaseNode['id'][]) => void
hoverHighlightMode: 'default' | 'delete' hoverHighlightMode: 'default' | 'delete' | 'paint-ready' | 'paint-disabled'
setHoverHighlightMode: (mode: 'default' | 'delete') => void setHoverHighlightMode: (mode: 'default' | 'delete' | 'paint-ready' | 'paint-disabled') => void
hoveredId: AnyNode['id'] | ZoneNode['id'] | null hoveredId: AnyNode['id'] | ZoneNode['id'] | null
setHoveredId: (id: AnyNode['id'] | ZoneNode['id'] | null) => void setHoveredId: (id: AnyNode['id'] | ZoneNode['id'] | null) => void
@@ -85,9 +85,10 @@ const useViewer = create<ViewerState>()(
previewSelectedIds: [], previewSelectedIds: [],
setPreviewSelectedIds: (ids) => set({ previewSelectedIds: ids }), setPreviewSelectedIds: (ids) => set({ previewSelectedIds: ids }),
hoverHighlightMode: 'default', hoverHighlightMode: 'default',
setHoverHighlightMode: (mode) => set({ hoverHighlightMode: mode }), setHoverHighlightMode: (mode) =>
set((state) => (state.hoverHighlightMode === mode ? state : { hoverHighlightMode: mode })),
hoveredId: null, hoveredId: null,
setHoveredId: (id) => set({ hoveredId: id }), setHoveredId: (id) => set((state) => (state.hoveredId === id ? state : { hoveredId: id })),
cameraMode: 'perspective', cameraMode: 'perspective',
setCameraMode: (mode) => set({ cameraMode: mode }), setCameraMode: (mode) => set({ cameraMode: mode }),