Add material paint mode with scrollable palette
This commit is contained in:
@@ -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 (
|
||||
<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 ──
|
||||
|
||||
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<HTMLDivElement>(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 <DeleteCursorBadge position={position} />
|
||||
if (!active) return null
|
||||
|
||||
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 ──
|
||||
@@ -684,6 +903,10 @@ const ViewerCanvas = memo(function ViewerCanvas({
|
||||
containerRef={viewer3dRef}
|
||||
isVersionPreviewMode={isVersionPreviewMode}
|
||||
/>
|
||||
<PaintCursorLayer
|
||||
containerRef={viewer3dRef}
|
||||
isVersionPreviewMode={isVersionPreviewMode}
|
||||
/>
|
||||
{!showLoader && isCameraControlsHintVisible && !isFirstPersonMode ? (
|
||||
<ViewerCanvasControlsHint
|
||||
isPreviewMode={isPreviewMode}
|
||||
|
||||
@@ -6,23 +6,37 @@ import {
|
||||
type ItemNode,
|
||||
type NodeEvent,
|
||||
type RoofEvent,
|
||||
type RoofNode,
|
||||
type RoofSegmentEvent,
|
||||
resolveLevelId,
|
||||
sceneRegistry,
|
||||
type StairEvent,
|
||||
type StairNode,
|
||||
type StairSurfaceMaterialRole,
|
||||
type StairSegmentEvent,
|
||||
type StairSurfaceMaterialRole,
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
type WallEvent,
|
||||
type WallNode,
|
||||
type WallSurfaceSide,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
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 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'
|
||||
|
||||
const isNodeInCurrentLevel = (node: AnyNode): boolean => {
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 (
|
||||
<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 }) {
|
||||
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 }) {
|
||||
</motion.div>
|
||||
)}
|
||||
</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 */}
|
||||
<div className="flex items-center justify-center gap-1 px-2 py-1.5">
|
||||
<ControlModes />
|
||||
|
||||
@@ -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: <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 ───────────────────────────────────────────────────────────
|
||||
{
|
||||
@@ -354,7 +371,7 @@ export function EditorCommands() {
|
||||
icon: <Box className="h-4 w-4" />,
|
||||
keywords: ['export', 'glb', 'gltf', '3d', 'model', 'download'],
|
||||
execute: () => run(() => exportScene()),
|
||||
} as const,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
|
||||
@@ -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<boolean>(!!value?.properties)
|
||||
const catalogScrollRef = useRef<HTMLDivElement>(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,16 +104,21 @@ export function MaterialPicker({
|
||||
}
|
||||
|
||||
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) && (
|
||||
<div className="space-y-2">
|
||||
<div className="min-w-0 space-y-2">
|
||||
{catalogItems.length > 0 ? (
|
||||
<div className="text-gray-500 text-xs uppercase tracking-[0.16em]">Library</div>
|
||||
) : 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) => (
|
||||
<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)
|
||||
? 'border-blue-500 ring-2 ring-blue-500/30'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
@@ -101,6 +128,7 @@ export function MaterialPicker({
|
||||
title={item.label}
|
||||
type="button"
|
||||
>
|
||||
<div className="pointer-events-none absolute inset-0 rounded-[inherit] ring-1 ring-inset ring-white/12" />
|
||||
{item.previewThumbnailUrl ? (
|
||||
<img
|
||||
alt={item.label}
|
||||
@@ -130,6 +158,7 @@ export function MaterialPicker({
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCustom && onChange && (
|
||||
|
||||
@@ -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<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() {
|
||||
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(() => {
|
||||
|
||||
@@ -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<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 }[] = [
|
||||
{ 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(() => {
|
||||
|
||||
@@ -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<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() {
|
||||
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(() => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<EditorState>()(
|
||||
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<EditorState>()(
|
||||
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: {},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<ViewerState>()(
|
||||
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 }),
|
||||
|
||||
Reference in New Issue
Block a user