Add material paint mode with scrollable palette
This commit is contained in:
@@ -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()
|
||||||
|
|||||||
@@ -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 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
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
|||||||
Reference in New Issue
Block a user